@oar-sh/oar 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/README.md +190 -0
- package/.github/extensions/web-relay/extension.mjs +972 -0
- package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
- package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
- package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
- package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
- package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
- package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
- package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
- package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
- package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
- package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
- package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
- package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
- package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
- package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
- package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
- package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
- package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
- package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
- package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
- package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/bin/bat/oar.bat +46 -0
- package/bin/oar.js +665 -0
- package/package.json +68 -0
- package/server/README.md +1379 -0
- package/server/claude-worker/claude-attachments.mjs +134 -0
- package/server/claude-worker/claude-preview-tool.mjs +57 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
- package/server/claude-worker/claude-session-process.mjs +2391 -0
- package/server/claude-worker/claude-session-test-harness.mjs +236 -0
- package/server/claude-worker/claude-session-worker.mjs +186 -0
- package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
- package/server/claude-worker/claude-turn-publisher.mjs +291 -0
- package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
- package/server/claude-worker/workflow-progress-digest.mjs +269 -0
- package/server/copilot-sdk-runtime.mjs +126 -0
- package/server/copilot-worker/copilot-attachments.mjs +97 -0
- package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
- package/server/copilot-worker/copilot-plan-board.mjs +110 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
- package/server/copilot-worker/fixtures/abort-turn.json +588 -0
- package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
- package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
- package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
- package/server/copilot-worker/fixtures/happy-turn.json +566 -0
- package/server/copilot-worker/fixtures/quota-turn.json +647 -0
- package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
- package/server/copilot-worker/fixtures/resume-turn.json +700 -0
- package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
- package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
- package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
- package/server/cursor-worker/cursor-attachments.mjs +88 -0
- package/server/cursor-worker/cursor-context-usage.mjs +85 -0
- package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
- package/server/cursor-worker/cursor-session-worker.mjs +145 -0
- package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
- package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
- package/server/db-schema.mjs +932 -0
- package/server/features.mjs +106 -0
- package/server/grok-worker/acp-client.mjs +434 -0
- package/server/grok-worker/acp-host-services.mjs +277 -0
- package/server/grok-worker/grok-context-usage.mjs +77 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
- package/server/grok-worker/grok-session-worker.mjs +145 -0
- package/server/grok-worker/grok-turn-runner.mjs +523 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
- package/server/migrations/0001-add-structured-answer.mjs +80 -0
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
- package/server/migrations/0003-push-subscriptions.mjs +87 -0
- package/server/public/app/action-confirmations.js +267 -0
- package/server/public/app/activity-replay-state.mjs +149 -0
- package/server/public/app/api-client.js +1082 -0
- package/server/public/app/ask-user-view.js +545 -0
- package/server/public/app/attachments-view.js +2112 -0
- package/server/public/app/auto-compact-window-options.mjs +59 -0
- package/server/public/app/background-tasks-view.mjs +494 -0
- package/server/public/app/bootstrap.js +4347 -0
- package/server/public/app/claude-auth-ui.js +394 -0
- package/server/public/app/claude-thinking-options.mjs +38 -0
- package/server/public/app/cli-install-ui.js +455 -0
- package/server/public/app/code-copy.mjs +85 -0
- package/server/public/app/composer-attachment-cache.mjs +151 -0
- package/server/public/app/composer-control-state.mjs +107 -0
- package/server/public/app/composer-paste.mjs +132 -0
- package/server/public/app/context-tier-options.mjs +68 -0
- package/server/public/app/context-usage-view.mjs +338 -0
- package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
- package/server/public/app/conversation-preferences.mjs +91 -0
- package/server/public/app/conversation-provider-indicator.mjs +88 -0
- package/server/public/app/conversation-view.js +2898 -0
- package/server/public/app/cwd-menu-placement.mjs +85 -0
- package/server/public/app/cwd-picker.js +605 -0
- package/server/public/app/emoji-view.js +101 -0
- package/server/public/app/external-link-policy.mjs +80 -0
- package/server/public/app/file-preview-navigation.mjs +51 -0
- package/server/public/app/font-scaling.js +262 -0
- package/server/public/app/git-changes-view.js +339 -0
- package/server/public/app/git-diff-model.mjs +109 -0
- package/server/public/app/grok-auth-ui.js +387 -0
- package/server/public/app/image-downscale.mjs +174 -0
- package/server/public/app/in-flight-snapshot.mjs +40 -0
- package/server/public/app/infinite-loader.js +164 -0
- package/server/public/app/journal-view.js +1141 -0
- package/server/public/app/known-cwd-options.mjs +41 -0
- package/server/public/app/launch-cwd-path.mjs +23 -0
- package/server/public/app/live-message-dedupe.mjs +57 -0
- package/server/public/app/message-search-view.js +242 -0
- package/server/public/app/model-selector-options.mjs +31 -0
- package/server/public/app/model-vision-support.mjs +56 -0
- package/server/public/app/new-conversation-model-choice.mjs +67 -0
- package/server/public/app/plan-usage-view.mjs +307 -0
- package/server/public/app/preview-cards.mjs +273 -0
- package/server/public/app/preview-command.mjs +103 -0
- package/server/public/app/push-settings.js +370 -0
- package/server/public/app/pwa-install.js +456 -0
- package/server/public/app/question-schema-view.mjs +71 -0
- package/server/public/app/reasoning-effort-labels.mjs +42 -0
- package/server/public/app/relay-board-view.js +117 -0
- package/server/public/app/relay-dot-state.mjs +69 -0
- package/server/public/app/relay-error-ctas.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
- package/server/public/app/relay-thoughts.mjs +98 -0
- package/server/public/app/relay-watchdog-policy.mjs +29 -0
- package/server/public/app/repo-browser-preferences.mjs +79 -0
- package/server/public/app/repo-browser-tree-state.mjs +97 -0
- package/server/public/app/router.js +657 -0
- package/server/public/app/selection-guard.mjs +93 -0
- package/server/public/app/settings-modal.js +1484 -0
- package/server/public/app/settings-tabs.js +129 -0
- package/server/public/app/slash-autocomplete.mjs +188 -0
- package/server/public/app/slash-commands.mjs +183 -0
- package/server/public/app/socket-handlers.js +703 -0
- package/server/public/app/status-store.mjs +242 -0
- package/server/public/app/status-view.mjs +315 -0
- package/server/public/app/store.js +1454 -0
- package/server/public/app/stream-state.mjs +73 -0
- package/server/public/app/streaming-dom-patch.mjs +23 -0
- package/server/public/app/sync-outbox.mjs +208 -0
- package/server/public/app/tap-actions.js +62 -0
- package/server/public/app/thread-order.mjs +54 -0
- package/server/public/app/tmux-inspector-view.js +422 -0
- package/server/public/app/transcript-separators.mjs +282 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon.svg +4 -0
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +5743 -0
- package/server/public/manifest.webmanifest +32 -0
- package/server/public/sw.js +307 -0
- package/server/relay-exit-codes.mjs +4 -0
- package/server/relay-self-restart.mjs +170 -0
- package/server/relay-tools.md +27 -0
- package/server/relay.mjs +1026 -0
- package/server/repositories/image-conversation-repository.mjs +317 -0
- package/server/repositories/message-repository.mjs +434 -0
- package/server/repositories/question-repository.mjs +161 -0
- package/server/repositories/session-repository.mjs +320 -0
- package/server/repositories/share-visibility-statements.mjs +24 -0
- package/server/routes/ask-user-routes.mjs +292 -0
- package/server/routes/cache-routes.mjs +44 -0
- package/server/routes/git-routes.mjs +92 -0
- package/server/routes/messages-routes-test-harness.mjs +111 -0
- package/server/routes/messages-routes.mjs +7107 -0
- package/server/routes/preview-routes.mjs +108 -0
- package/server/routes/push-routes.mjs +59 -0
- package/server/routes/relay-board-routes.mjs +268 -0
- package/server/routes/sessions-routes.mjs +6150 -0
- package/server/server-runtime.mjs +7463 -0
- package/server/server.js +24 -0
- package/server/services/ask-user-routing-service.mjs +117 -0
- package/server/services/cache-rebuild-service.mjs +236 -0
- package/server/services/claude-auth-service.mjs +578 -0
- package/server/services/claude-context-usage.mjs +240 -0
- package/server/services/claude-session-root-service.mjs +228 -0
- package/server/services/cli-install-service.mjs +1152 -0
- package/server/services/cli-process-runner.mjs +181 -0
- package/server/services/cloudflared-tunnel-service.mjs +358 -0
- package/server/services/context-snapshot-service.mjs +499 -0
- package/server/services/context-usage-view.mjs +160 -0
- package/server/services/conversation-preferences-service.mjs +208 -0
- package/server/services/cursor-dashboard-usage.mjs +217 -0
- package/server/services/cursor-session-root-service.mjs +62 -0
- package/server/services/delete-archive-service.mjs +154 -0
- package/server/services/drives-path-helpers.mjs +66 -0
- package/server/services/fixtures/claude-auth-stub.sh +62 -0
- package/server/services/fixtures/cli-install-stub.sh +75 -0
- package/server/services/fixtures/grok-stub.sh +86 -0
- package/server/services/generated-image-cleanup-service.mjs +114 -0
- package/server/services/git-changes-service.mjs +195 -0
- package/server/services/github-billing-usage.mjs +133 -0
- package/server/services/grok-auth-service.mjs +593 -0
- package/server/services/grok-billing-usage.mjs +120 -0
- package/server/services/image-context-service.mjs +106 -0
- package/server/services/image-operation-service.mjs +135 -0
- package/server/services/keyed-mutex-service.mjs +82 -0
- package/server/services/mime-sniffer.mjs +118 -0
- package/server/services/oar-cli-helpers.mjs +109 -0
- package/server/services/oar-state-migration-service.mjs +243 -0
- package/server/services/plan-usage-claude.mjs +387 -0
- package/server/services/plan-usage-contract.mjs +271 -0
- package/server/services/plan-usage-copilot.mjs +421 -0
- package/server/services/plan-usage-cursor.mjs +566 -0
- package/server/services/plan-usage-grok.mjs +436 -0
- package/server/services/plan-usage-service.mjs +487 -0
- package/server/services/preview-health-probe.mjs +82 -0
- package/server/services/preview-proxy-server.mjs +498 -0
- package/server/services/preview-registry-service.mjs +457 -0
- package/server/services/preview-static-handler.mjs +237 -0
- package/server/services/provider-model-selection.mjs +91 -0
- package/server/services/provider-reasoning-effort.mjs +94 -0
- package/server/services/push-active-device-service.mjs +57 -0
- package/server/services/push-dispatch-service.mjs +459 -0
- package/server/services/rate-limit-service.mjs +63 -0
- package/server/services/relay-bridge-owner-service.mjs +84 -0
- package/server/services/relay-cli-launcher-service.mjs +209 -0
- package/server/services/relay-prompt-sanitizer.mjs +137 -0
- package/server/services/relay-queue-gate-service.mjs +38 -0
- package/server/services/relay-restart-orchestrator-service.mjs +935 -0
- package/server/services/relay-singleton-guard.mjs +124 -0
- package/server/services/safe-served-content.mjs +57 -0
- package/server/services/sdk-session-import-service.mjs +271 -0
- package/server/services/sdk-session-sync-service.mjs +207 -0
- package/server/services/session-history-refresh-service.mjs +497 -0
- package/server/services/session-transcript-service.mjs +419 -0
- package/server/services/session-worker-launch-service.mjs +786 -0
- package/server/services/session-worker-process-service.mjs +319 -0
- package/server/services/session-worker-registry-service.mjs +194 -0
- package/server/services/session-worker-stop-service.mjs +141 -0
- package/server/services/session-worker-supervisor-service.mjs +898 -0
- package/server/services/session-worker-websocket-service.mjs +483 -0
- package/server/services/ssh-tunnel-manager-service.mjs +423 -0
- package/server/services/status-event-service.mjs +158 -0
- package/server/services/tmux-inspector-access-policy.mjs +77 -0
- package/server/services/tmux-inspector-socket-service.mjs +191 -0
- package/server/services/tmux-inspector-stream-service.mjs +552 -0
- package/server/services/tunnel-worker-path-guard.mjs +107 -0
- package/server/services/upload-sweep.mjs +60 -0
- package/server/services/usage-snapshot-helpers.mjs +209 -0
- package/server/services/windows-autostart-service.mjs +115 -0
- package/server/services/workspace-root-defaults-service.mjs +34 -0
- package/server/services/workspace-root-path-policy.mjs +196 -0
- package/server/services/workspace-root-relaunch-service.mjs +132 -0
- package/server/services/workspace-symlink-guard.mjs +54 -0
- package/server/tools/capture-proxy.mjs +173 -0
- package/server/tty-console-bootstrap.mjs +75 -0
- package/server/uploads/README.md +36 -0
- package/server/uploads/image_input.md +540 -0
- package/server/windows-terminal-launcher.mjs +29 -0
- package/server/workspace-root.mjs +87 -0
- package/shared/ask-user-bridge.mjs +131 -0
- package/shared/auto-compact-window.mjs +116 -0
- package/shared/background-task-timeout.mjs +52 -0
- package/shared/claude-thinking.mjs +101 -0
- package/shared/context-window-fallbacks.mjs +148 -0
- package/shared/control-poller.mjs +115 -0
- package/shared/empty-turn-completion.mjs +20 -0
- package/shared/model-catalog-freshness.mjs +11 -0
- package/shared/model-descriptors.mjs +118 -0
- package/shared/model-id.mjs +147 -0
- package/shared/model-refresh.mjs +24 -0
- package/shared/openai-reasoning.mjs +13 -0
- package/shared/plan-lines.mjs +13 -0
- package/shared/preview-instructions.mjs +77 -0
- package/shared/preview-tool-core.mjs +191 -0
- package/shared/provider-routing.mjs +29 -0
- package/shared/question-schema.mjs +315 -0
- package/shared/question-timeout.mjs +18 -0
- package/shared/stream-emit-gating.mjs +28 -0
- package/shared/subagent-run-id.mjs +17 -0
- package/shared/thought-cap.mjs +13 -0
- package/shared/tool-activity.mjs +98 -0
- package/shared/turn-ceiling.mjs +53 -0
- package/shared/worker-bootstrap.mjs +44 -0
- package/shared/worker-crash-guard.mjs +60 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Shared test harness for the Claude session runner suites
|
|
2
|
+
// (claude-session-process.test.mjs and
|
|
3
|
+
// claude-session-process.routes-integration.test.mjs): the scripted SDK
|
|
4
|
+
// stand-ins, SDK message builders, api stub, and runner factory they both use.
|
|
5
|
+
// Not a *.test.mjs file on purpose — the node --test glob must not pick it up.
|
|
6
|
+
|
|
7
|
+
import { createClaudeSessionRunner } from './claude-session-process.mjs';
|
|
8
|
+
|
|
9
|
+
// Keeps the tests off the real `~/.claude/projects`; transcript relocation has
|
|
10
|
+
// its own suite in claude-transcript-relocator.test.mjs.
|
|
11
|
+
export const noopRelocate = () => ({ status: 'skipped' });
|
|
12
|
+
|
|
13
|
+
export const tick = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
|
|
15
|
+
export async function waitFor(predicate, { timeoutMs = 3000, label = 'condition' } = {}) {
|
|
16
|
+
const start = Date.now();
|
|
17
|
+
for (;;) {
|
|
18
|
+
const value = predicate();
|
|
19
|
+
if (value) return value;
|
|
20
|
+
if (Date.now() - start > timeoutMs) throw new Error(`waitFor timed out: ${label}`);
|
|
21
|
+
await tick();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function makeApiStub({ failRoutes = new Set(), continuationIds = [] } = {}) {
|
|
26
|
+
const calls = [];
|
|
27
|
+
let continuationCounter = 0;
|
|
28
|
+
return {
|
|
29
|
+
calls,
|
|
30
|
+
api: async (method, routePath, body) => {
|
|
31
|
+
calls.push({ method, routePath, body });
|
|
32
|
+
if (failRoutes.has(routePath)) throw new Error(`stubbed failure for ${routePath}`);
|
|
33
|
+
if (routePath === '/api/continuation-turn') {
|
|
34
|
+
continuationCounter += 1;
|
|
35
|
+
return { messageId: continuationIds[continuationCounter - 1] || `cont-${continuationCounter}` };
|
|
36
|
+
}
|
|
37
|
+
return { ok: true };
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A scriptable stand-in for the SDK Query: the test emits SDK messages when it
|
|
44
|
+
* chooses, the stream only ends on endInput/close (like the real CLI), and
|
|
45
|
+
* pushed user turns are recorded (and optionally echoed back as replays).
|
|
46
|
+
* NOTE: the real CLI replays a pushed message when it DEQUEUES it — between
|
|
47
|
+
* turns for queued-behind messages, or mid-turn when it absorbs the push as
|
|
48
|
+
* steering — never at push time. echoPushes replays at push time, so it only
|
|
49
|
+
* models pushes made while no turn is active; scripts that queue a message
|
|
50
|
+
* behind a running turn must emit the replay manually at the dequeue point.
|
|
51
|
+
*/
|
|
52
|
+
export function scriptedTurn({ echoPushes = false } = {}) {
|
|
53
|
+
const queued = [];
|
|
54
|
+
let wake = null;
|
|
55
|
+
let ended = false;
|
|
56
|
+
const turn = {
|
|
57
|
+
pushed: [],
|
|
58
|
+
endInputCalls: 0,
|
|
59
|
+
closed: false,
|
|
60
|
+
interrupts: 0,
|
|
61
|
+
stoppedTasks: [],
|
|
62
|
+
pushUserMessage(content) {
|
|
63
|
+
if (ended) throw new Error('user message stream already ended');
|
|
64
|
+
turn.pushed.push(content);
|
|
65
|
+
if (echoPushes) {
|
|
66
|
+
turn.emit({ type: 'user', parent_tool_use_id: null, message: { role: 'user', content } });
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
endInput() {
|
|
70
|
+
turn.endInputCalls += 1;
|
|
71
|
+
ended = true;
|
|
72
|
+
wake?.();
|
|
73
|
+
},
|
|
74
|
+
close() {
|
|
75
|
+
turn.closed = true;
|
|
76
|
+
ended = true;
|
|
77
|
+
wake?.();
|
|
78
|
+
},
|
|
79
|
+
async interrupt() {
|
|
80
|
+
turn.interrupts += 1;
|
|
81
|
+
},
|
|
82
|
+
async stopTask(taskId) {
|
|
83
|
+
turn.stoppedTasks.push(taskId);
|
|
84
|
+
},
|
|
85
|
+
emit(message) {
|
|
86
|
+
queued.push(message);
|
|
87
|
+
wake?.();
|
|
88
|
+
},
|
|
89
|
+
fail(error) {
|
|
90
|
+
queued.push({ __throw: error });
|
|
91
|
+
wake?.();
|
|
92
|
+
},
|
|
93
|
+
async* [Symbol.asyncIterator]() {
|
|
94
|
+
for (;;) {
|
|
95
|
+
while (queued.length) {
|
|
96
|
+
const next = queued.shift();
|
|
97
|
+
if (next?.__throw) throw next.__throw;
|
|
98
|
+
yield next;
|
|
99
|
+
}
|
|
100
|
+
if (ended) return;
|
|
101
|
+
await new Promise((resolve) => { wake = resolve; });
|
|
102
|
+
wake = null;
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
return turn;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Emits the given messages, then the stream ends by itself. */
|
|
110
|
+
export function fakeTurn(messages) {
|
|
111
|
+
return {
|
|
112
|
+
pushUserMessage() {},
|
|
113
|
+
endInput() {},
|
|
114
|
+
async* [Symbol.asyncIterator]() {
|
|
115
|
+
for (const message of messages) yield message;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function initMessage(sessionId) {
|
|
121
|
+
return { type: 'system', subtype: 'init', session_id: sessionId, model: 'claude-sonnet-5' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function resultMessage(text, sessionId) {
|
|
125
|
+
return {
|
|
126
|
+
type: 'result', subtype: 'success', is_error: false, result: text, session_id: sessionId, num_turns: 1, duration_api_ms: 100,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function phantomResultMessage(sessionId) {
|
|
131
|
+
return {
|
|
132
|
+
type: 'result', subtype: 'success', is_error: false, result: '', session_id: sessionId, num_turns: 0, duration_api_ms: 0,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function backgroundTasksMessage(tasks) {
|
|
137
|
+
return { type: 'system', subtype: 'background_tasks_changed', tasks };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function taskNotificationMessage(taskId, status = 'completed', { skipTranscript = false } = {}) {
|
|
141
|
+
return {
|
|
142
|
+
type: 'system',
|
|
143
|
+
subtype: 'task_notification',
|
|
144
|
+
task_id: taskId,
|
|
145
|
+
status,
|
|
146
|
+
summary: 'settled',
|
|
147
|
+
skip_transcript: skipTranscript,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The live wire shape: snake_case under `compact_metadata`, and `post_tokens`
|
|
153
|
+
* absent on every auto-compact payload observed (conv 563e252e, 2026-08-20).
|
|
154
|
+
*/
|
|
155
|
+
export function compactBoundaryMessage({ preTokens = 614117, postTokens = null, trigger = 'auto' } = {}) {
|
|
156
|
+
return {
|
|
157
|
+
type: 'system',
|
|
158
|
+
subtype: 'compact_boundary',
|
|
159
|
+
compact_metadata: {
|
|
160
|
+
trigger,
|
|
161
|
+
pre_tokens: preTokens,
|
|
162
|
+
...(postTokens === null ? {} : { post_tokens: postTokens }),
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The CLI's compaction announcement. Emitted ONCE, when compaction starts: the
|
|
169
|
+
* bundle's 30 s re-emit is gated on the remote-control client's activity
|
|
170
|
+
* callback, which the plain SDK `query()` this worker uses never registers.
|
|
171
|
+
*/
|
|
172
|
+
export function compactingStatusMessage(status = 'compacting') {
|
|
173
|
+
return { type: 'system', subtype: 'status', status };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The INFERRED shape of what re-opens the turn after an auto-compaction: the
|
|
178
|
+
* CLI's own summary message, which matches no delivered entry's text. Read off
|
|
179
|
+
* the on-disk transcript (`isCompactSummary: true`) plus the runner's observed
|
|
180
|
+
* behaviour in conv 563e252e — not captured from the SDK stream. What the
|
|
181
|
+
* tests actually pin is the runner's response to ANY turn-opening user message
|
|
182
|
+
* that follows a compaction and matches nothing pending.
|
|
183
|
+
*/
|
|
184
|
+
export function compactSummaryReplay() {
|
|
185
|
+
return userReplay('This session is being continued from a previous conversation that ran out of context.\n\nSummary:\n1. Primary Request…');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function userReplay(text) {
|
|
189
|
+
return { type: 'user', parent_tool_use_id: null, message: { role: 'user', content: [{ type: 'text', text }] } };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A settled task's continuation as the SDK actually stamps it: `origin.kind`
|
|
194
|
+
* carries the provenance (SDKMessageOrigin) and `content` is a plain string,
|
|
195
|
+
* not a block array. Deliberately WITHOUT the `<task-notification>` tag in the
|
|
196
|
+
* text, so anything that passes only by tag-sniffing fails here.
|
|
197
|
+
*/
|
|
198
|
+
export function taskNotificationReplay(text = 'The background agent has finished its work.') {
|
|
199
|
+
return {
|
|
200
|
+
type: 'user',
|
|
201
|
+
parent_tool_use_id: null,
|
|
202
|
+
origin: { kind: 'task-notification' },
|
|
203
|
+
message: { role: 'user', content: text },
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function assistantText(text) {
|
|
208
|
+
return { type: 'assistant', parent_tool_use_id: null, message: { content: [{ type: 'text', text }] } };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Frozen: tests derive per-test messages via spread ({ ...baseMessage, ... }),
|
|
212
|
+
// never by mutating this object.
|
|
213
|
+
export const baseMessage = Object.freeze({
|
|
214
|
+
id: 'q-1',
|
|
215
|
+
conversationId: 'conv-1',
|
|
216
|
+
relayMode: 'agent',
|
|
217
|
+
text: 'hello',
|
|
218
|
+
model: 'claude-sonnet-5',
|
|
219
|
+
attachments: [],
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
export function makeRunner({ stub, startImpl, ...overrides }) {
|
|
223
|
+
return createClaudeSessionRunner({
|
|
224
|
+
api: stub.api,
|
|
225
|
+
sdkSessionId: 'conv-1',
|
|
226
|
+
cwd: '/tmp',
|
|
227
|
+
relocateTranscriptImpl: noopRelocate,
|
|
228
|
+
startClaudeSessionImpl: startImpl,
|
|
229
|
+
continuationRetryDelayMs: 10,
|
|
230
|
+
...overrides,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function settled(runner) {
|
|
235
|
+
await waitFor(() => !runner._getProcess(), { label: 'process teardown' });
|
|
236
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Claude session worker: a per-conversation Node process that speaks the same
|
|
3
|
+
// relay contracts as the Copilot CLI workers (worker WebSocket, heartbeat,
|
|
4
|
+
// control polling, activity channels) but executes turns through the Claude
|
|
5
|
+
// Agent SDK using the host machine's logged-in Claude credentials.
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
loadTokenFromConfig,
|
|
11
|
+
resolveRelayServerUrl,
|
|
12
|
+
} from '../../.github/extensions/web-relay/runtime/config-loader.mjs';
|
|
13
|
+
import { createApiClient } from '../../.github/extensions/web-relay/runtime/api-client.mjs';
|
|
14
|
+
import { createWorkerWebSocketLink } from '../../.github/extensions/web-relay/runtime/worker-websocket-link.mjs';
|
|
15
|
+
import { createHeartbeatController } from '../../.github/extensions/web-relay/polling/heartbeat.mjs';
|
|
16
|
+
import { createControlPoller } from '../../shared/control-poller.mjs';
|
|
17
|
+
import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
|
|
18
|
+
import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
|
|
19
|
+
import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
|
|
20
|
+
import { createClaudeSessionRunner } from './claude-session-process.mjs';
|
|
21
|
+
|
|
22
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const HEARTBEAT_MS = 10_000;
|
|
24
|
+
|
|
25
|
+
function parseSessionIdArg(argv = process.argv) {
|
|
26
|
+
const index = argv.indexOf('--session-id');
|
|
27
|
+
if (index !== -1 && argv[index + 1]) return String(argv[index + 1]).trim();
|
|
28
|
+
const inline = argv.find((arg) => String(arg || '').startsWith('--session-id='));
|
|
29
|
+
if (inline) return String(inline.split('=')[1] || '').trim();
|
|
30
|
+
return String(process.env.SESSION_ID || '').trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function dbg(...parts) {
|
|
34
|
+
const timestamp = new Date().toISOString();
|
|
35
|
+
console.log(`[claude-worker ${timestamp}]`, ...parts);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function main() {
|
|
39
|
+
const sdkSessionId = parseSessionIdArg();
|
|
40
|
+
if (!sdkSessionId) {
|
|
41
|
+
console.error('claude-session-worker: missing --session-id');
|
|
42
|
+
process.exit(2);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
|
|
46
|
+
|| path.resolve(__dirname, '..', 'config.json');
|
|
47
|
+
const serverUrl = resolveRelayServerUrl({ configPath });
|
|
48
|
+
const token = loadTokenFromConfig(configPath);
|
|
49
|
+
const cwd = String(process.env.COPILOT_WORKSPACE_ROOT || '').trim() || process.cwd();
|
|
50
|
+
const defaultModel = String(process.env.CLAUDE_RELAY_MODEL || '').trim();
|
|
51
|
+
const pathToClaudeCodeExecutable = String(process.env.CLAUDE_CODE_EXECUTABLE || '').trim();
|
|
52
|
+
// How long background tasks alone may keep the CLI process alive (0 = no
|
|
53
|
+
// limit). Seeded from the environment; refreshed from every delivery payload
|
|
54
|
+
// so the settings slider applies without a worker restart.
|
|
55
|
+
let backgroundTaskTimeoutMs = Number(process.env.CLAUDE_RELAY_BACKGROUND_TASK_TIMEOUT_MS) || 0;
|
|
56
|
+
// Per-conversation auto-compact window (token count, null = Auto); arrives
|
|
57
|
+
// piggybacked on each queue delivery, like the timeouts above.
|
|
58
|
+
let autoCompactWindow = null;
|
|
59
|
+
// Per-conversation thinking control ({enabled: bool|null, display}); same
|
|
60
|
+
// piggyback. enabled null = host default; display defaults to 'summarized'
|
|
61
|
+
// (today's behavior — visible thought bubbles).
|
|
62
|
+
let thinking = resolveDeliveredThinking(null, null);
|
|
63
|
+
|
|
64
|
+
const api = createApiClient({
|
|
65
|
+
serverUrl,
|
|
66
|
+
token,
|
|
67
|
+
getHeaders: () => ({
|
|
68
|
+
'X-Relay-Process-Pid': String(process.pid),
|
|
69
|
+
'X-Relay-Parent-Pid': String(process.ppid),
|
|
70
|
+
'X-Relay-Session-Id': sdkSessionId,
|
|
71
|
+
'X-Relay-Conversation-Id': sdkSessionId,
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const controlPoller = createControlPoller({
|
|
76
|
+
api,
|
|
77
|
+
sdkSessionId,
|
|
78
|
+
abortAckNote: 'claude query aborted',
|
|
79
|
+
// Backgrounded subagents are SDK tasks; their relay run id is the
|
|
80
|
+
// spawning tool_use id, which maps to a stoppable task id. (Late-bound:
|
|
81
|
+
// controls only poll while a turn is live, well after turnRunner exists.)
|
|
82
|
+
onAbortSubagent: (subagentRunId) => turnRunner.stopBackgroundTaskByToolUseId(subagentRunId),
|
|
83
|
+
dbg,
|
|
84
|
+
});
|
|
85
|
+
const turnRunner = createClaudeSessionRunner({
|
|
86
|
+
api,
|
|
87
|
+
sdkSessionId,
|
|
88
|
+
cwd,
|
|
89
|
+
defaultModel,
|
|
90
|
+
controlPoller,
|
|
91
|
+
pathToClaudeCodeExecutable,
|
|
92
|
+
idleShutdownMs: Number(process.env.CLAUDE_RELAY_IDLE_SHUTDOWN_MS) > 0
|
|
93
|
+
? Number(process.env.CLAUDE_RELAY_IDLE_SHUTDOWN_MS)
|
|
94
|
+
: undefined,
|
|
95
|
+
// 0 disables the watchdog (matching the background-task timeout's
|
|
96
|
+
// 0 = no-limit convention); unset/invalid falls back to the default.
|
|
97
|
+
pendingDeliveredTimeoutMs: (() => {
|
|
98
|
+
const raw = String(process.env.CLAUDE_RELAY_PENDING_DELIVERED_TIMEOUT_MS || '').trim();
|
|
99
|
+
const parsed = Number(raw);
|
|
100
|
+
return raw !== '' && Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
|
101
|
+
})(),
|
|
102
|
+
getBackgroundTaskTimeoutMs: () => backgroundTaskTimeoutMs,
|
|
103
|
+
getAutoCompactWindow: () => autoCompactWindow,
|
|
104
|
+
getThinking: () => thinking,
|
|
105
|
+
dbg,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
let heartbeatTimer = null;
|
|
109
|
+
const heartbeat = createHeartbeatController({
|
|
110
|
+
api,
|
|
111
|
+
pollMs: HEARTBEAT_MS,
|
|
112
|
+
getSessionReady: () => true,
|
|
113
|
+
getHeartbeatTimer: () => heartbeatTimer,
|
|
114
|
+
setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
|
|
115
|
+
getActiveQueueMessageId: () => turnRunner.getActiveQueueMessageId(),
|
|
116
|
+
getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds(),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const wsLink = createWorkerWebSocketLink({
|
|
120
|
+
serverUrl,
|
|
121
|
+
token,
|
|
122
|
+
dbg,
|
|
123
|
+
getSessionReady: () => true,
|
|
124
|
+
getSessionId: () => sdkSessionId,
|
|
125
|
+
getPid: () => process.pid,
|
|
126
|
+
onDeliver: async (pending, reason) => {
|
|
127
|
+
dbg('queue.deliver received', `reason=${reason}`, `msgId=${pending?.message?.id || 'none'}`);
|
|
128
|
+
const deliveredTimeout = Number(pending?.settings?.backgroundTaskTimeoutMs);
|
|
129
|
+
if (Number.isFinite(deliveredTimeout) && deliveredTimeout >= 0) {
|
|
130
|
+
backgroundTaskTimeoutMs = deliveredTimeout;
|
|
131
|
+
}
|
|
132
|
+
// Absent (an older relay) leaves the last known value alone; an explicit
|
|
133
|
+
// null is the user choosing Auto and must clear the pin.
|
|
134
|
+
autoCompactWindow = resolveDeliveredAutoCompactWindow(autoCompactWindow, pending?.settings);
|
|
135
|
+
// Same presence semantics as the window: absent keys (an older relay)
|
|
136
|
+
// keep the last known state; an explicit null on thinkingEnabled is the
|
|
137
|
+
// user choosing Host default.
|
|
138
|
+
thinking = resolveDeliveredThinking(thinking, pending?.settings);
|
|
139
|
+
try {
|
|
140
|
+
return await turnRunner.handlePendingPayload(pending);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
dbg('turn handling failed', error?.message || String(error));
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
onControl: async (control) => {
|
|
147
|
+
const type = String(control?.type || '').trim();
|
|
148
|
+
if (type === 'stop_background_task') {
|
|
149
|
+
const taskId = String(control?.taskId || '').trim();
|
|
150
|
+
dbg('stop background task control', taskId);
|
|
151
|
+
await turnRunner.stopBackgroundTask(taskId);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
dbg('unknown worker control ignored', type || '(none)');
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const shutdown = (signal) => {
|
|
159
|
+
dbg(`shutting down (${signal})`);
|
|
160
|
+
try { wsLink.stop(); } catch {}
|
|
161
|
+
try { heartbeat.stopHeartbeat(); } catch {}
|
|
162
|
+
try { controlPoller.stop(); } catch {}
|
|
163
|
+
// Hard-close the persistent CLI process; a worker teardown cannot save
|
|
164
|
+
// its background tasks, and the orphan-notification replay on the next
|
|
165
|
+
// resume reports whatever they left behind.
|
|
166
|
+
try { turnRunner.shutdown({ graceful: false }); } catch {}
|
|
167
|
+
process.exit(0);
|
|
168
|
+
};
|
|
169
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
170
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
171
|
+
installWorkerCrashGuard({
|
|
172
|
+
api,
|
|
173
|
+
workerName: 'claude-session-worker',
|
|
174
|
+
getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds(),
|
|
175
|
+
onBeforeExit: () => { try { turnRunner.shutdown({ graceful: false }); } catch {} },
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}`);
|
|
179
|
+
heartbeat.startHeartbeat();
|
|
180
|
+
wsLink.start();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
main().catch((error) => {
|
|
184
|
+
console.error('claude-session-worker fatal:', error?.stack || error?.message || error);
|
|
185
|
+
process.exit(1);
|
|
186
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import nodeFs from 'node:fs';
|
|
2
|
+
import nodePath from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { claudeProjectDirSlug, resolveClaudeProjectsRoots } from '../services/claude-session-root-service.mjs';
|
|
5
|
+
|
|
6
|
+
// Keep a resumable Claude session resumable after its workspace root changes.
|
|
7
|
+
//
|
|
8
|
+
// The CLI stores a session as `<configRoot>/projects/<slug(cwd)>/<id>.jsonl`
|
|
9
|
+
// and looks `resume` up in the project directory derived from the CWD of the
|
|
10
|
+
// *current* process — not in the one that wrote the transcript. So changing a
|
|
11
|
+
// conversation's CWD and relaunching the worker leaves every following turn
|
|
12
|
+
// failing with `No conversation found with session ID: <id>`, permanently: the
|
|
13
|
+
// id stays pinned in `runtime_sessions`, and the transcript stays behind in the
|
|
14
|
+
// old workspace's project directory.
|
|
15
|
+
//
|
|
16
|
+
// Before a resuming turn starts, the transcript (and the sibling `<id>/`
|
|
17
|
+
// directory holding subagent and tool-result files) is therefore moved into the
|
|
18
|
+
// project directory for the current CWD.
|
|
19
|
+
//
|
|
20
|
+
// Moved, not copied: a copy left behind in the old directory is resumed again —
|
|
21
|
+
// with its now-stale history — the moment the conversation switches back, which
|
|
22
|
+
// silently drops every turn taken in between.
|
|
23
|
+
//
|
|
24
|
+
// The slug is built from the *real* path. The CLI resolves symlinks before
|
|
25
|
+
// slugifying, so a symlinked workspace root (`/tmp` on macOS, a linked
|
|
26
|
+
// checkout) otherwise targets a directory the CLI never reads.
|
|
27
|
+
|
|
28
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
29
|
+
const MAX_PROJECT_DIRS_SCANNED = 500;
|
|
30
|
+
|
|
31
|
+
function isFile(fs, candidate) {
|
|
32
|
+
try {
|
|
33
|
+
return fs.statSync(candidate).isFile();
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isDirectory(fs, candidate) {
|
|
40
|
+
try {
|
|
41
|
+
return fs.statSync(candidate).isDirectory();
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function modifiedAtMs(fs, candidate) {
|
|
48
|
+
try {
|
|
49
|
+
return Number(fs.statSync(candidate).mtimeMs) || 0;
|
|
50
|
+
} catch {
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Move `source` to `target`, falling back to copy+delete when the two live on
|
|
57
|
+
* different filesystems (`CLAUDE_CONFIG_DIR` on another mount than `$HOME`).
|
|
58
|
+
*/
|
|
59
|
+
function movePath(fs, source, target, { recursive = false } = {}) {
|
|
60
|
+
try {
|
|
61
|
+
fs.renameSync(source, target);
|
|
62
|
+
return;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error?.code !== 'EXDEV') throw error;
|
|
65
|
+
}
|
|
66
|
+
if (recursive) {
|
|
67
|
+
fs.cpSync(source, target, { recursive: true });
|
|
68
|
+
fs.rmSync(source, { recursive: true, force: true });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
fs.copyFileSync(source, target);
|
|
72
|
+
fs.rmSync(source, { force: true });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Move the session's sibling directory, merging into an existing target rather
|
|
77
|
+
* than replacing it. The source must always end up gone: a leftover `<id>/`
|
|
78
|
+
* still anchors the session to the old project directory for the relay's
|
|
79
|
+
* Session-root resolver, which would keep the file explorer pointed at it.
|
|
80
|
+
*/
|
|
81
|
+
function moveSessionDir(fs, source, target) {
|
|
82
|
+
if (!isDirectory(fs, target)) {
|
|
83
|
+
movePath(fs, source, target, { recursive: true });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// Existing target files win — they belong to the session as it runs now.
|
|
87
|
+
fs.cpSync(source, target, { recursive: true, force: false, errorOnExist: false });
|
|
88
|
+
fs.rmSync(source, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Find every project directory that currently holds `<nativeSessionId>.jsonl`,
|
|
93
|
+
* newest first. Bounded and never recursive: one readdir per configured root
|
|
94
|
+
* plus one stat per project directory.
|
|
95
|
+
*/
|
|
96
|
+
function findTranscriptSources({ fs, path, roots, nativeSessionId, excludeDir }) {
|
|
97
|
+
const transcriptName = `${nativeSessionId}.jsonl`;
|
|
98
|
+
const matches = [];
|
|
99
|
+
for (const root of roots) {
|
|
100
|
+
let entries = [];
|
|
101
|
+
try {
|
|
102
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
103
|
+
} catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
let scanned = 0;
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (!entry.isDirectory()) continue;
|
|
109
|
+
if (scanned >= MAX_PROJECT_DIRS_SCANNED) break;
|
|
110
|
+
scanned += 1;
|
|
111
|
+
const projectDir = path.join(root, entry.name);
|
|
112
|
+
if (path.resolve(projectDir) === excludeDir) continue;
|
|
113
|
+
const transcriptPath = path.join(projectDir, transcriptName);
|
|
114
|
+
if (!isFile(fs, transcriptPath)) continue;
|
|
115
|
+
matches.push({ projectDir, transcriptPath, modifiedAtMs: modifiedAtMs(fs, transcriptPath) });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return matches.sort((a, b) => b.modifiedAtMs - a.modifiedAtMs);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Ensure the session `nativeSessionId` is resumable from `cwd`.
|
|
123
|
+
*
|
|
124
|
+
* Never throws: a relocation that cannot be completed leaves the CLI to report
|
|
125
|
+
* the missing session exactly as it did before, which is strictly better than
|
|
126
|
+
* failing a turn that might not even have needed the transcript.
|
|
127
|
+
*
|
|
128
|
+
* @returns {{ status: 'skipped'|'present'|'moved'|'missing'|'failed', from?: string, to?: string, error?: string }}
|
|
129
|
+
*/
|
|
130
|
+
export function relocateClaudeTranscriptForCwd({
|
|
131
|
+
nativeSessionId = '',
|
|
132
|
+
cwd = '',
|
|
133
|
+
fs = nodeFs,
|
|
134
|
+
path = nodePath,
|
|
135
|
+
env = process.env,
|
|
136
|
+
homedir = os.homedir,
|
|
137
|
+
dbg = () => {},
|
|
138
|
+
} = {}) {
|
|
139
|
+
const sessionId = String(nativeSessionId || '').trim();
|
|
140
|
+
const workspaceRoot = String(cwd || '').trim();
|
|
141
|
+
// The id is joined into a filesystem path, so it is validated before any
|
|
142
|
+
// syscall rather than trusted.
|
|
143
|
+
if (!sessionId || !SESSION_ID_PATTERN.test(sessionId) || !workspaceRoot) return { status: 'skipped' };
|
|
144
|
+
|
|
145
|
+
const roots = resolveClaudeProjectsRoots({ env, homedir, path });
|
|
146
|
+
if (!roots.length) return { status: 'skipped' };
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
let resolvedRoot = workspaceRoot;
|
|
150
|
+
try {
|
|
151
|
+
resolvedRoot = fs.realpathSync(workspaceRoot);
|
|
152
|
+
} catch {
|
|
153
|
+
// A workspace root that no longer exists cannot be resolved; the slug of
|
|
154
|
+
// the literal path is still the best guess.
|
|
155
|
+
}
|
|
156
|
+
// The CLI reads `CLAUDE_CONFIG_DIR` first and falls back to `~/.claude`,
|
|
157
|
+
// which is the order `resolveClaudeProjectsRoots` returns.
|
|
158
|
+
const targetProjectDir = path.join(roots[0], claudeProjectDirSlug(resolvedRoot));
|
|
159
|
+
const targetTranscriptPath = path.join(targetProjectDir, `${sessionId}.jsonl`);
|
|
160
|
+
if (isFile(fs, targetTranscriptPath)) return { status: 'present', to: targetTranscriptPath };
|
|
161
|
+
|
|
162
|
+
const sources = findTranscriptSources({
|
|
163
|
+
fs,
|
|
164
|
+
path,
|
|
165
|
+
roots,
|
|
166
|
+
nativeSessionId: sessionId,
|
|
167
|
+
excludeDir: path.resolve(targetProjectDir),
|
|
168
|
+
});
|
|
169
|
+
if (!sources.length) return { status: 'missing', to: targetTranscriptPath };
|
|
170
|
+
|
|
171
|
+
const source = sources[0];
|
|
172
|
+
fs.mkdirSync(targetProjectDir, { recursive: true });
|
|
173
|
+
movePath(fs, source.transcriptPath, targetTranscriptPath);
|
|
174
|
+
|
|
175
|
+
// The sibling directory is created lazily — only once the session produces
|
|
176
|
+
// subagent or tool-result files — so its absence is normal.
|
|
177
|
+
const sourceSessionDir = path.join(source.projectDir, sessionId);
|
|
178
|
+
if (isDirectory(fs, sourceSessionDir)) {
|
|
179
|
+
moveSessionDir(fs, sourceSessionDir, path.join(targetProjectDir, sessionId));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
dbg(`relocated claude transcript ${sessionId} from ${source.projectDir} to ${targetProjectDir}`);
|
|
183
|
+
return { status: 'moved', from: source.transcriptPath, to: targetTranscriptPath };
|
|
184
|
+
} catch (error) {
|
|
185
|
+
const message = error?.message || String(error);
|
|
186
|
+
dbg(`claude transcript relocation failed for ${sessionId}: ${message}`);
|
|
187
|
+
return { status: 'failed', error: message };
|
|
188
|
+
}
|
|
189
|
+
}
|