Skip to content
This repository was archived by the owner on Feb 14, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8e14cfa
fix(mode-switch): signal forwarding prevents orphaned processes (#11)
michelhelsdingen Feb 3, 2026
fd1d379
fix: re-enable terminal title control via change_title MCP tool
michelhelsdingen Feb 6, 2026
99cd2d7
feat: image upload support + fix CLAUDECODE nesting crash
michelhelsdingen Feb 18, 2026
eb1ea60
feat: forward total_cost_usd from SDK result to server usage reports
michelhelsdingen Feb 18, 2026
9bb6d1b
feat(coordinator): add types, class, and barrel export for auto-pilot
michelhelsdingen Feb 18, 2026
ee085ae
test(coordinator): add unit tests for task queue management
michelhelsdingen Feb 18, 2026
12ec0e8
feat(coordinator): integrate auto-pilot into session loop with RPC ha…
michelhelsdingen Feb 18, 2026
761edff
feat(coordinator): emit state changes to mobile app via session events
michelhelsdingen Feb 18, 2026
03a735c
chore: bump version to 0.15.0 for cost tracking and coordinator features
michelhelsdingen Feb 18, 2026
2b7da3a
fix: prevent EPIPE crash when changing title in remote sessions
michelhelsdingen Feb 18, 2026
db10d31
feat(live-activity): add APNs push updates and RPC token handler
michelhelsdingen Feb 19, 2026
ff47914
feat(daemon): persist tracked sessions across daemon restarts
michelhelsdingen Feb 22, 2026
147553e
fix: P0 fixes, Live Activity cleanup, and push notification improvements
michelhelsdingen Feb 22, 2026
4812169
Merge pull request #1 from michelhelsdingen/feat/session-persistence-…
michelhelsdingen Feb 22, 2026
43f1ca2
feat: post session recaps to inbox feed after each Claude turn
michelhelsdingen Feb 22, 2026
8fe7775
Merge pull request #2 from michelhelsdingen/feat/session-recaps
michelhelsdingen Feb 22, 2026
c79cd45
fix(tmux): enable phone-to-MacBook session takeover via tmux
michelhelsdingen Feb 23, 2026
abc9d2f
fix: remove replay phase filtering that blocked remote mode display
michelhelsdingen Feb 23, 2026
99d535a
feat: add file/tool tracking to session recap summaries
michelhelsdingen Feb 23, 2026
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "happy-coder",
"version": "0.14.0-0",
"version": "0.15.0",
"description": "Mobile and Web client for Claude Code and Codex",
"author": "Kirill Dubovitskiy",
"license": "MIT",
Expand Down
3 changes: 3 additions & 0 deletions scripts/claude_local_launcher.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ const fs = require('fs');
// Disable autoupdater (never works really)
process.env.DISABLE_AUTOUPDATER = '1';

// Disable Claude Code's terminal title setting so Happy CLI can control it
process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE = '1';

// Helper to write JSON messages to fd 3
function writeMessage(message) {
try {
Expand Down
13 changes: 13 additions & 0 deletions scripts/claude_version_utils.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,19 @@ function runClaudeCli(cliPath) {
stdio: 'inherit',
env: process.env
});

// Forward signals to child process so it gets killed when parent is killed
// This prevents orphaned Claude processes when switching between local/remote modes
// Fix for issue #11 / GitHub slopus/happy#430
const forwardSignal = (signal) => {
if (child.pid && !child.killed) {
child.kill(signal);
}
};
process.on('SIGTERM', () => forwardSignal('SIGTERM'));
process.on('SIGINT', () => forwardSignal('SIGINT'));
process.on('SIGHUP', () => forwardSignal('SIGHUP'));

child.on('exit', (code) => {
process.exit(code || 0);
});
Expand Down
19 changes: 19 additions & 0 deletions src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,25 @@ export class ApiClient {
return this.pushClient;
}

async postFeedItem(body: Record<string, any>, repeatKey?: string): Promise<void> {
try {
await axios.post(
`${configuration.serverUrl}/v1/feed`,
{ body, repeatKey },
{
headers: {
'Authorization': `Bearer ${this.credential.token}`,
'Content-Type': 'application/json'
},
timeout: 5000
}
);
logger.debug('[API] Feed item posted successfully');
} catch (error) {
logger.debug('[API] Failed to post feed item:', error);
}
}

/**
* Register a vendor API token with the server
* The token is sent as a JSON string - server handles encryption
Expand Down
2 changes: 1 addition & 1 deletion src/api/apiMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export class ApiMachineClient {
logger: (msg, data) => logger.debug(msg, data)
});

registerCommonHandlers(this.rpcHandlerManager, process.cwd());
registerCommonHandlers(this.rpcHandlerManager, process.cwd(), this.machine.id);
}

setRPCHandlers({
Expand Down
56 changes: 48 additions & 8 deletions src/api/apiSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export class ApiSessionClient extends EventEmitter {
encryptionVariant: this.encryptionVariant,
logger: (msg, data) => logger.debug(msg, data)
});
registerCommonHandlers(this.rpcHandlerManager, this.metadata.path);
registerCommonHandlers(this.rpcHandlerManager, this.metadata.path, this.sessionId);

//
// Create socket
Expand Down Expand Up @@ -221,10 +221,9 @@ export class ApiSessionClient extends EventEmitter {

logger.debugLargeJson('[SOCKET] Sending message through socket:', content)

// Check if socket is connected before sending
// Socket.io buffers messages when disconnected and sends them on reconnect
if (!this.socket.connected) {
logger.debug('[API] Socket not connected, cannot send Claude session message. Message will be lost:', { type: body.type });
return;
logger.debug('[API] Socket not connected, message will be buffered for reconnect:', { type: body.type });
}

const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content));
Expand Down Expand Up @@ -267,12 +266,11 @@ export class ApiSessionClient extends EventEmitter {
};
const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content));

// Check if socket is connected before sending
// Socket.io buffers messages when disconnected and sends them on reconnect
if (!this.socket.connected) {
logger.debug('[API] Socket not connected, cannot send message. Message will be lost:', { type: body.type });
// TODO: Consider implementing message queue or HTTP fallback for reliability
logger.debug('[API] Socket not connected, message will be buffered for reconnect:', { type: body.type });
}

this.socket.emit('message', {
sid: this.sessionId,
message: encrypted
Expand Down Expand Up @@ -316,6 +314,8 @@ export class ApiSessionClient extends EventEmitter {
type: 'permission-mode-changed', mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'
} | {
type: 'ready'
} | {
type: 'coordinator-state', [key: string]: unknown
}, id?: string) {
let content = {
role: 'agent',
Expand Down Expand Up @@ -384,6 +384,36 @@ export class ApiSessionClient extends EventEmitter {
this.socket.emit('usage-report', usageReport);
}

/**
* Send cost data from SDK result to the server
*/
sendCostData(totalCostUsd: number, usage?: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number }) {
if (!usage) return;

const totalTokens = usage.input_tokens + usage.output_tokens
+ (usage.cache_read_input_tokens || 0)
+ (usage.cache_creation_input_tokens || 0);

const usageReport = {
key: 'claude-session',
sessionId: this.sessionId,
tokens: {
total: totalTokens,
input: usage.input_tokens,
output: usage.output_tokens,
cache_creation: usage.cache_creation_input_tokens || 0,
cache_read: usage.cache_read_input_tokens || 0
},
cost: {
total: totalCostUsd,
input: 0,
output: 0
}
};
logger.debugLargeJson('[SOCKET] Sending cost data from result:', usageReport);
this.socket.emit('usage-report', usageReport);
}

/**
* Update session metadata
* @param handler - Handler function that returns the updated metadata
Expand Down Expand Up @@ -454,6 +484,16 @@ export class ApiSessionClient extends EventEmitter {
});
}

/**
* Signal the end of a Claude session turn.
* In the standalone CLI (socket-based), this is a no-op since we don't use
* the session protocol envelope system. The monorepo version sends turn-end
* envelopes via HTTP outbox.
*/
closeClaudeSessionTurn(status: 'completed' | 'failed' | 'cancelled' = 'completed') {
logger.debug(`[API] closeClaudeSessionTurn: ${status}`);
}

async close() {
logger.debug('[API] socket.close() called');
this.socket.close();
Expand Down
5 changes: 4 additions & 1 deletion src/api/pushNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,10 @@ export class PushNotificationClient {
body,
data,
sound: 'default',
priority: 'high'
priority: 'high',
...(data?.categoryIdentifier && {
categoryId: data.categoryIdentifier,
}),
}
})

Expand Down
41 changes: 39 additions & 2 deletions src/claude/claudeLocal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,21 +217,51 @@ export async function claudeLocal(opts: {
// Prepare environment variables
// Note: Local mode uses global Claude installation with --session-id flag
// Launcher only intercepts fetch for thinking state tracking
const env = {
const env: Record<string, string | undefined> = {
...process.env,
...opts.claudeEnvVars
}
// Remove Claude Code nesting detection vars that may linger from SDK remote mode.
// The SDK sets CLAUDE_CODE_ENTRYPOINT in process.env (query.ts:282-283),
// which persists after remote mode ends. If passed to the local Claude CLI,
// it causes Claude to think it's running nested inside another session and exit.
delete env.CLAUDECODE
delete env.CLAUDE_CODE_ENTRYPOINT

logger.debug(`[ClaudeLocal] Spawning launcher: ${claudeCliPath}`);
logger.debug(`[ClaudeLocal] Args: ${JSON.stringify(args)}`);

const spawnTime = Date.now();
const child = spawn('node', [claudeCliPath, ...args], {
stdio: ['inherit', 'inherit', 'inherit', 'pipe'],
signal: opts.abort,
cwd: opts.path,
env,
});

// Forward signals to child process to prevent orphaned processes
// Fix for issue #11 / GitHub slopus/happy#430
// Note: signal: opts.abort handles programmatic abort (mode switching),
// but direct OS signals (e.g., kill, Ctrl+C) need explicit forwarding
const forwardSignal = (signal: NodeJS.Signals) => {
if (child.pid && !child.killed) {
child.kill(signal);
}
};
const onSigterm = () => forwardSignal('SIGTERM');
const onSigint = () => forwardSignal('SIGINT');
const onSighup = () => forwardSignal('SIGHUP');
process.on('SIGTERM', onSigterm);
process.on('SIGINT', onSigint);
process.on('SIGHUP', onSighup);

// Cleanup signal handlers when child exits to avoid leaks
child.on('exit', () => {
process.off('SIGTERM', onSigterm);
process.off('SIGINT', onSigint);
process.off('SIGHUP', onSighup);
});

// Listen to the custom fd (fd 3) for thinking state tracking
if (child.stdio[3]) {
const rl = createInterface({
Expand Down Expand Up @@ -300,14 +330,21 @@ export async function claudeLocal(opts: {
});
}
child.on('error', (error) => {
// Ignore
logger.debug(`[ClaudeLocal] Process spawn error: ${error.message}`);
});
child.on('exit', (code, signal) => {
const runtime = Date.now() - spawnTime;
logger.debug(`[ClaudeLocal] Process exited: code=${code}, signal=${signal}, runtime=${runtime}ms, aborted=${opts.abort.aborted}`);
if (signal === 'SIGTERM' && opts.abort.aborted) {
// Normal termination due to abort signal
r();
} else if (signal) {
reject(new Error(`Process terminated with signal: ${signal}`));
} else if (code !== 0 && code !== null) {
reject(new Error(`Process exited with code ${code} after ${runtime}ms`));
} else if (runtime < 2000 && !opts.abort.aborted) {
// Process exited too quickly (< 2s) - likely a startup failure
reject(new Error(`Process exited suspiciously fast (${runtime}ms) - possible startup failure`));
} else {
r();
}
Expand Down
19 changes: 15 additions & 4 deletions src/claude/claudeLocalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,17 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' |
}

// Run local mode
let launchAttempts = 0;
const MAX_LAUNCH_ATTEMPTS = 3;
while (true) {
// If we already have an exit reason, return it
if (exitReason) {
return exitReason;
}

// Launch
logger.debug('[local]: launch');
launchAttempts++;
logger.debug(`[local]: launch (attempt ${launchAttempts}/${MAX_LAUNCH_ATTEMPTS})`);
try {
await claudeLocal({
path: session.path,
Expand All @@ -115,15 +118,23 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' |
// For example we don't want to pass --resume flag after first spawn
session.consumeOneTimeFlags();

// Normal exit
// Normal exit - reset attempts on successful run
launchAttempts = 0;
if (!exitReason) {
exitReason = 'exit';
break;
}
} catch (e) {
logger.debug('[local]: launch error', e);
const errorMsg = e instanceof Error ? e.message : String(e);
logger.debug(`[local]: launch error (attempt ${launchAttempts}): ${errorMsg}`);
if (!exitReason) {
session.client.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' });
if (launchAttempts >= MAX_LAUNCH_ATTEMPTS) {
logger.debug(`[local]: max launch attempts (${MAX_LAUNCH_ATTEMPTS}) reached, giving up`);
session.client.sendSessionEvent({ type: 'message', message: `Local mode failed after ${MAX_LAUNCH_ATTEMPTS} attempts: ${errorMsg}` });
exitReason = 'exit';
break;
}
session.client.sendSessionEvent({ type: 'message', message: `Process exited unexpectedly: ${errorMsg}. Retrying...` });
continue;
} else {
break;
Expand Down
11 changes: 8 additions & 3 deletions src/claude/claudeRemote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { PushableAsyncIterable } from "@/utils/PushableAsyncIterable";
import { getProjectPath } from "./utils/path";
import { awaitFileExist } from "@/modules/watcher/awaitFileExist";
import { systemPrompt } from "./utils/systemPrompt";
import { PermissionResult } from "./sdk/types";
import { PermissionResult, SDKResultMessage } from "./sdk/types";
import type { JsRuntime } from "./runClaude";

export async function claudeRemote(opts: {
Expand Down Expand Up @@ -39,7 +39,8 @@ export async function claudeRemote(opts: {
onThinkingChange?: (thinking: boolean) => void,
onMessage: (message: SDKMessage) => void,
onCompletionEvent?: (message: string) => void,
onSessionReset?: () => void
onSessionReset?: () => void,
onResult?: (result: SDKResultMessage) => void
}) {

// Check if session is valid
Expand Down Expand Up @@ -168,7 +169,6 @@ export async function claudeRemote(opts: {
for await (const message of response) {
logger.debugLargeJson(`[claudeRemote] Message ${message.type}`, message);

// Handle messages
opts.onMessage(message);

// Handle special system messages
Expand All @@ -194,6 +194,11 @@ export async function claudeRemote(opts: {
updateThinking(false);
logger.debug('[claudeRemote] Result received, exiting claudeRemote');

// Forward result with cost data
if (opts.onResult) {
opts.onResult(message as SDKResultMessage);
}

// Send completion messages
if (isCompactCommand) {
logger.debug('[claudeRemote] Compaction completed');
Expand Down
Loading