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 });
Comment on lines +28 to +31
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add input validation to ensure pid is a number before using in shell command

Suggested change
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 });
function getParentPid(pid: number): number | null {
if (process.platform === 'win32') return null;
// Runtime validation for shell command safety
if (!Number.isInteger(pid) || pid <= 0) return null;
try {

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}`);
}
Comment on lines +108 to +130
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add test coverage for the parent PID matching logic - should verify:

  • child process correctly re-keyed when PPID matches daemon-spawned session
  • awaiter resolved when parent PID match found
  • falls back to externally-started when PPID doesn't match
  • handles getParentPid returning null gracefully

} 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