Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
148 commits
Select commit Hold shift + click to select a range
604d70d
feat(web,hub): scratchlist v2 - hub sync via typed table + session-up…
heavygee Jun 13, 2026
92862f8
feat(web): rich hover tooltips on session-list attention indicators
heavygee Jun 13, 2026
29a0f6e
fix(scratchlist): address HAPI Bot Major findings on PR #896
heavygee Jun 13, 2026
390dfba
feat(web/scratchlist): per-entry age indicator (clock icon + tooltip)
heavygee Jun 13, 2026
dc07b91
fix(scratchlist): bound client-supplied entryId length (HAPI Bot, PR …
heavygee Jun 13, 2026
0fab1df
fix(scratchlist): banner state machine - 'completed' is sticky until …
heavygee Jun 13, 2026
5b80d59
feat(web): opaque tooltip surface; drop redundant 'updated Nm ago' body
heavygee Jun 13, 2026
bf7c022
fix(web): highlight 'Default' in cursor flat-mode model picker (#46)
heavygee Jun 13, 2026
7076a48
feat(web): Web Share Target -> composer attachment preload
heavygee Jun 13, 2026
370a6d2
soup: renumber scratchlist migration v10->v11 for fcm-push-api stack
heavygee Jun 15, 2026
d64cb02
feat(runner): adopt orphaned children on late webhook instead of kill…
heavygee Jun 16, 2026
7b11ee8
fix(web/share): freeze picker session list at mount, sort by updatedA…
heavygee Jun 16, 2026
9ebb457
feat(garden): collapse hapi-garden fork into driver as /garden route
heavygee Jun 11, 2026
8bc7ad4
merge: feat/pwa-share-target for soup manifest compatibility
heavygee Jun 16, 2026
4f50e1b
fix(garden): add pendingRequests to SessionSummary test fixtures
heavygee Jun 16, 2026
d94bc6f
fix(garden): annotate xrStore type for portable typecheck
heavygee Jun 16, 2026
ac517a3
merge: feat/session-list-rich-tooltips for SessionSummary.pendingRequ…
heavygee Jun 16, 2026
55738b6
feat(hub,web): import existing Claude Code sessions
Jun 17, 2026
d44a9af
feat(cli): fork-resume sessions still live as a background agent
Jun 17, 2026
58fce8e
feat(web): offer flat HAPI and Garden XR on Quest
heavygee Jun 17, 2026
26c45f1
fix(hub): preserve original timestamps when importing transcripts
Jun 17, 2026
454e5ad
fix(cli): apply claudeEnvVars before probing the live-agent roster
Jun 17, 2026
8d67d17
chore(scripts): add cursor-acp verify-probe audit harness
heavygee Jun 16, 2026
a7534a7
feat(hub): cursor flavor of the multi-agent session import surface
heavygee Jun 16, 2026
f26bf74
feat(web): cursor flavor in multi-agent session import dialog
heavygee Jun 17, 2026
3e14f51
feat(web): mermaid diagram lightbox on click
heavygee May 30, 2026
02b8ceb
fix(web): fit mermaid lightbox to viewport on open
heavygee May 30, 2026
9b17ac2
fix(web): fit mermaid lightbox to device screen not inner panel
heavygee May 30, 2026
a337b25
fix(web): show mermaid lightbox by reusing inline SVG
heavygee May 30, 2026
1ec8834
fix(web): uniquify mermaid SVG ids in lightbox clone
heavygee May 30, 2026
be7ca87
fix(web): give mermaid lightbox SVG explicit dimensions
heavygee May 30, 2026
575de54
fix(web): render mermaid lightbox via isolated SVG data URL
heavygee May 31, 2026
63c8c3d
fix(web): lightbox re-renders SVG for sequence diagrams
heavygee May 31, 2026
b204d90
fix(web): mermaid lightbox uses inline SVG in shadow DOM
heavygee May 31, 2026
005d211
test(web): Playwright lightbox coverage per mermaid diagram type
heavygee May 31, 2026
90d48ae
test(web): bounded Playwright via webServer, fix gantt fit sizing
heavygee May 31, 2026
499c3e9
chore(web): gitignore Playwright test-results
heavygee May 31, 2026
3eb6aa1
fix(web): address PR 741 bot feedback (typecheck, fit floor, gitignore)
heavygee May 31, 2026
7a5bc8a
test(web): Playwright asserts click expands diagram vs inline
heavygee May 31, 2026
19ccc04
test(web): Playwright against live HAPI session for mermaid lightbox
heavygee May 31, 2026
fb435ab
fix(web): undo wrapper transform in lightbox fit; carry fit floor in …
heavygee May 31, 2026
71c57c1
fix(scripts): mermaid seed refuses to wipe non-fixture sessions
heavygee May 31, 2026
8b5644d
fix(web): normalize mermaid svg for lightbox shadow root
heavygee May 31, 2026
890bfa8
fix(web): keep mermaid lightbox content below the toolbar
heavygee May 31, 2026
bbdab3e
fix(web): guard ResizeObserver before constructing it
heavygee May 31, 2026
4543777
fix(scripts): live mermaid playwright wrapper runs from repo root
heavygee Jun 1, 2026
03dbe06
fix(web): accept signed viewBox values in mermaid lightbox normalize
heavygee Jun 1, 2026
c2fb713
fix(web): align @playwright/test on 1.60.0 across workspaces
heavygee Jun 4, 2026
ed2d22a
fix(web): move mermaid playwright fixtures out of public
heavygee Jun 6, 2026
8f2b078
fix(web): Quest-only Garden XR and header entry buttons
heavygee Jun 17, 2026
979faeb
fix(web): move Garden XR entry to left header chip
heavygee Jun 18, 2026
d53185c
perf(hub,web): emit structured patches for session todos/teamState/me…
heavygee Jun 13, 2026
7e7e8da
fix(hub): snapshot metadata reference before applySessionPatch mutati…
heavygee Jun 13, 2026
081ea2c
fix(schemas): reorder SessionPatchSchema fields so soup-merge with co…
heavygee Jun 13, 2026
d07074d
fix(sse): propagate TeamDelete clear through structured patch path
heavygee Jun 16, 2026
9f8d01b
Add Codex session list/resume flow to web new-session UI
dsus4wang Apr 27, 2026
291bef2
fix(sse): version-gate metadata/agentState patches against cache regr…
heavygee Jun 16, 2026
9462767
restore Codex session history
dsus4wang Apr 27, 2026
32c4bce
add Codex usage indicator
dsus4wang Apr 27, 2026
e7b7f8d
feat(codex-usage): surface credits + reached-type for premium accounts
heavygee Jun 8, 2026
1b8b353
fix(codex-usage): drop $ prefix + format credit balance
heavygee Jun 8, 2026
2f881b3
fix(codex-usage): ring shows worst constraint, not just context
heavygee Jun 8, 2026
921ded0
refactor(codex-usage): split ring meaning - centre = context, colour …
heavygee Jun 9, 2026
d1b2c44
fix(codex-usage): forward importHistory through machine RPC to spawnS…
heavygee Jun 10, 2026
f26705f
fix(codex-usage): cold-review fixes - duplicate history, type, popove…
heavygee Jun 10, 2026
c33db4a
fix(codex-usage): scope session listing to workspace roots on --works…
heavygee Jun 10, 2026
793385a
fix(codex-usage): use selected session's path as spawn directory on r…
heavygee Jun 10, 2026
ea1a957
fix(web): propagate metadata fields in patchSessionSummary
heavygee Jun 10, 2026
0c6ad1d
fix(codex): prevent double-replay and stale-selection regressions
heavygee Jun 11, 2026
8c9a1cc
fix(web): paginate Codex session picker to fetch all pages
heavygee Jun 11, 2026
f79a780
fix(web): gate Create button on resolved spawn directory for pathless…
heavygee Jun 13, 2026
594d3e8
fix(codex-usage): validate Codex session path against workspace root …
heavygee Jun 17, 2026
88a740b
fix(codex-usage): use path-aware isSameOrChild check for import direc…
heavygee Jun 17, 2026
16b80b8
feat(web): add Copy reference to session context menu
heavygee Jun 18, 2026
8bbb18f
fix(codex-usage): gate Codex resume on workspace-root check before spawn
heavygee Jun 18, 2026
ab09d1f
fix(sse): include updatedAt in structured patches + pendingRequests s…
heavygee Jun 18, 2026
8aa8d6b
feat(web): unify Claude import into AgentSessionImportDialog tabs
heavygee Jun 18, 2026
95e9258
fix(web,hub): apply serviceTier in structured session patch path
heavygee Jun 18, 2026
7af2f1c
fix(web): use lazy/Suspense imports for garden route (merge-safe)
heavygee Jun 18, 2026
08fc009
fix(web): sanitize session titles in copy-reference text
heavygee Jun 18, 2026
f1249b9
feat(garden): 25 active orbs on sphere rows, larger gaze, voice brief…
heavygee Jun 19, 2026
6baf0e3
fix(garden): add nextScheduledAt to SessionSummary test fixtures
heavygee Jun 19, 2026
d4db159
fix(web): drop duplicate formatRelativeTime import path
heavygee Jun 19, 2026
e277f2b
fix(web): avoid duplicate formatRelativeTime import at soup merge
heavygee Jun 20, 2026
20bbdea
fix(runner): treat stop of unknown session as idempotent success
heavygee Jun 20, 2026
03f112e
fix(garden): merge-safe vite.config for upstream share-target + PWA p…
heavygee Jun 20, 2026
6c40406
fix(garden): minimal vite.config delta for soup merge (flat defineCon…
heavygee Jun 20, 2026
134f046
test(runner): serialize stress-test stops to avoid control-server flake
heavygee Jun 20, 2026
978ed2b
feat(hub): native companion (FCM) push channel + device registry
heavygee Jun 2, 2026
0bd7cf7
docs(contract): clarify scope - companion is remote-hub client, not h…
heavygee Jun 3, 2026
0ccb60f
docs(contract): correct Scope section - hub topology is unchanged
heavygee Jun 3, 2026
f389bd0
feat(web): companion app pairing QR in Settings
heavygee Jun 4, 2026
6866b97
feat(hub): terminal QR for companion app pairing alongside PWA QR
heavygee Jun 4, 2026
a2a0a30
fix(fcm): address HAPI Bot review on PR #803
heavygee Jun 5, 2026
1cdd154
docs(contract): correct FCM visibility rule and remove unsupported ev…
heavygee Jun 5, 2026
16bafb9
docs(contract): drop trailing whitespace, use blank line for paragrap…
heavygee Jun 5, 2026
6e3f66a
fix(web): persist CLI access token after Telegram bind so pairing QR …
heavygee Jun 5, 2026
5d444cc
fix(fcm): gate native-fallback probe on rolling FCM health
heavygee Jun 5, 2026
6322f67
refactor(telegram): drop duplicate tool-args formatter, use shared mo…
heavygee Jun 7, 2026
49c1d0a
fix(fcm): require positive evidence in health window before suppressi…
heavygee Jun 8, 2026
ccac1f9
fix(hub): bump FCM migration to V10→V11 after upstream service_tier V…
heavygee Jun 17, 2026
8f87051
fix(hub): per-dispatch native gate instead of stale FCM probe
heavygee Jun 17, 2026
8b5a5b3
fix(fcm): wrist push on model errors via sendModelError (#878)
heavygee Jun 17, 2026
dd370e1
fix(hub,web): cap notifySummary for FCM limits; fix PWA test cast
heavygee Jun 20, 2026
fd89f05
fix(hub): cap all FCM notifySummary fields and task bodies
heavygee Jun 20, 2026
80c3490
fix(hub): FCM fetch timeouts and cap Grep/Glob permission args
heavygee Jun 20, 2026
ab16803
fix(hub): bind FCM token to one namespace on re-pair
heavygee Jun 20, 2026
0757054
fix(web): localize Companion settings and pairing copy
heavygee Jun 20, 2026
6d6bf77
fix(hub): tighten FCM token-invalid detection and truncation edge cases
heavygee Jun 20, 2026
e0ab169
fix(hub): parse FcmError details.errorCode for UNREGISTERED tokens
heavygee Jun 20, 2026
1035913
Merge branch 'feat/mermaid-lightbox-737' into driver/integration
heavygee Jun 20, 2026
9633343
Merge branch 'feat/codex-usage-indicator-rebased' into driver/integra…
heavygee Jun 20, 2026
aaaea91
Merge branch 'soup/cursor-model-error-fcm-bridge' into driver/integra…
heavygee Jun 20, 2026
526c04b
Merge branch 'feat/sse-patch-extend-session-state' into driver/integr…
heavygee Jun 20, 2026
0562c0b
Merge branch 'fix/cursor-flat-picker-default-highlight' into driver/i…
heavygee Jun 20, 2026
f4b962b
Merge branch 'soup/scratchlist-v2-v11' into driver/integration
heavygee Jun 20, 2026
b804c6d
fix(hub): narrow types so manifest soup typecheck passes
heavygee Jun 16, 2026
54a6c56
fix(cli): Pi parse schemas for Zod 4 optional field output
heavygee Jun 19, 2026
012bd90
test(hub): allow same-ms updatedAt in session handler patch tests
heavygee Jun 19, 2026
8eac749
Merge branch 'feat/mermaid-lightbox-737' into driver/integration
heavygee Jun 20, 2026
b13f3c3
Merge branch 'feat/codex-usage-indicator-rebased' into driver/integra…
heavygee Jun 20, 2026
0accc41
Merge branch 'soup/cursor-model-error-fcm-bridge' into driver/integra…
heavygee Jun 20, 2026
b50729a
Merge branch 'feat/sse-patch-extend-session-state' into driver/integr…
heavygee Jun 20, 2026
3cdf481
Merge branch 'fix/cursor-flat-picker-default-highlight' into driver/i…
heavygee Jun 20, 2026
e489972
Merge branch 'soup/scratchlist-v2-v11' into driver/integration
heavygee Jun 20, 2026
f12d156
Merge branch 'fix/soup-typecheck-followups' into driver/integration
heavygee Jun 20, 2026
c4b7008
Merge branch 'feat/tier-b-reattach-orphan-runner-children' into drive…
heavygee Jun 20, 2026
95016c5
Merge branch 'feat/garden-route' into driver/integration
heavygee Jun 20, 2026
531c406
Merge branch 'feat/claude-session-import' into driver/integration
heavygee Jun 20, 2026
127ce54
Merge branch 'feat/agent-session-import-picker' into driver/integration
heavygee Jun 20, 2026
95ef7c6
Merge branch 'feat/session-copy-link' into driver/integration
heavygee Jun 20, 2026
ea2ae48
feat(overseer): events substrate from AGENT_NOTIFY_SUMMARY (#22)
heavygee Jun 19, 2026
fcbca54
fix(hub): type WebAppEnv in systemEvents route test
heavygee Jun 19, 2026
8684388
test(e2e): allow PLAYWRIGHT_WEB_PORT for events debug smoke
heavygee Jun 19, 2026
86d69ae
fix(hub): combine v11 FCM, scratchlist, and events migrations
heavygee Jun 19, 2026
627c7ad
fix(overseer): init-gate events schema; repair FTS triggers (#22)
heavygee Jun 19, 2026
32c86ad
fix(overseer): detach/repoint events on session delete and merge
heavygee Jun 20, 2026
a4c9592
feat(overseer): denormalize session identity + deleted_sessions tombs…
heavygee Jun 20, 2026
20efb03
feat(overseer): inbox substrate with dumb v1 ordering (#23)
heavygee Jun 20, 2026
60ddaaa
fix(overseer): hub-inferred stale silence is captured-only, not inbox…
heavygee Jun 21, 2026
e21a3c5
feat(overseer): read-only Overseer entity + 7 query tools + voice route
heavygee Jun 21, 2026
b174af7
feat(overseer): link_seen taxonomy + hub URL scoop (#22)
Jul 12, 2026
2bd89e1
feat(web): Session Log panel beside Outline (durable events + Links)
Jul 12, 2026
b2b376a
fix(overseer): stop persisting hub stale silence; carve Links out of …
Jul 14, 2026
f6b289a
feat(web): remount Overseer Events+Inbox under post-#1027 Settings About
Jul 14, 2026
2486764
fix(web): hide historical stale rows from Settings Events debug feed
Jul 14, 2026
8045059
fix(web): densify Session Log — relative time, one-line Links
Jul 14, 2026
ff5f384
feat(overseer): make AGENT_NOTIFY_SUMMARY contract invisible to humans
Jul 14, 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
179 changes: 169 additions & 10 deletions bun.lock

Large diffs are not rendered by default.

26 changes: 23 additions & 3 deletions cli/src/api/apiMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type ListOpencodeModelsForCwdRequest,
type ListOpencodeModelsForCwdResponse
} from '../modules/common/opencodeModels'
import { findCodexSessionPath } from '../modules/common/codexSessions'
import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes'
import { applyVersionedAck } from './versionedUpdate'
import { buildSocketIoExtraHeaderOptions } from './hubExtraHeaders'
Expand Down Expand Up @@ -92,7 +93,14 @@ export class ApiMachineClient {
logger: (msg, data) => logger.debug(msg, data)
})

registerCommonHandlers(this.rpcHandlerManager, getInvokedCwd())
registerCommonHandlers(this.rpcHandlerManager, getInvokedCwd(), {
codexSessionPathAllowed: this.normalizedWorkspaceRoots?.length
? async (path) => {
if (!path) return false
return this.isWithinWorkspaceRoots(await this.resolveForWorkspaceCheck(path))
}
: undefined
})

this.rpcHandlerManager.registerHandler<PathExistsRequest, PathExistsResponse>(RPC_METHODS.PathExists, async (params) => {
const rawPaths = Array.isArray(params?.paths) ? params.paths : []
Expand Down Expand Up @@ -249,7 +257,7 @@ export class ApiMachineClient {

setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void {
this.rpcHandlerManager.registerHandler(RPC_METHODS.SpawnHappySession, async (params: any) => {
const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, token, sessionType, worktreeName } = params || {}
const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, token, sessionType, worktreeName, importHistory } = params || {}

if (!directory) {
throw new Error('Directory is required')
Expand All @@ -260,6 +268,17 @@ export class ApiMachineClient {
return { type: 'error', errorMessage: 'Directory is outside this machine\'s workspace roots' }
}

if (agent === 'codex' && resumeSessionId && this.normalizedWorkspaceRoots?.length) {
const codexSessionPath = await findCodexSessionPath(resumeSessionId)
if (!codexSessionPath) {
return { type: 'error', errorMessage: 'Codex session path is unavailable or outside workspace roots' }
}
const resolvedCodexSessionPath = await this.resolveForWorkspaceCheck(codexSessionPath)
if (!this.isWithinWorkspaceRoots(resolvedCodexSessionPath)) {
return { type: 'error', errorMessage: 'Codex session is outside this machine\'s workspace roots' }
}
}

const result = await spawnSession({
directory,
sessionId,
Expand All @@ -275,7 +294,8 @@ export class ApiMachineClient {
serviceTier,
token,
sessionType,
worktreeName
worktreeName,
importHistory
})

switch (result.type) {
Expand Down
223 changes: 223 additions & 0 deletions cli/src/claude/claudeRemote.fork.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as claudeSdk from '@/claude/sdk';
import type { SDKMessage } from '@/claude/sdk/types';
import type { Metadata } from '@/api/types';

vi.mock('@/claude/utils/claudeCheckSession', () => ({
claudeCheckSession: () => true
}));

vi.mock('@/modules/watcher/awaitFileExist', () => ({
awaitFileExist: async () => true
}));

vi.mock('@/claude/sdk/utils', () => ({
getDefaultClaudeCodePath: () => '/usr/bin/claude'
}));

const { getLiveAgentKindMock } = vi.hoisted(() => ({
getLiveAgentKindMock: vi.fn<(sessionId: string) => 'background' | 'interactive' | null>()
}));

vi.mock('@/claude/utils/getLiveAgentKind', () => ({
getLiveAgentKind: getLiveAgentKindMock
}));

const queryMock = vi.fn();

function createAsyncStream(messages: SDKMessage[]): AsyncIterable<SDKMessage> {
return {
async *[Symbol.asyncIterator]() {
for (const message of messages) {
await Promise.resolve();
yield message;
}
}
};
}

const RESUME_ID = '6f0c4551-1111-4222-8333-444455556666';
const FORKED_ID = 'aaaa1111-2222-4333-8444-555566667777';

function initThenResult(sessionId: string): SDKMessage[] {
return [
{
type: 'system',
subtype: 'init',
session_id: sessionId
} as unknown as SDKMessage,
{
type: 'result',
subtype: 'success',
num_turns: 1,
total_cost_usd: 0,
duration_ms: 1,
duration_api_ms: 1,
is_error: false,
session_id: sessionId
} as unknown as SDKMessage
];
}

const ENV_KEYS_UNDER_TEST = ['CLAUDE_CONFIG_DIR', 'DISABLE_AUTOUPDATER'] as const;
let savedEnv: Record<string, string | undefined>;

beforeEach(() => {
vi.clearAllMocks();
savedEnv = {};
for (const key of ENV_KEYS_UNDER_TEST) {
savedEnv[key] = process.env[key];
}
});

afterEach(() => {
// Restore any process.env keys we touched so cases don't leak into each other.
for (const key of ENV_KEYS_UNDER_TEST) {
const original = savedEnv[key];
if (original === undefined) {
delete process.env[key];
} else {
process.env[key] = original;
}
}
});

describe('claudeRemote fork-on-live-session decision', () => {
it('forks (forkSession=true) and records forkedFrom when the session is live', async () => {
const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query);
getLiveAgentKindMock.mockReturnValue('background');
queryMock.mockReturnValueOnce(createAsyncStream(initThenResult(FORKED_ID)));

const { claudeRemote } = await import('./claudeRemote');
const foundCalls: Array<{ id: string; extras?: Partial<Metadata> }> = [];

let nextCallCount = 0;
await claudeRemote({
sessionId: RESUME_ID,
path: process.cwd(),
mcpServers: {},
claudeEnvVars: {},
claudeArgs: [],
allowedTools: [],
hookSettingsPath: '/tmp/hook.json',
canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }),
nextMessage: async () => {
nextCallCount += 1;
if (nextCallCount === 1) {
return { message: 'ping', mode: { permissionMode: 'default' } };
}
return null;
},
onReady: () => {},
isAborted: () => false,
onSessionFound: (id, extras) => {
foundCalls.push({ id, extras });
},
onMessage: () => {}
});

try {
expect(getLiveAgentKindMock).toHaveBeenCalledWith(RESUME_ID);
const passedOptions = queryMock.mock.calls[0][0].options;
expect(passedOptions.resume).toBe(RESUME_ID);
expect(passedOptions.forkSession).toBe(true);
expect(foundCalls).toEqual([{ id: FORKED_ID, extras: { forkedFrom: RESUME_ID } }]);
} finally {
queryMock.mockReset();
querySpy.mockRestore();
}
}, 15_000);

it('applies claudeEnvVars to process.env BEFORE probing liveness (regression)', async () => {
// getLiveAgentKind execs `claude agents --json` off process.env, so the
// probe must see the injected claude env (e.g. CLAUDE_CONFIG_DIR) the real
// launch uses; otherwise it queries the default config and misses live
// sessions. Capture process.env at the moment the probe runs.
const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query);
let configDirAtProbe: string | undefined = 'NOT_CALLED';
getLiveAgentKindMock.mockImplementation(() => {
configDirAtProbe = process.env.CLAUDE_CONFIG_DIR;
return null;
});
queryMock.mockReturnValueOnce(createAsyncStream(initThenResult(RESUME_ID)));

const { claudeRemote } = await import('./claudeRemote');

let nextCallCount = 0;
await claudeRemote({
sessionId: RESUME_ID,
path: process.cwd(),
mcpServers: {},
claudeEnvVars: { CLAUDE_CONFIG_DIR: '/tmp/x-cfg' },
claudeArgs: [],
allowedTools: [],
hookSettingsPath: '/tmp/hook.json',
canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }),
nextMessage: async () => {
nextCallCount += 1;
if (nextCallCount === 1) {
return { message: 'ping', mode: { permissionMode: 'default' } };
}
return null;
},
onReady: () => {},
isAborted: () => false,
onSessionFound: () => {},
onMessage: () => {}
});

try {
expect(getLiveAgentKindMock).toHaveBeenCalledWith(RESUME_ID);
// The injected env must already be live when the probe runs.
expect(configDirAtProbe).toBe('/tmp/x-cfg');
} finally {
queryMock.mockReset();
querySpy.mockRestore();
}
}, 15_000);

it('does NOT fork a dead session and records no forkedFrom (regression: AC7)', async () => {
const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query);
getLiveAgentKindMock.mockReturnValue(null);
// A dead resume reuses the same session id.
queryMock.mockReturnValueOnce(createAsyncStream(initThenResult(RESUME_ID)));

const { claudeRemote } = await import('./claudeRemote');
const foundCalls: Array<{ id: string; extras?: Partial<Metadata> }> = [];

let nextCallCount = 0;
await claudeRemote({
sessionId: RESUME_ID,
path: process.cwd(),
mcpServers: {},
claudeEnvVars: {},
claudeArgs: [],
allowedTools: [],
hookSettingsPath: '/tmp/hook.json',
canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }),
nextMessage: async () => {
nextCallCount += 1;
if (nextCallCount === 1) {
return { message: 'ping', mode: { permissionMode: 'default' } };
}
return null;
},
onReady: () => {},
isAborted: () => false,
onSessionFound: (id, extras) => {
foundCalls.push({ id, extras });
},
onMessage: () => {}
});

try {
const passedOptions = queryMock.mock.calls[0][0].options;
expect(passedOptions.resume).toBe(RESUME_ID);
expect(passedOptions.forkSession).toBe(false);
expect(foundCalls).toEqual([{ id: RESUME_ID, extras: undefined }]);
} finally {
queryMock.mockReset();
querySpy.mockRestore();
}
}, 15_000);
});
37 changes: 34 additions & 3 deletions cli/src/claude/claudeRemote.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { EnhancedMode, PermissionMode } from "./loop";
import { query, type QueryOptions as Options, type SDKMessage, type SDKSystemMessage, AbortError, SDKUserMessage } from '@/claude/sdk'
import { claudeCheckSession } from "./utils/claudeCheckSession";
import { getLiveAgentKind } from "./utils/getLiveAgentKind";
import type { Metadata } from "@/api/types";
import { join } from 'node:path';
import { parseSpecialCommand } from "@/parsers/specialCommands";
import { logger } from "@/lib";
Expand Down Expand Up @@ -31,7 +33,7 @@ export async function claudeRemote(opts: {
isAborted: (toolCallId: string) => boolean,

// Callbacks
onSessionFound: (id: string) => void,
onSessionFound: (id: string, extras?: Partial<Metadata>) => void,
onThinkingChange?: (thinking: boolean) => void,
onMessage: (message: SDKMessage) => void,
onCompletionEvent?: (message: string) => void,
Expand Down Expand Up @@ -71,14 +73,36 @@ export async function claudeRemote(opts: {
}
}

// Set environment variables for Claude Code SDK
// Set environment variables for Claude Code SDK.
// Apply these BEFORE the liveness probe below: `getLiveAgentKind` execs
// `claude agents --json` using `process.env` (e.g. CLAUDE_CONFIG_DIR,
// HAPI_CLAUDE_PATH), so the probe must see the same claude env the real
// launch will use. Otherwise it queries the default config/home, misses the
// live session, picks `forkSession=false`, and the resume hits the
// "currently running as a background agent" failure path.
if (opts.claudeEnvVars) {
Object.entries(opts.claudeEnvVars).forEach(([key, value]) => {
process.env[key] = value;
});
}
process.env.DISABLE_AUTOUPDATER = '1';

// Decide how to resume. A claude session can still be held open by a running
// agent (background/interactive); a plain `--resume` is then rejected with
// "currently running as a background agent". When that's the case, branch off
// a copy with `--fork-session` instead of taking over the live session.
// `getLiveAgentKind` degrades to null (treat as dead -> plain resume) when the
// daemon roster is unavailable, so this never blocks the resume path.
let forkSession = false;
if (startFrom) {
const liveKind = getLiveAgentKind(startFrom);
if (liveKind) {
forkSession = true;
logger.debug(`[claudeRemote] Session ${startFrom} is live as ${liveKind} agent; resuming with --fork-session`);
}
}
const forkedFrom = forkSession ? startFrom : null;

// Get initial message
let initial;
try {
Expand Down Expand Up @@ -125,6 +149,7 @@ export async function claudeRemote(opts: {
const sdkOptions: Options = {
cwd: opts.path,
resume: startFrom ?? undefined,
forkSession,
mcpServers: opts.mcpServers,
permissionMode: initial.mode.permissionMode,
model: initial.mode.model,
Expand Down Expand Up @@ -250,7 +275,13 @@ export async function claudeRemote(opts: {
const projectDir = getProjectPath(opts.path);
const found = await awaitFileExist(join(projectDir, `${systemInit.session_id}.jsonl`));
logger.debug(`[claudeRemote] Session file found: ${systemInit.session_id} ${found}`);
opts.onSessionFound(systemInit.session_id);
// When forked, the new session_id is a branched copy: record its
// origin so the web list can mark it as "forked from ..." instead
// of surfacing two unrelated-looking sessions (R5).
const extras = forkedFrom && forkedFrom !== systemInit.session_id
? { forkedFrom }
: undefined;
opts.onSessionFound(systemInit.session_id, extras);
}
}

Expand Down
Loading
Loading