Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 53 additions & 9 deletions apps/cli/src/daemon/sessions/onHappySessionWebhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { logger } from '@/ui/logger';

import os from 'node:os';
import path from 'node:path';
import { execSync } from 'node:child_process';

import { findHappyProcessByPid } from '../doctor';
import type { TrackedSession } from '../types';
Expand All @@ -17,6 +18,24 @@ function resolveTildePath(inputPath: string): string {
return inputPath;
}

/**
* Get the parent PID of a process.
*
* Used to detect wrapper-script scenarios where the daemon spawns a wrapper
* (e.g. Node.js entrypoint) that in turn spawns the actual session binary.
* Returns null on Windows or if the lookup fails.
*/
function getParentPid(pid: number): number | null {
if (process.platform === 'win32') return null;
try {
const stdout = execSync(`ps -o ppid= -p ${pid}`, { encoding: 'utf-8', timeout: 1000 });
const ppid = parseInt(stdout.trim(), 10);
return Number.isFinite(ppid) ? ppid : null;
} catch {
return null;
}
}

export function createOnHappySessionWebhook(params: Readonly<{
pidToTrackedSession: Map<number, TrackedSession>;
pidToAwaiter: Map<number, (session: TrackedSession) => void>;
Expand Down Expand Up @@ -86,15 +105,40 @@ export function createOnHappySessionWebhook(params: Readonly<{
logger.debug(`[DAEMON RUN] Refreshed externally-started session ${sessionId}`);
}
} else if (!existingSession) {
// New session started externally
const trackedSession: TrackedSession = {
startedBy: 'happy directly - likely by user from terminal',
happySessionId: sessionId,
happySessionMetadataFromLocalWebhook: normalizedMetadata,
pid
};
pidToTrackedSession.set(pid, trackedSession);
logger.debug(`[DAEMON RUN] Registered externally-started session ${sessionId}`);
// PID not in tracked map. Check if this is a child of a tracked PID —
// this happens when a wrapper script (e.g. Node.js entrypoint) spawns
// the actual session binary as a child process, causing a PID mismatch
// between what the daemon spawned and what the session reports.
const ppid = getParentPid(pid);
const parentSession = ppid ? pidToTrackedSession.get(ppid) : null;

if (parentSession && parentSession.startedBy === 'daemon') {
// Re-key the tracked session from wrapper PID to actual session PID
pidToTrackedSession.delete(ppid);
parentSession.pid = pid;
parentSession.happySessionId = sessionId;
parentSession.happySessionMetadataFromLocalWebhook = normalizedMetadata;
pidToTrackedSession.set(pid, parentSession);
logger.debug(`[DAEMON RUN] Re-keyed daemon session from wrapper PID ${ppid} to actual PID ${pid}`);

// Resolve any awaiter that was waiting on the wrapper PID
const awaiter = pidToAwaiter.get(ppid);
if (awaiter) {
pidToAwaiter.delete(ppid);
awaiter(parentSession);
logger.debug(`[DAEMON RUN] Resolved session awaiter via parent PID ${ppid}`);
}
} else {
// New session started externally (not by this daemon)
const trackedSession: TrackedSession = {
startedBy: 'happy directly - likely by user from terminal',
happySessionId: sessionId,
happySessionMetadataFromLocalWebhook: normalizedMetadata,
pid
};
pidToTrackedSession.set(pid, trackedSession);
logger.debug(`[DAEMON RUN] Registered externally-started session ${sessionId}`);
}
}

// Best-effort: write/update marker so future daemon restarts can reattach.
Expand Down