Skip to content
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@happier-dev/cli",
"version": "0.1.0",
"version": "0.1.1",
"description": "Mobile and Web client for Claude Code and Codex",
"author": "Leeroy Brun <leeroy.brun@gmail.com>",
"license": "MIT",
Expand Down
52 changes: 52 additions & 0 deletions apps/cli/scripts/__tests__/buildSharedDeps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { join, resolve, sep } from 'node:path';

import { createTempDirSync, removeTempDirSync } from '../../src/testkit/fs/tempDir';
import {
buildSharedDeps,
resolveTscBin,
runTsc,
syncBundledWorkspaceDist,
Expand Down Expand Up @@ -214,6 +215,35 @@ describe('buildSharedDeps', () => {
).toBe(true);
});

it('builds protocol before agents so agents do not consume stale protocol declarations', () => {
const runTsc = vi.fn(() => undefined);
const syncBundledWorkspaceDist = vi.fn(() => undefined);
const syncBundledWorkspaceRuntimeDependencies = vi.fn(() => undefined);
const syncCliRuntimeDependencies = vi.fn(() => undefined);

buildSharedDeps({
repoRoot: '/repo',
runTsc,
existsSync: () => true,
syncBundledWorkspaceDist,
syncBundledWorkspaceRuntimeDependencies,
syncCliRuntimeDependencies,
});

const expectedTsconfigs = [
'/repo/packages/protocol/tsconfig.json',
'/repo/packages/agents/tsconfig.json',
'/repo/packages/cli-common/tsconfig.json',
'/repo/packages/connection-supervisor/tsconfig.json',
'/repo/packages/transfers/tsconfig.json',
'/repo/packages/release-runtime/tsconfig.json',
];
expect(runTsc.mock.calls.map((args) => args[0])).toEqual(expectedTsconfigs);
expect(syncBundledWorkspaceDist).toHaveBeenCalledWith({ repoRoot: '/repo' });
expect(syncBundledWorkspaceRuntimeDependencies).toHaveBeenCalledWith({ repoRoot: '/repo' });
expect(syncCliRuntimeDependencies).toHaveBeenCalledWith({ repoRoot: '/repo' });
});

it('bundles tweetnacl into the CLI publish tree for packaged installs', () => {
const { repoRoot, happyCliDir, cleanup } = createPackageLayoutSandbox('happy-build-shared-runtime-');

Expand Down Expand Up @@ -288,4 +318,26 @@ describe('buildSharedDeps', () => {
removeTempDirSync(rootDir);
}
});

it('builds protocol before agents so agents do not consume stale protocol declarations', () => {
const runTsc = vi.fn(() => undefined);

buildSharedDeps({
repoRoot: '/repo',
runTsc,
existsSync: () => true,
syncBundledWorkspaceDist: vi.fn(() => undefined),
syncCliRuntimeDependencies: vi.fn(() => undefined),
syncBundledWorkspaceRuntimeDependencies: vi.fn(() => undefined),
});

expect(runTsc.mock.calls.map((args) => args[0])).toEqual([
'/repo/packages/protocol/tsconfig.json',
'/repo/packages/agents/tsconfig.json',
'/repo/packages/cli-common/tsconfig.json',
'/repo/packages/connection-supervisor/tsconfig.json',
'/repo/packages/transfers/tsconfig.json',
'/repo/packages/release-runtime/tsconfig.json',
]);
});
});
52 changes: 35 additions & 17 deletions apps/cli/scripts/buildSharedDeps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -247,25 +247,43 @@ export function syncBundledWorkspaceRuntimeDependencies(opts = {}) {
}
}

export function buildSharedDeps(opts = {}) {
const repoRootArg = opts.repoRoot;
const resolvedRepoRoot =
typeof repoRootArg === 'string' && repoRootArg.trim() ? repoRootArg : findRepoRoot(__dirname);
const runTscImpl = opts.runTsc ?? runTsc;
const syncBundledWorkspaceDistImpl = opts.syncBundledWorkspaceDist ?? syncBundledWorkspaceDist;
const syncBundledWorkspaceRuntimeDependenciesImpl =
opts.syncBundledWorkspaceRuntimeDependencies ?? syncBundledWorkspaceRuntimeDependencies;
const syncCliRuntimeDependenciesImpl = opts.syncCliRuntimeDependencies ?? syncCliRuntimeDependencies;
const existsSyncImpl = opts.existsSync ?? existsSync;

const tsconfigPaths = [
resolve(resolvedRepoRoot, 'packages', 'protocol', 'tsconfig.json'),
resolve(resolvedRepoRoot, 'packages', 'agents', 'tsconfig.json'),
resolve(resolvedRepoRoot, 'packages', 'cli-common', 'tsconfig.json'),
resolve(resolvedRepoRoot, 'packages', 'connection-supervisor', 'tsconfig.json'),
resolve(resolvedRepoRoot, 'packages', 'transfers', 'tsconfig.json'),
resolve(resolvedRepoRoot, 'packages', 'release-runtime', 'tsconfig.json'),
];

for (const tsconfigPath of tsconfigPaths) {
runTscImpl(tsconfigPath);
}

const protocolDist = resolve(resolvedRepoRoot, 'packages', 'protocol', 'dist', 'index.js');
if (!existsSyncImpl(protocolDist)) {
throw new Error(`Expected @happier-dev/protocol build output missing: ${protocolDist}`);
}

syncBundledWorkspaceDistImpl({ repoRoot: resolvedRepoRoot });
syncBundledWorkspaceRuntimeDependenciesImpl({ repoRoot: resolvedRepoRoot });
syncCliRuntimeDependenciesImpl({ repoRoot: resolvedRepoRoot });
}

export function main() {
return withBuildSharedDepsLock(async () => {
runTsc(resolve(repoRoot, 'packages', 'agents', 'tsconfig.json'));
runTsc(resolve(repoRoot, 'packages', 'cli-common', 'tsconfig.json'));
runTsc(resolve(repoRoot, 'packages', 'connection-supervisor', 'tsconfig.json'));
runTsc(resolve(repoRoot, 'packages', 'protocol', 'tsconfig.json'));
runTsc(resolve(repoRoot, 'packages', 'transfers', 'tsconfig.json'));
runTsc(resolve(repoRoot, 'packages', 'release-runtime', 'tsconfig.json'));

const protocolDist = resolve(repoRoot, 'packages', 'protocol', 'dist', 'index.js');
if (!existsSync(protocolDist)) {
throw new Error(`Expected @happier-dev/protocol build output missing: ${protocolDist}`);
}

// If the CLI currently has bundled workspace deps under apps/cli/node_modules,
// keep their dist outputs in sync so local builds/tests do not consume stale artifacts.
syncBundledWorkspaceDist({ repoRoot });
syncBundledWorkspaceRuntimeDependencies({ repoRoot });
syncCliRuntimeDependencies({ repoRoot });
buildSharedDeps({ repoRoot });
});
}

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/agent/runtime/runStandardAcpProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ export async function runStandardAcpProvider(
};

session.rpcHandlerManager.registerHandler('abort', handleAbort);
registerKillSessionHandlerFn(session.rpcHandlerManager, handleKillSession);
registerKillSessionHandlerFn(session.rpcHandlerManager, session.sessionId, handleKillSession);

const sendReady = config.createSendReady
? config.createSendReady({ session, api })
Expand Down
6 changes: 5 additions & 1 deletion apps/cli/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,11 @@ export interface ClientToServerEvents {
'ping': (callback: () => void) => void
[SOCKET_RPC_EVENTS.REGISTER]: (data: { method: string }) => void
[SOCKET_RPC_EVENTS.UNREGISTER]: (data: { method: string }) => void
[SOCKET_RPC_EVENTS.CALL]: (data: SocketRpcCallPayload, callback: (response: SocketRpcCallResponse) => void) => void
[SOCKET_RPC_EVENTS.CALL]: (data: { method: string, params: unknown }, callback: (response: {
ok: boolean
result?: unknown
error?: string
}) => void) => void
'usage-report': (data: {
key: string
sessionId: string
Expand Down
10 changes: 10 additions & 0 deletions apps/cli/src/backends/claude/claudeRemoteLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { resolveHasTTY } from '@/ui/tty/resolveHasTTY';
import { createNonBlockingStdout } from '@/ui/ink/nonBlockingStdout';
import { updateMetadataBestEffort } from '@/api/session/sessionWritesBestEffort';
import { sendReadyWithPushNotification } from '@/agent/runtime/sendReadyWithPushNotification';
import { stopDaemonSession } from '@/daemon/controlClient';
import { getLatestAssistantMessagePreview, getSessionNotificationTitle } from '@/agent/runtime/readyNotificationContext';
import { shouldSendReadyPushNotification } from '@/settings/notifications/notificationsPolicy';
import { dirname, join } from 'node:path';
Expand Down Expand Up @@ -220,6 +221,15 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' |
if (!exitReason) {
exitReason = 'exit';
}
// Notify daemon to stop respawning this session
if (session.sessionId) {
try {
await stopDaemonSession(session.sessionId);
logger.debug('[remote]: Notified daemon to stop session');
} catch (e) {
logger.debug('[remote]: Failed to notify daemon of stop:', e);
}
}
await abort();
},
onSwitchToLocal: () => {
Expand Down
62 changes: 32 additions & 30 deletions apps/cli/src/backends/claude/runClaude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,35 +760,38 @@ export async function runClaude(credentials: Credentials, options: StartOptions
}),
});

registerKillSessionHandler(session.rpcHandlerManager, async () => {
registerKillSessionHandler(session.rpcHandlerManager, session.sessionId, async () => {
terminationHandlers.requestTermination({ kind: 'killSession' });
await terminationHandlers.whenTerminated;
});

// Create claude loop
const resolvedMcp = await resolveRunnerMcpServers({
session,
credentials,
accountSettings,
machineId,
directory: workingDirectory,
sessionMetadata: session.getMetadataSnapshot(),
});
const exitCode = await loop({
path: workingDirectory,
model: options.model,
permissionMode: options.permissionMode,
permissionModeUpdatedAt: options.permissionModeUpdatedAt,
startingMode: options.startingMode,
claudeCodeExperimentalAgentTeamsEnabled: currentClaudeRemoteMetaState.claudeCodeExperimentalAgentTeamsEnabled,
startedBy: options.startedBy,
messageQueue,
session,
pushSender: api.push(),
accountSettings,
precomputedMcpBridge: { mcpServers: resolvedMcp.mcpServers, stop: resolvedMcp.happierMcpServer.stop },
onModeChange: (newMode) => {
session.sendSessionEvent({ type: 'switch', mode: newMode });
const resolvedMcp = await resolveRunnerMcpServers({
session,
credentials,
accountSettings,
machineId,
directory: workingDirectory,
sessionMetadata: session.getMetadataSnapshot(),
});
const exitCode = await loop({
path: workingDirectory,
model: options.model,
permissionMode: options.permissionMode,
permissionModeUpdatedAt: options.permissionModeUpdatedAt,
startingMode: options.startingMode,
claudeCodeExperimentalAgentTeamsEnabled: currentClaudeRemoteMetaState.claudeCodeExperimentalAgentTeamsEnabled,
startedBy: options.startedBy,
messageQueue,
session,
pushSender: api.push(),
accountSettings,
precomputedMcpBridge: {
mcpServers: resolvedMcp.mcpServers,
stop: resolvedMcp.happierMcpServer.stop,
},
onModeChange: (newMode) => {
session.sendSessionEvent({ type: 'switch', mode: newMode });
updateAgentStateBestEffort(
session,
(currentState) => ({
Expand All @@ -812,11 +815,10 @@ export async function runClaude(credentials: Credentials, options: StartOptions
rebuildLocalPermissionBridge();
}
},
claudeArgs: options.claudeArgs,
hookSettingsPath,
jsRuntime: options.jsRuntime,
defaultSystemPromptText,
});
claudeArgs: options.claudeArgs,
hookSettingsPath,
jsRuntime: options.jsRuntime,
});

terminationHandlers.dispose();

Expand Down Expand Up @@ -1414,7 +1416,7 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO
}),
});

registerKillSessionHandler(coordinator.artifacts.deferredSession.rpcHandlerManager, async () => {
registerKillSessionHandler(coordinator.artifacts.deferredSession.rpcHandlerManager, coordinator.artifacts.deferredSession.sessionId, async () => {
terminationHandlers.requestTermination({ kind: 'killSession' });
await terminationHandlers.whenTerminated;
});
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/backends/codex/runCodex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -894,7 +894,7 @@ export async function runCodex(opts: {
return await runtime.rollbackConversation(parsed.data);
});

registerKillSessionHandler(session.rpcHandlerManager, handleKillSession);
registerKillSessionHandler(session.rpcHandlerManager, session.sessionId, handleKillSession);

//
// Initialize Ink UI
Expand Down
18 changes: 9 additions & 9 deletions apps/cli/src/backends/gemini/acp/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,16 +202,16 @@ export function createGeminiBackend(options: GeminiBackendOptions): GeminiBacken
permissionHandler: options.permissionHandler,
transportHandler: geminiTransport,
authMethodId,
// Check if prompt instructs the agent to change title (for auto-approval of change_title tool)
hasChangeTitleInstruction: (prompt: string) => {
const lower = prompt.toLowerCase();
return (
CHANGE_TITLE_TOOL_NAME_ALIASES.some((alias) => lower.includes(alias)) ||
// Check if prompt instructs the agent to change title (for auto-approval of change_title tool)
hasChangeTitleInstruction: (prompt: string) => {
const lower = prompt.toLowerCase();
return (
CHANGE_TITLE_TOOL_NAME_ALIASES.some((alias) => lower.includes(alias)) ||
lower.includes('change title') ||
lower.includes('set title')
);
},
};
lower.includes('set title')
);
},
};

// Determine model source for logging
const modelSource = getGeminiModelSource(options.model, localConfig, mergedSourceEnv);
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/backends/gemini/acp/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ export class GeminiTransport implements TransportHandler {
const syntheticTitleOnlyInput = isSyntheticTitleOnlyInferenceInput(input);
const opaqueToolPrefix = syntheticTitleOnlyInput ? extractOpaqueToolNamePrefix(toolCallId) : null;

// 0. Normalize direct legacy aliases (for example happy__change_title) to canonical names.
// 0. Normalize direct legacy aliases (for example happier__change_title) to canonical names.
const directToolName = findToolNameFromId(toolName, GEMINI_TOOL_PATTERNS, { preferLongestMatch: true });
if (directToolName) return directToolName;

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/backends/gemini/runGemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ export async function runGemini(opts: {
};

session.rpcHandlerManager.registerHandler('abort', handleAbort);
registerKillSessionHandler(session.rpcHandlerManager, handleKillSession);
registerKillSessionHandler(session.rpcHandlerManager, session.sessionId, handleKillSession);

// Create permission handler for tool approval (variable declared earlier for onSessionSwap)
permissionHandler = createProviderEnforcedPermissionHandler({
Expand Down
6 changes: 5 additions & 1 deletion apps/cli/src/daemon/platform/tmux/spawnConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ export function buildTmuxSpawnConfig(params: {
const launchSpec = buildHappyCliSubprocessLaunchSpec(args);
const commandTokens = [launchSpec.filePath, ...launchSpec.args];

const tmuxEnv = buildTmuxWindowEnv(process.env, { ...params.extraEnv, ...(launchSpec.env ?? {}) });
const tmuxEnv = buildTmuxWindowEnv(process.env, {
...params.extraEnv,
...(launchSpec.env ?? {}),
HAPPIER_CLI_ALLOW_DAEMON_TTY_IN_TMUX: '1',
});

const tmuxCommandEnv: Record<string, string> = { ...(params.tmuxCommandEnv ?? {}) };
const tmuxTmpDir = tmuxCommandEnv.TMUX_TMPDIR;
Expand Down
Loading
Loading