@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,2391 @@
|
|
|
1
|
+
import nodeFs from 'node:fs';
|
|
2
|
+
import nodePath from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { buildClaudeUserContent } from './claude-attachments.mjs';
|
|
5
|
+
import {
|
|
6
|
+
compactBoundaryActivityAction,
|
|
7
|
+
createSdkMessageNormalizer,
|
|
8
|
+
formatApiRetryNotice,
|
|
9
|
+
isSubagentToolName,
|
|
10
|
+
} from './sdk-message-normalizer.mjs';
|
|
11
|
+
import { digestFromRunRecord, digestFromJournal } from './workflow-progress-digest.mjs';
|
|
12
|
+
import { createClaudeSessionRootResolver } from '../services/claude-session-root-service.mjs';
|
|
13
|
+
import {
|
|
14
|
+
startClaudeSession,
|
|
15
|
+
createCanUseTool,
|
|
16
|
+
readContextUsage,
|
|
17
|
+
readPlanUsage,
|
|
18
|
+
normalizeClaudeEffort,
|
|
19
|
+
claudeUltracodeFlagSettings,
|
|
20
|
+
claudeAutoCompactFlagSettings,
|
|
21
|
+
claudeThinkingFlagSettings,
|
|
22
|
+
applyThinkingDisplay,
|
|
23
|
+
normalizeAutoCompactWindow,
|
|
24
|
+
permissionModeForRelayMode,
|
|
25
|
+
} from './claude-sdk-adapter.mjs';
|
|
26
|
+
import { parseThinkingDisplay, parseThinkingEnabled } from '../../shared/claude-thinking.mjs';
|
|
27
|
+
import { relocateClaudeTranscriptForCwd } from './claude-transcript-relocator.mjs';
|
|
28
|
+
import { createClaudeTurnPublisher } from './claude-turn-publisher.mjs';
|
|
29
|
+
import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Which system-prompt append a relay mode gets (claude-sdk-adapter's
|
|
33
|
+
* MODE_SYSTEM_PROMPT_APPEND). The append is fixed at process spawn, so a mode
|
|
34
|
+
* change across turns only forces a process recycle when the append class
|
|
35
|
+
* differs AND nothing (background tasks, queued continuations) would die with
|
|
36
|
+
* the process; otherwise the turn runs with the previous append and the
|
|
37
|
+
* functionally important switch (permission mode) is applied live.
|
|
38
|
+
*/
|
|
39
|
+
function modeAppendClass(relayMode) {
|
|
40
|
+
const mode = String(relayMode || 'agent').trim().toLowerCase();
|
|
41
|
+
return mode === 'ask' || mode === 'autopilot' ? mode : 'none';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Task types that run a model of their own (vs. bash/monitor/workflow). */
|
|
45
|
+
const AGENT_TASK_TYPES = new Set(['local_agent', 'agent', 'subagent']);
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Workflow progress sources (CLI 2.1.226 on-disk formats)
|
|
49
|
+
//
|
|
50
|
+
// A `local_workflow` task's tree lives under the native session's project
|
|
51
|
+
// directory: `<sessionDir>/subagents/workflows/<runId>/journal.jsonl` is
|
|
52
|
+
// appended live while the run executes, and `<sessionDir>/workflows/
|
|
53
|
+
// <runId>.json` (the run record, the only place carrying `taskId`) is written
|
|
54
|
+
// only at completion. The poller reads the journal mid-run and switches to the
|
|
55
|
+
// run record the moment it exists.
|
|
56
|
+
|
|
57
|
+
/** Run ids and agent ids are joined into paths, so both are validated first. */
|
|
58
|
+
const WORKFLOW_FS_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
59
|
+
/**
|
|
60
|
+
* Mid-run nothing on disk maps runId→taskId, so run directories are matched
|
|
61
|
+
* to the task by time instead: the task's startedAt is worker wall clock while
|
|
62
|
+
* dir mtimes come from the filesystem, and this slack absorbs the skew.
|
|
63
|
+
*/
|
|
64
|
+
const WORKFLOW_RUN_MATCH_SLACK_MS = 15_000;
|
|
65
|
+
const MAX_WORKFLOW_RECORDS_SCANNED = 25;
|
|
66
|
+
const MAX_WORKFLOW_JOURNAL_BYTES = 2 * 1024 * 1024;
|
|
67
|
+
const MAX_WORKFLOW_RECORD_BYTES = 8 * 1024 * 1024;
|
|
68
|
+
const AGENT_LABEL_READ_BYTES = 8_192;
|
|
69
|
+
|
|
70
|
+
function workflowMtimeMs(candidate) {
|
|
71
|
+
try {
|
|
72
|
+
return Number(nodeFs.statSync(candidate).mtimeMs) || 0;
|
|
73
|
+
} catch {
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Up to `maxBytes` of a file as utf8, or null when it cannot be read. */
|
|
79
|
+
function readBoundedUtf8(filePath, maxBytes) {
|
|
80
|
+
let fd = null;
|
|
81
|
+
try {
|
|
82
|
+
fd = nodeFs.openSync(filePath, 'r');
|
|
83
|
+
const buffer = Buffer.alloc(maxBytes);
|
|
84
|
+
const bytes = nodeFs.readSync(fd, buffer, 0, maxBytes, 0);
|
|
85
|
+
return buffer.toString('utf8', 0, bytes);
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
} finally {
|
|
89
|
+
if (fd !== null) {
|
|
90
|
+
try { nodeFs.closeSync(fd); } catch {}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Parse `<sessionDir>/workflows/<runId>.json`, or null. */
|
|
96
|
+
function readWorkflowRunRecord(sessionDir, runId) {
|
|
97
|
+
if (!WORKFLOW_FS_NAME_PATTERN.test(runId)) return null;
|
|
98
|
+
const text = readBoundedUtf8(nodePath.join(sessionDir, 'workflows', `${runId}.json`), MAX_WORKFLOW_RECORD_BYTES);
|
|
99
|
+
if (!text) return null;
|
|
100
|
+
try {
|
|
101
|
+
const record = JSON.parse(text);
|
|
102
|
+
return record && typeof record === 'object' ? record : null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Find the run record whose `taskId` matches — the only authoritative
|
|
110
|
+
* runId↔taskId join, available once the record is written (completion). Only
|
|
111
|
+
* records touched since the task started (minus slack) are parsed, so a
|
|
112
|
+
* session's pile of past run records costs one stat each, not a parse.
|
|
113
|
+
*/
|
|
114
|
+
function scanWorkflowRunRecords({ sessionDir, taskId, startedAt }) {
|
|
115
|
+
const workflowsDir = nodePath.join(sessionDir, 'workflows');
|
|
116
|
+
let entries = [];
|
|
117
|
+
try {
|
|
118
|
+
entries = nodeFs.readdirSync(workflowsDir, { withFileTypes: true });
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const candidates = [];
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
125
|
+
const runId = entry.name.slice(0, -'.json'.length);
|
|
126
|
+
if (!WORKFLOW_FS_NAME_PATTERN.test(runId)) continue;
|
|
127
|
+
const modifiedAt = workflowMtimeMs(nodePath.join(workflowsDir, entry.name));
|
|
128
|
+
if (startedAt && modifiedAt < startedAt - WORKFLOW_RUN_MATCH_SLACK_MS) continue;
|
|
129
|
+
candidates.push({ runId, modifiedAt });
|
|
130
|
+
}
|
|
131
|
+
candidates.sort((a, b) => b.modifiedAt - a.modifiedAt);
|
|
132
|
+
for (const candidate of candidates.slice(0, MAX_WORKFLOW_RECORDS_SCANNED)) {
|
|
133
|
+
const record = readWorkflowRunRecord(sessionDir, candidate.runId);
|
|
134
|
+
if (record && String(record.taskId || '').trim() === taskId) return record;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Locate the LIVE run directory for a task, heuristically: among the run
|
|
141
|
+
* directories under `<sessionDir>/subagents/workflows/`, keep those whose own
|
|
142
|
+
* mtime or journal mtime is at or after the task's startedAt minus the slack,
|
|
143
|
+
* and pick the newest. The caller caches the match per taskId; a later run record
|
|
144
|
+
* whose taskId disagrees clears that cache (concurrent workflows can tie the
|
|
145
|
+
* newest-dir pick to the wrong task until their records land).
|
|
146
|
+
*/
|
|
147
|
+
function findLiveWorkflowRunId({ sessionDir, startedAt }) {
|
|
148
|
+
const runsDir = nodePath.join(sessionDir, 'subagents', 'workflows');
|
|
149
|
+
let entries = [];
|
|
150
|
+
try {
|
|
151
|
+
entries = nodeFs.readdirSync(runsDir, { withFileTypes: true });
|
|
152
|
+
} catch {
|
|
153
|
+
return '';
|
|
154
|
+
}
|
|
155
|
+
let bestRunId = '';
|
|
156
|
+
let bestModifiedAt = 0;
|
|
157
|
+
for (const entry of entries) {
|
|
158
|
+
if (!entry.isDirectory() || !WORKFLOW_FS_NAME_PATTERN.test(entry.name)) continue;
|
|
159
|
+
const runDir = nodePath.join(runsDir, entry.name);
|
|
160
|
+
const modifiedAt = Math.max(
|
|
161
|
+
workflowMtimeMs(runDir),
|
|
162
|
+
workflowMtimeMs(nodePath.join(runDir, 'journal.jsonl')),
|
|
163
|
+
);
|
|
164
|
+
if (startedAt && modifiedAt < startedAt - WORKFLOW_RUN_MATCH_SLACK_MS) continue;
|
|
165
|
+
if (modifiedAt > bestModifiedAt) {
|
|
166
|
+
bestModifiedAt = modifiedAt;
|
|
167
|
+
bestRunId = entry.name;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return bestRunId;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Parsed lines of a run directory's journal.jsonl, or null when unreadable. */
|
|
174
|
+
function readWorkflowJournal(runDir) {
|
|
175
|
+
const text = readBoundedUtf8(nodePath.join(runDir, 'journal.jsonl'), MAX_WORKFLOW_JOURNAL_BYTES);
|
|
176
|
+
if (text === null) return null;
|
|
177
|
+
const entries = [];
|
|
178
|
+
for (const line of text.split('\n')) {
|
|
179
|
+
const trimmed = line.trim();
|
|
180
|
+
if (!trimmed) continue;
|
|
181
|
+
try {
|
|
182
|
+
entries.push(JSON.parse(trimmed));
|
|
183
|
+
} catch {
|
|
184
|
+
// A half-appended (or truncated-by-cap) line parses on a later poll.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return entries;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** JSON string body → text, tolerating a fragment cut mid-escape-sequence. */
|
|
191
|
+
function unescapeJsonStringFragment(fragment) {
|
|
192
|
+
for (let cut = 0; cut < 6 && cut < fragment.length; cut += 1) {
|
|
193
|
+
try {
|
|
194
|
+
return JSON.parse(`"${fragment.slice(0, fragment.length - cut)}"`);
|
|
195
|
+
} catch {
|
|
196
|
+
// The tail may be a split escape sequence; trim one char and retry.
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return '';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The live label for one workflow agent: the first line of its
|
|
204
|
+
* `agent-<agentId>.jsonl` is `{"type":"user","message":{role,content},...}`
|
|
205
|
+
* where `message.content` is the agent's prompt — its first ~160 chars are
|
|
206
|
+
* the best mid-run label (the run record's `label` only exists at
|
|
207
|
+
* completion). Only the first ~8KB is read; a first line longer than that is
|
|
208
|
+
* recovered via a raw `"content":"…"` match on the chunk.
|
|
209
|
+
*/
|
|
210
|
+
function readWorkflowAgentLabel(runDir, agentId) {
|
|
211
|
+
if (!WORKFLOW_FS_NAME_PATTERN.test(agentId)) return '';
|
|
212
|
+
const chunk = readBoundedUtf8(nodePath.join(runDir, `agent-${agentId}.jsonl`), AGENT_LABEL_READ_BYTES);
|
|
213
|
+
if (!chunk) return '';
|
|
214
|
+
const newlineIndex = chunk.indexOf('\n');
|
|
215
|
+
const firstLine = newlineIndex === -1 ? chunk : chunk.slice(0, newlineIndex);
|
|
216
|
+
let content = null;
|
|
217
|
+
try {
|
|
218
|
+
content = JSON.parse(firstLine)?.message?.content;
|
|
219
|
+
} catch {
|
|
220
|
+
const match = /"content"\s*:\s*"((?:[^"\\]|\\.){1,400})/.exec(firstLine);
|
|
221
|
+
if (match) content = unescapeJsonStringFragment(match[1]);
|
|
222
|
+
}
|
|
223
|
+
if (Array.isArray(content)) {
|
|
224
|
+
content = content.find((block) => block?.type === 'text')?.text;
|
|
225
|
+
}
|
|
226
|
+
if (typeof content !== 'string') return '';
|
|
227
|
+
return content.replace(/\s+/g, ' ').trim().slice(0, 160);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Default session-dir resolution for the workflow poller: the same
|
|
232
|
+
* `<configRoot>/projects/<slug(cwd)>/<nativeSessionId>/` derivation the
|
|
233
|
+
* transcript relocator uses, via the shared session-root resolver (which owns
|
|
234
|
+
* the cwd slug, the CLAUDE_CONFIG_DIR/~/.claude root order, the scan fallback,
|
|
235
|
+
* and caching). Injectable so tests can point the poller at a temp dir.
|
|
236
|
+
*/
|
|
237
|
+
function createDefaultWorkflowSessionDirResolver() {
|
|
238
|
+
let resolver = null;
|
|
239
|
+
return ({ nativeSessionId, cwd }) => {
|
|
240
|
+
if (!resolver) resolver = createClaudeSessionRootResolver();
|
|
241
|
+
const resolved = resolver.resolveClaudeSessionRoot({
|
|
242
|
+
claudeNativeSessionId: nativeSessionId,
|
|
243
|
+
workspaceRootPath: cwd,
|
|
244
|
+
});
|
|
245
|
+
return resolved?.sessionRootPath || '';
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The text a user content payload streams as — the replay-matching key. */
|
|
250
|
+
function contentText(content) {
|
|
251
|
+
if (typeof content === 'string') return content;
|
|
252
|
+
if (!Array.isArray(content)) return '';
|
|
253
|
+
return content
|
|
254
|
+
.filter((block) => block?.type === 'text')
|
|
255
|
+
.map((block) => String(block.text || ''))
|
|
256
|
+
.join('\n');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A turn-opening user message in the SDK stream: top-level (not a subagent's),
|
|
261
|
+
* carrying prompt text rather than tool_result blocks. Both our own pushed
|
|
262
|
+
* messages and the CLI's task-notification continuations replay this way.
|
|
263
|
+
*/
|
|
264
|
+
function turnOpeningUserSignature(sdkMessage) {
|
|
265
|
+
if (sdkMessage?.type !== 'user' || sdkMessage?.parent_tool_use_id) return null;
|
|
266
|
+
const content = sdkMessage?.message?.content;
|
|
267
|
+
if (typeof content === 'string') return content;
|
|
268
|
+
if (!Array.isArray(content)) return null;
|
|
269
|
+
if (content.some((block) => block?.type === 'tool_result')) return null;
|
|
270
|
+
// Unlike turnOpeningUserText this keeps '' — an attachment-only message
|
|
271
|
+
// replays with an empty text block, and the absorbed-steering match must
|
|
272
|
+
// still recognize it (expectedText is '' for those too).
|
|
273
|
+
return contentText(content);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function turnOpeningUserText(sdkMessage) {
|
|
277
|
+
const text = turnOpeningUserSignature(sdkMessage);
|
|
278
|
+
return text || null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The CLI synthesizes a user-role message when a background task settles. The
|
|
283
|
+
* SDK stamps its provenance structurally: `SDKUserMessage.origin` is an
|
|
284
|
+
* `SDKMessageOrigin`, and a settled task's continuation carries
|
|
285
|
+
* `{ kind: 'task-notification' }` (sdk.d.ts). The compaction's own summary
|
|
286
|
+
* replay carries no `origin` at all (incident transcript row 1514).
|
|
287
|
+
*
|
|
288
|
+
* The `<task-notification>` opening tag — which the CLI documents to the model
|
|
289
|
+
* as the way to recognize these ("they look like user messages but are not") —
|
|
290
|
+
* is kept as a fallback for emitters that predate the field. It can only push
|
|
291
|
+
* the answer toward "not the compaction's replay", which is the safe
|
|
292
|
+
* direction: not adopting merely restores the pre-fix behaviour, while a wrong
|
|
293
|
+
* adoption closes the user's row with another turn's answer.
|
|
294
|
+
*/
|
|
295
|
+
const TASK_NOTIFICATION_TAG_PREFIX = '<task-notification';
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The first text block's raw text, or null when there is none to read. Real
|
|
299
|
+
* notification rows carry `content` as a plain string (242 of them on disk),
|
|
300
|
+
* not a block array, so both spellings are read.
|
|
301
|
+
*/
|
|
302
|
+
function firstTextBlock(sdkMessage) {
|
|
303
|
+
const content = sdkMessage?.message?.content;
|
|
304
|
+
if (typeof content === 'string') return content;
|
|
305
|
+
if (!Array.isArray(content)) return null;
|
|
306
|
+
const first = content[0];
|
|
307
|
+
return first?.type === 'text' ? String(first.text || '') : null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** A settled background task's continuation, by origin or by leading tag. */
|
|
311
|
+
function isTaskNotificationReplay(sdkMessage) {
|
|
312
|
+
if (String(sdkMessage?.origin?.kind || '') === 'task-notification') return true;
|
|
313
|
+
const text = firstTextBlock(sdkMessage);
|
|
314
|
+
return text !== null && text.trimStart().startsWith(TASK_NOTIFICATION_TAG_PREFIX);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Whether a turn the CLI opened by itself may be the compaction re-opening a
|
|
319
|
+
* delivered turn, rather than something else the CLI decided to say.
|
|
320
|
+
*
|
|
321
|
+
* Tested POSITIVELY, and it has to be: `SDKMessageOrigin` is a nine-member
|
|
322
|
+
* union (`human`, `channel`, `peer`, `task-notification`, `coordinator`,
|
|
323
|
+
* `unclassified`, `observer`, …), so "not a task notification" is fail-OPEN —
|
|
324
|
+
* a cross-session `peer` message or an `auto-continuation` would be read as
|
|
325
|
+
* the compaction's summary and would permanently steal the delivered row. The
|
|
326
|
+
* compact summary is the CLI's own synthesized replay and carries NO `origin`
|
|
327
|
+
* at all (incident transcript row 1514: `origin=None`), so any stamped
|
|
328
|
+
* provenance disqualifies it. The `<task-notification>` tag stays as a second
|
|
329
|
+
* disqualifier for emitters that predate `origin`.
|
|
330
|
+
*
|
|
331
|
+
* Failing closed costs only the pre-fix behaviour (the row waits for its own
|
|
332
|
+
* replay); failing open closes a user's row with another turn's answer.
|
|
333
|
+
*/
|
|
334
|
+
function isCompactionReplayCandidate(sdkMessage) {
|
|
335
|
+
if (firstTextBlock(sdkMessage) === null) return false;
|
|
336
|
+
if (sdkMessage?.origin !== undefined && sdkMessage?.origin !== null) return false;
|
|
337
|
+
return !isTaskNotificationReplay(sdkMessage);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The bookkeeping result a resumed session emits after replaying an
|
|
342
|
+
* orphaned-task notification: zero API work, nothing to publish. Detected at
|
|
343
|
+
* the process level (in addition to the normalizer's own skip) so it can never
|
|
344
|
+
* open or close a turn context.
|
|
345
|
+
*/
|
|
346
|
+
function isPhantomResult(sdkMessage) {
|
|
347
|
+
return sdkMessage?.type === 'result'
|
|
348
|
+
&& sdkMessage.subtype === 'success'
|
|
349
|
+
&& sdkMessage.is_error !== true
|
|
350
|
+
&& Number(sdkMessage.num_turns) === 0
|
|
351
|
+
&& Number(sdkMessage.duration_api_ms) === 0;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Run a relay conversation against ONE persistent Claude CLI process.
|
|
356
|
+
*
|
|
357
|
+
* The pre-existing runner spawned a CLI per turn and held its stdin open
|
|
358
|
+
* ("input gate linger") while background subagents ran — which meant
|
|
359
|
+
* backgrounded Bash/Monitor tasks died seconds after each reply and their
|
|
360
|
+
* "you will be notified" continuations never happened (incident conv
|
|
361
|
+
* `2353a9eb`). Here the process outlives turns: delivered relay messages are
|
|
362
|
+
* pushed into the same streaming-input query, background tasks keep running
|
|
363
|
+
* between turns, and the continuation turns the CLI dequeues when a task
|
|
364
|
+
* settles are published as their own relay turns (synthetic queue rows via
|
|
365
|
+
* POST /api/continuation-turn).
|
|
366
|
+
*
|
|
367
|
+
* Lifecycle: the process stays alive while a turn is active or queued, any
|
|
368
|
+
* background task is live, a settled task's continuation is still due, or a
|
|
369
|
+
* canUseTool round-trip (question / permission) is pending. With none of
|
|
370
|
+
* those it idles out after `idleShutdownMs` and the CLI exits; the next
|
|
371
|
+
* delivered message respawns it with `resume`. `getBackgroundTaskTimeoutMs`
|
|
372
|
+
* (0 = unlimited) caps how long tasks alone may hold the process — on expiry
|
|
373
|
+
* every live task is stopped via the SDK and the wind-down proceeds through
|
|
374
|
+
* the normal notification/continuation path.
|
|
375
|
+
*/
|
|
376
|
+
export function createClaudeSessionRunner({
|
|
377
|
+
api,
|
|
378
|
+
sdkSessionId,
|
|
379
|
+
cwd,
|
|
380
|
+
defaultModel = '',
|
|
381
|
+
controlPoller,
|
|
382
|
+
pathToClaudeCodeExecutable = '',
|
|
383
|
+
startClaudeSessionImpl = startClaudeSession,
|
|
384
|
+
readContextUsageImpl = readContextUsage,
|
|
385
|
+
readPlanUsageImpl = readPlanUsage,
|
|
386
|
+
relocateTranscriptImpl = relocateClaudeTranscriptForCwd,
|
|
387
|
+
idleShutdownMs = 10 * 60_000,
|
|
388
|
+
getBackgroundTaskTimeoutMs = () => 0,
|
|
389
|
+
// The per-conversation auto-compact window (token count, null = Auto). Read
|
|
390
|
+
// per turn rather than captured, so a slider change picked up on the next
|
|
391
|
+
// delivery reaches a process that is already running.
|
|
392
|
+
getAutoCompactWindow = () => null,
|
|
393
|
+
// The per-conversation thinking state ({enabled, display}); read per turn
|
|
394
|
+
// like the window, so a settings change reaches a running process on its
|
|
395
|
+
// next delivery.
|
|
396
|
+
getThinking = () => ({ enabled: null, display: '' }),
|
|
397
|
+
lifecyclePollMs = 5_000,
|
|
398
|
+
// A settled task's continuation normally begins within ~1s; when nothing
|
|
399
|
+
// arrives inside this window the notification was silent (skip_transcript)
|
|
400
|
+
// and must stop pinning the process.
|
|
401
|
+
notificationGraceMs = 60_000,
|
|
402
|
+
// How long a delivered entry may sit unattached (no active turn, no live
|
|
403
|
+
// tasks, quiet stream) before the watchdog fails its row over. Must exceed
|
|
404
|
+
// the slowest cold start observed; the primary absorbed-steering fix in
|
|
405
|
+
// resolveContext should make this fire only for unknown variants.
|
|
406
|
+
pendingDeliveredTimeoutMs = 5 * 60_000,
|
|
407
|
+
// Backstop for the compaction hold. A compaction is announced ONCE by
|
|
408
|
+
// `status: 'compacting'` (the CLI's periodic re-emit is gated on the
|
|
409
|
+
// remote-control client's activity callback, which the plain SDK `query()`
|
|
410
|
+
// never registers) and is released by the boundary, by a terminating status,
|
|
411
|
+
// or by the process dying — this cap only matters when none of those ever
|
|
412
|
+
// arrives. It gates the pending-delivered watchdog, idle shutdown and the
|
|
413
|
+
// mode-change recycle, so it is sized far above any plausible compaction
|
|
414
|
+
// (measured: 133 s for 614k tokens) rather than tightly: releasing late
|
|
415
|
+
// leaves an idle CLI up a few minutes longer, releasing early kills a live
|
|
416
|
+
// compaction mid-flight.
|
|
417
|
+
compactionStaleMs = 10 * 60_000,
|
|
418
|
+
// How long after a compaction signal a delivered message may still adopt the
|
|
419
|
+
// turn the CLI opens by itself. The replay follows the boundary within
|
|
420
|
+
// milliseconds; this only has to outlast that gap, and the window is spent
|
|
421
|
+
// by the first turn to open regardless.
|
|
422
|
+
compactReplayAdoptionMs = 60_000,
|
|
423
|
+
continuationRetryDelayMs = 500,
|
|
424
|
+
workflowPollMs = 2_000,
|
|
425
|
+
resolveWorkflowSessionDirImpl = null,
|
|
426
|
+
askUserBridgeOptions = {},
|
|
427
|
+
dbg = () => {},
|
|
428
|
+
} = {}) {
|
|
429
|
+
const publisher = createClaudeTurnPublisher({
|
|
430
|
+
api,
|
|
431
|
+
dbg,
|
|
432
|
+
takeWorkflowRuns: () => drainSettledWorkflowRuns(),
|
|
433
|
+
});
|
|
434
|
+
const resolveWorkflowSessionDir = resolveWorkflowSessionDirImpl || createDefaultWorkflowSessionDirResolver();
|
|
435
|
+
let claudeNativeSessionId = '';
|
|
436
|
+
let proc = null;
|
|
437
|
+
|
|
438
|
+
async function persistNativeSessionId(conversationId, sessionId) {
|
|
439
|
+
const normalized = String(sessionId || '').trim();
|
|
440
|
+
if (!normalized || normalized === claudeNativeSessionId) return;
|
|
441
|
+
try {
|
|
442
|
+
await api('POST', '/api/claude-native-session', {
|
|
443
|
+
conversationId,
|
|
444
|
+
claudeNativeSessionId: normalized,
|
|
445
|
+
});
|
|
446
|
+
// Only cache after the server accepted it, so a failed persist is
|
|
447
|
+
// retried on the next init — resume across worker restarts depends on
|
|
448
|
+
// the server-side copy.
|
|
449
|
+
claudeNativeSessionId = normalized;
|
|
450
|
+
} catch (error) {
|
|
451
|
+
dbg('claude native session persist failed', error?.message || String(error));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function resolvePerTurnModel(message) {
|
|
456
|
+
// Per-message model wins so the composer can switch Claude models between
|
|
457
|
+
// turns; the conversation's provider model and worker default are fallbacks.
|
|
458
|
+
const requestedModel = String(message.model || '').trim();
|
|
459
|
+
const perTurnModel = requestedModel.toLowerCase() !== 'auto' && requestedModel.toLowerCase().startsWith('claude-')
|
|
460
|
+
? requestedModel
|
|
461
|
+
: '';
|
|
462
|
+
return perTurnModel
|
|
463
|
+
|| String(message.providerModel || '').trim()
|
|
464
|
+
|| defaultModel;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function getActiveQueueMessageId() {
|
|
468
|
+
if (!proc) return '';
|
|
469
|
+
return String(proc.activeCtx?.message?.id || proc.pendingDelivered[0]?.ctx.message?.id || '');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Every queue row this worker currently owes work for: the running turn
|
|
473
|
+
// (delivered or continuation) plus any delivered message queued behind it.
|
|
474
|
+
// The heartbeat reports all of them so the server's owner-recovery never
|
|
475
|
+
// replays a row the process still holds.
|
|
476
|
+
function getActiveQueueMessageIds() {
|
|
477
|
+
if (!proc) return [];
|
|
478
|
+
return [
|
|
479
|
+
proc.activeCtx?.message?.id,
|
|
480
|
+
...proc.pendingDelivered.map((entry) => entry.ctx.message?.id),
|
|
481
|
+
].map((id) => String(id || '').trim()).filter(Boolean);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function isTurnActive() {
|
|
485
|
+
return Boolean(proc && (proc.activeCtx || proc.pendingDelivered.length));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
// Turn contexts
|
|
490
|
+
|
|
491
|
+
function createContext(kind, message) {
|
|
492
|
+
return {
|
|
493
|
+
kind, // 'delivered' | 'continuation'
|
|
494
|
+
message,
|
|
495
|
+
normalizer: createSdkMessageNormalizer(),
|
|
496
|
+
state: {
|
|
497
|
+
result: null,
|
|
498
|
+
resultTexts: [],
|
|
499
|
+
lastStreamedText: '',
|
|
500
|
+
responseModel: '',
|
|
501
|
+
contextUsage: null,
|
|
502
|
+
planUsage: null,
|
|
503
|
+
modelUsage: null,
|
|
504
|
+
},
|
|
505
|
+
planBoardPosted: false,
|
|
506
|
+
// Set only on a context attached by post-compaction adoption, and only
|
|
507
|
+
// until that turn produces output — see handBackAdoptedContext.
|
|
508
|
+
adoptedFromCompaction: false,
|
|
509
|
+
// The pendingDelivered entry this context was attached from, kept so a
|
|
510
|
+
// handed-back adoption can restore it verbatim.
|
|
511
|
+
deliveredEntry: null,
|
|
512
|
+
// When the provisional adoption's quiet clock started (see
|
|
513
|
+
// reapSilentProvisionalAdoption).
|
|
514
|
+
provisionalSince: 0,
|
|
515
|
+
interrupted: false,
|
|
516
|
+
discarded: false,
|
|
517
|
+
registered: kind === 'delivered',
|
|
518
|
+
bufferedActions: [],
|
|
519
|
+
controlState: null,
|
|
520
|
+
finalized: false,
|
|
521
|
+
resolveDone: null,
|
|
522
|
+
rejectDone: null,
|
|
523
|
+
done: null,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function createDeliveredContext(message) {
|
|
528
|
+
const ctx = createContext('delivered', message);
|
|
529
|
+
ctx.done = new Promise((resolve, reject) => {
|
|
530
|
+
ctx.resolveDone = resolve;
|
|
531
|
+
ctx.rejectDone = reject;
|
|
532
|
+
});
|
|
533
|
+
return ctx;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function activateContext(ctx) {
|
|
537
|
+
proc.activeCtx = ctx;
|
|
538
|
+
proc.lastBoundary = null;
|
|
539
|
+
proc.continuationInitPending = false;
|
|
540
|
+
proc.notificationPendingAt = 0;
|
|
541
|
+
proc.taskSettledAt = 0;
|
|
542
|
+
if (proc.initModel && !ctx.state.responseModel) ctx.state.responseModel = proc.initModel;
|
|
543
|
+
ctx.controlState = controlPoller?.start?.({
|
|
544
|
+
queueMessageId: ctx.message?.id || '',
|
|
545
|
+
onAbortTurn: () => interruptActiveTurn(ctx),
|
|
546
|
+
}) || null;
|
|
547
|
+
// One-shot: a compaction's replay window is spent by the turn that
|
|
548
|
+
// consumes it, and a turn that opened for any other reason retires it too
|
|
549
|
+
// — a boundary must never be able to adopt a second turn (see
|
|
550
|
+
// resolveContext).
|
|
551
|
+
proc.compactReplayUntil = 0;
|
|
552
|
+
// Orphan/settled-task notification lines that arrived between turns belong
|
|
553
|
+
// to the turn they triggered.
|
|
554
|
+
const carried = proc.pendingActivities.splice(0);
|
|
555
|
+
if (carried.length) {
|
|
556
|
+
const actions = carried.map((entry) => (typeof entry === 'string'
|
|
557
|
+
? { channel: 'activity', payload: { text: entry, subagentRunId: null } }
|
|
558
|
+
: entry));
|
|
559
|
+
if (ctx.registered) {
|
|
560
|
+
(async () => {
|
|
561
|
+
for (const action of actions) await publisher.dispatchAction(ctx.message, action, ctx.state);
|
|
562
|
+
})().catch(() => {});
|
|
563
|
+
} else {
|
|
564
|
+
ctx.bufferedActions.push(...actions);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Attach the delivered entry at `index` — the head unless a replay matched a
|
|
571
|
+
* later one. The CLI normally dequeues in order, but two messages pushed
|
|
572
|
+
* back to back can replay out of order, and pairing by position alone would
|
|
573
|
+
* answer each with the other's turn.
|
|
574
|
+
*/
|
|
575
|
+
function attachDeliveredContext(index = 0) {
|
|
576
|
+
const [entry] = proc.pendingDelivered.splice(index, 1);
|
|
577
|
+
entry.ctx.deliveredEntry = entry;
|
|
578
|
+
activateContext(entry.ctx);
|
|
579
|
+
return entry.ctx;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Undo a post-compaction adoption: this turn was never the delivered
|
|
584
|
+
* message's, and nothing has published on it (the provisional flag is
|
|
585
|
+
* cleared by the turn's first real output, so every caller runs strictly
|
|
586
|
+
* before that).
|
|
587
|
+
*
|
|
588
|
+
* The entry goes back to the HEAD of the queue — it is still the next thing
|
|
589
|
+
* the CLI owes a replay for, and anything queued behind it must stay behind
|
|
590
|
+
* it — with its unattached clock reset, exactly as `armCompactReplayAdoption`
|
|
591
|
+
* leaves it. The control poller is stopped because `activateContext` started
|
|
592
|
+
* one and will start another when the entry attaches for real.
|
|
593
|
+
*
|
|
594
|
+
* The adoption window is deliberately NOT re-armed: the replay to come
|
|
595
|
+
* carries the message's own text and attaches by the ordinary match, so
|
|
596
|
+
* re-arming would only widen the chance of adopting yet another turn.
|
|
597
|
+
*/
|
|
598
|
+
function restoreAdoptedContext(ctx) {
|
|
599
|
+
// A context that has begun settling must never go back on the queue:
|
|
600
|
+
// `finalizeContext` marks `finalized` before its awaited publishes, and a
|
|
601
|
+
// finalized entry re-attached by a later turn returns from `finalizeContext`
|
|
602
|
+
// immediately — so `closeContext` never runs and `activeCtx` is pinned to a
|
|
603
|
+
// corpse for the life of the process (the 2026-08-18 deadlock shape).
|
|
604
|
+
if (ctx.finalized) return;
|
|
605
|
+
detachActiveContext(ctx);
|
|
606
|
+
ctx.adoptedFromCompaction = false;
|
|
607
|
+
ctx.provisionalSince = 0;
|
|
608
|
+
// `activateContext` started a control poller keyed on this row, so the
|
|
609
|
+
// provisional window is the one moment a still-QUEUED message is
|
|
610
|
+
// Stoppable. An abort taken there belongs to the turn that was running,
|
|
611
|
+
// not to the row: left set, `finalizeContext` would take its interrupted
|
|
612
|
+
// branch when the row finally runs for real and publish no response at all.
|
|
613
|
+
ctx.interrupted = false;
|
|
614
|
+
controlPoller?.stop?.(ctx.controlState);
|
|
615
|
+
ctx.controlState = null;
|
|
616
|
+
const entry = ctx.deliveredEntry;
|
|
617
|
+
if (!entry) return;
|
|
618
|
+
entry.unattachedSince = 0;
|
|
619
|
+
proc.pendingDelivered.unshift(entry);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* The adopted turn turned out to belong to a settled task's continuation,
|
|
624
|
+
* which replayed its notification into the same turn after the compaction
|
|
625
|
+
* summary opened it. Give the row back and register the continuation the
|
|
626
|
+
* turn actually is.
|
|
627
|
+
*/
|
|
628
|
+
function handBackAdoptedContext(ctx) {
|
|
629
|
+
restoreAdoptedContext(ctx);
|
|
630
|
+
dbg('post-compaction adoption handed back to a task continuation', ctx.message?.id || '(no id)');
|
|
631
|
+
return openContinuationContext();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* The CLI started a turn on its own (a background task's notification).
|
|
636
|
+
* Register a synthetic relay turn for it; actions buffer until the server
|
|
637
|
+
* hands back a message id, then flush in order. A failed registration
|
|
638
|
+
* discards the turn's relay output (it still lands in the native
|
|
639
|
+
* transcript) rather than failing the process.
|
|
640
|
+
*/
|
|
641
|
+
function openContinuationContext() {
|
|
642
|
+
const ctx = createContext('continuation', {
|
|
643
|
+
id: null,
|
|
644
|
+
conversationId: sdkSessionId,
|
|
645
|
+
relayMode: proc.relayMode,
|
|
646
|
+
model: '',
|
|
647
|
+
});
|
|
648
|
+
ctx.registered = false;
|
|
649
|
+
activateContext(ctx);
|
|
650
|
+
(async () => {
|
|
651
|
+
let response = null;
|
|
652
|
+
// Retry on any registration that produced no message id — a truthy but
|
|
653
|
+
// empty response body must not end the loop early.
|
|
654
|
+
for (let attempt = 0; attempt < 3 && !response?.messageId; attempt += 1) {
|
|
655
|
+
// Handed off / settled before a row was created: stop before making one.
|
|
656
|
+
if (ctx.finalized || ctx.discarded) return;
|
|
657
|
+
response = await api('POST', '/api/continuation-turn', {
|
|
658
|
+
conversationId: sdkSessionId,
|
|
659
|
+
sdkSessionId,
|
|
660
|
+
relayMode: ctx.message.relayMode,
|
|
661
|
+
trigger: 'background_task',
|
|
662
|
+
}).catch((error) => {
|
|
663
|
+
dbg('continuation turn registration failed', error?.message || String(error));
|
|
664
|
+
return null;
|
|
665
|
+
});
|
|
666
|
+
if (!response?.messageId) await new Promise((resolve) => setTimeout(resolve, continuationRetryDelayMs));
|
|
667
|
+
}
|
|
668
|
+
if (!response?.messageId) {
|
|
669
|
+
ctx.discarded = true;
|
|
670
|
+
ctx.bufferedActions = [];
|
|
671
|
+
controlPoller?.stop?.(ctx.controlState);
|
|
672
|
+
ctx.controlState = null;
|
|
673
|
+
dbg('continuation turn discarded (no relay message id)');
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
if (ctx.discarded || ctx.finalized) {
|
|
677
|
+
// The context was handed off or settled while registration was in
|
|
678
|
+
// flight; drop the just-created server row (requeue fails a
|
|
679
|
+
// processing continuation quietly) instead of leaving it orphaned.
|
|
680
|
+
await api('POST', '/api/requeue', { messageId: String(response.messageId) }).catch(() => {});
|
|
681
|
+
controlPoller?.stop?.(ctx.controlState);
|
|
682
|
+
ctx.controlState = null;
|
|
683
|
+
dbg('continuation turn dropped (handed off during registration)');
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
ctx.message.id = String(response.messageId);
|
|
687
|
+
// The route reports which conversation the synthetic row landed on;
|
|
688
|
+
// trusting it beats assuming worker session id === conversation id.
|
|
689
|
+
if (String(response.conversationId || '').trim()) {
|
|
690
|
+
ctx.message.conversationId = String(response.conversationId).trim();
|
|
691
|
+
}
|
|
692
|
+
// Restart the control poller now that the turn has its real queue id.
|
|
693
|
+
controlPoller?.stop?.(ctx.controlState);
|
|
694
|
+
ctx.controlState = controlPoller?.start?.({
|
|
695
|
+
queueMessageId: ctx.message.id,
|
|
696
|
+
onAbortTurn: () => interruptActiveTurn(ctx),
|
|
697
|
+
}) || null;
|
|
698
|
+
// Drain in arrival order; anything the consumer adds while a batch is in
|
|
699
|
+
// flight keeps buffering (registered is still false) so a later action
|
|
700
|
+
// can never overtake an earlier one.
|
|
701
|
+
while (ctx.bufferedActions.length) {
|
|
702
|
+
const batch = ctx.bufferedActions.splice(0);
|
|
703
|
+
for (const action of batch) {
|
|
704
|
+
await publisher.dispatchAction(ctx.message, action, ctx.state);
|
|
705
|
+
if (action.channel === 'result') await finalizeContext(ctx);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
ctx.registered = true;
|
|
709
|
+
})().catch((error) => {
|
|
710
|
+
dbg('continuation flush failed', error?.message || String(error));
|
|
711
|
+
});
|
|
712
|
+
return ctx;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** Wait until a continuation context has its relay queue row (or gave up). */
|
|
716
|
+
async function waitForContextRegistration(ctx, timeoutMs = 10_000) {
|
|
717
|
+
const deadline = Date.now() + timeoutMs;
|
|
718
|
+
while (!ctx.registered && !ctx.discarded && Date.now() < deadline) {
|
|
719
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Decide which turn context an SDK message belongs to, opening or attaching
|
|
725
|
+
* one when the message begins a turn. Delivered turns attach on their user
|
|
726
|
+
* replay (matched by text) or, when no replay precedes assistant traffic, on
|
|
727
|
+
* that traffic; anything the CLI starts by itself becomes a continuation.
|
|
728
|
+
*/
|
|
729
|
+
function resolveContext(sdkMessage) {
|
|
730
|
+
if (proc.activeCtx) {
|
|
731
|
+
// The CLI dequeues a message pushed mid-turn INTO the running turn
|
|
732
|
+
// (steering) instead of opening a new turn after it: the replay arrives
|
|
733
|
+
// while another context is active, so the turn's single result would
|
|
734
|
+
// land on that context and the delivered row would stay `processing`
|
|
735
|
+
// forever while its lease renews (conv f93135ac row 962c36b1,
|
|
736
|
+
// 2026-08-18). Detect the absorbed replay and hand the turn off — the
|
|
737
|
+
// active context settles with what it already streamed, and everything
|
|
738
|
+
// from the replay on, including the result, belongs to the delivered
|
|
739
|
+
// message it answers.
|
|
740
|
+
if (proc.pendingDelivered.length) {
|
|
741
|
+
const signature = turnOpeningUserSignature(sdkMessage);
|
|
742
|
+
const absorbed = signature === null
|
|
743
|
+
? -1
|
|
744
|
+
: proc.pendingDelivered.findIndex((entry) => entry.expectedText === signature);
|
|
745
|
+
if (absorbed >= 0) {
|
|
746
|
+
const outgoing = proc.activeCtx;
|
|
747
|
+
const restored = outgoing.adoptedFromCompaction;
|
|
748
|
+
if (restored) {
|
|
749
|
+
// The outgoing context was only PROVISIONALLY adopted and has
|
|
750
|
+
// published nothing. Settling it as an absorbed turn would tell
|
|
751
|
+
// the user their message was "merged into the next reply" and
|
|
752
|
+
// refuse to requeue it — but the CLI never consumed that prompt,
|
|
753
|
+
// so it is still owed a replay. Unwind the adoption instead; the
|
|
754
|
+
// row goes back to the queue and attaches for real later.
|
|
755
|
+
restoreAdoptedContext(outgoing);
|
|
756
|
+
} else {
|
|
757
|
+
// onSdkMessage awaits this before dispatching the new context's
|
|
758
|
+
// actions, so the handed-off row's publishes stay ordered ahead
|
|
759
|
+
// of the absorbed turn's.
|
|
760
|
+
proc.handoffSettling = handoffAbsorbedTurn(outgoing);
|
|
761
|
+
}
|
|
762
|
+
const ctx = attachDeliveredContext(
|
|
763
|
+
proc.pendingDelivered.findIndex((entry) => entry.expectedText === signature),
|
|
764
|
+
);
|
|
765
|
+
// Continuity: open thinking blocks and tool_use/tool_result pairing
|
|
766
|
+
// must survive the boundary — the stream did not restart. Only for a
|
|
767
|
+
// genuine hand-off, where the outgoing context is settled and never
|
|
768
|
+
// used again: a RESTORED one goes back on the queue and will run its
|
|
769
|
+
// own turn later, and a shared normalizer would then publish this
|
|
770
|
+
// turn's streamed text as that row's answer (its `streamText` is only
|
|
771
|
+
// reset by a `message_start`, so it outlives the result).
|
|
772
|
+
if (!restored) ctx.normalizer = outgoing.normalizer;
|
|
773
|
+
return ctx;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
// The turn was adopted for a delivered message on the strength of an
|
|
777
|
+
// untagged post-compaction replay — and now a task notification replays
|
|
778
|
+
// into the same turn. The compaction happened INSIDE a continuation the
|
|
779
|
+
// CLI had already opened for a settled task, so the summary row was
|
|
780
|
+
// never the delivered message's: the turn belongs to the task. Hand it
|
|
781
|
+
// back before anything publishes (see handBackAdoptedContext).
|
|
782
|
+
if (proc.activeCtx.adoptedFromCompaction) {
|
|
783
|
+
const signature = turnOpeningUserSignature(sdkMessage);
|
|
784
|
+
// The adopted message's OWN replay: the CLI has now proved it dequeued
|
|
785
|
+
// exactly this prompt, so the guess is confirmed and the turn is the
|
|
786
|
+
// row's for good. Every unwind path rests on "the CLI never consumed
|
|
787
|
+
// this prompt" — after this replay that premise is false, and putting
|
|
788
|
+
// the row back would fail or re-run a message the CLI already took.
|
|
789
|
+
if (signature !== null && signature === proc.activeCtx.deliveredEntry?.expectedText) {
|
|
790
|
+
proc.activeCtx.adoptedFromCompaction = false;
|
|
791
|
+
return proc.activeCtx;
|
|
792
|
+
}
|
|
793
|
+
if (signature !== null && isTaskNotificationReplay(sdkMessage)) {
|
|
794
|
+
return handBackAdoptedContext(proc.activeCtx);
|
|
795
|
+
}
|
|
796
|
+
// A phantom result closes the turn with nothing published. Committed,
|
|
797
|
+
// it would leave the adopted context active forever — no result to
|
|
798
|
+
// finalize it, no queue entry left for the watchdog to fail over, and
|
|
799
|
+
// `handlePendingPayload` never resolving. Give the row back and let
|
|
800
|
+
// the ordinary phantom skip run.
|
|
801
|
+
if (isPhantomResult(sdkMessage)) {
|
|
802
|
+
restoreAdoptedContext(proc.activeCtx);
|
|
803
|
+
proc.lastBoundary = null;
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
// The adoption commits on the turn's first real OUTPUT, and only
|
|
807
|
+
// that. `system` frames publish nothing, and the compaction's own
|
|
808
|
+
// `compact_result` status routinely lands between the summary row and
|
|
809
|
+
// a notification replay. Nor does a background subagent's stream
|
|
810
|
+
// count: it arrives at top level carrying `parent_tool_use_id`, it is
|
|
811
|
+
// a LIVE task's chatter rather than this turn's answer, and a task
|
|
812
|
+
// running while the user's message is queued is the ordinary state of
|
|
813
|
+
// the conversation this bug came from — committing on it would leave
|
|
814
|
+
// the hand-back unreachable in exactly the order it exists for.
|
|
815
|
+
const type = String(sdkMessage?.type || '');
|
|
816
|
+
// A `result` counts too. It is not "output" in the streaming sense —
|
|
817
|
+
// it is the turn ENDING — but leaving the flag set through
|
|
818
|
+
// `finalizeContext`'s several awaited relay round-trips gives the
|
|
819
|
+
// lifecycle timer a window to reap a context that is already settling.
|
|
820
|
+
if (!sdkMessage?.parent_tool_use_id
|
|
821
|
+
&& (type === 'assistant' || type === 'stream_event' || type === 'result')) {
|
|
822
|
+
proc.activeCtx.adoptedFromCompaction = false;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return proc.activeCtx;
|
|
826
|
+
}
|
|
827
|
+
const type = String(sdkMessage?.type || '');
|
|
828
|
+
|
|
829
|
+
const openingText = turnOpeningUserText(sdkMessage);
|
|
830
|
+
if (openingText !== null) {
|
|
831
|
+
const matched = proc.pendingDelivered.findIndex((entry) => entry.expectedText === openingText);
|
|
832
|
+
if (matched >= 0) return attachDeliveredContext(matched);
|
|
833
|
+
// A compaction just replayed the conversation: the CLI re-opens the turn
|
|
834
|
+
// with its own summary message ("This session is being continued…"),
|
|
835
|
+
// whose text matches no delivered entry, and then answers the delivered
|
|
836
|
+
// message on that turn. Without adoption the answer publishes as a
|
|
837
|
+
// synthetic continuation and the real row orphans until the watchdog
|
|
838
|
+
// fails it — the user loses the message on a turn that succeeded (conv
|
|
839
|
+
// 563e252e, 2026-08-20). The turn is identified directly rather than by
|
|
840
|
+
// bookkeeping about what the CLI might owe: a settled task's
|
|
841
|
+
// continuation announces itself through `origin.kind` (see
|
|
842
|
+
// isTaskNotificationReplay), and everything else inside the window is
|
|
843
|
+
// the compaction's own replay. Only the no-active-context path adopts (a
|
|
844
|
+
// compaction mid-turn must leave the running turn alone), and the window
|
|
845
|
+
// is one-shot — attachDeliveredContext clears it through
|
|
846
|
+
// activateContext, so one boundary can never adopt two turns.
|
|
847
|
+
//
|
|
848
|
+
// `lastBoundary === 'self-opened'` is the turn-level half of the same
|
|
849
|
+
// question: the CLI had already begun a turn of its own (a task
|
|
850
|
+
// continuation, or a resumed init) and merely had not produced traffic
|
|
851
|
+
// yet, so a compaction inside THAT turn emits its untagged summary row
|
|
852
|
+
// into a turn that was never the delivered message's. The tag says which
|
|
853
|
+
// MESSAGE this is; the boundary says which TURN it belongs to, and
|
|
854
|
+
// adoption needs both.
|
|
855
|
+
if (
|
|
856
|
+
proc.pendingDelivered.length
|
|
857
|
+
&& proc.lastBoundary !== 'self-opened'
|
|
858
|
+
&& !proc.continuationInitPending
|
|
859
|
+
&& Date.now() < proc.compactReplayUntil
|
|
860
|
+
&& isCompactionReplayCandidate(sdkMessage)
|
|
861
|
+
) {
|
|
862
|
+
const ctx = attachDeliveredContext();
|
|
863
|
+
// Provisional: the summary row alone cannot rule out a continuation
|
|
864
|
+
// whose notification replays after it (see the hand-back above).
|
|
865
|
+
ctx.adoptedFromCompaction = true;
|
|
866
|
+
return ctx;
|
|
867
|
+
}
|
|
868
|
+
// A turn the CLI opened on its own (task-notification replay). The
|
|
869
|
+
// context opens lazily on its first real traffic so a bookkeeping
|
|
870
|
+
// replay that ends in a phantom result never creates a relay turn.
|
|
871
|
+
proc.lastBoundary = 'self-opened';
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (type === 'assistant' || type === 'stream_event' || type === 'user') {
|
|
876
|
+
if (proc.lastBoundary !== 'self-opened') {
|
|
877
|
+
if (proc.pendingDelivered.length) return attachDeliveredContext();
|
|
878
|
+
// Between-turn chatter — a background subagent's stream, a stray
|
|
879
|
+
// top-level tool_result — is not a turn of its own. Only traffic that
|
|
880
|
+
// follows a self-opened boundary (the CLI's task-notification replay)
|
|
881
|
+
// may open a continuation; without that boundary there is no top-level
|
|
882
|
+
// result coming to ever close the context, and an open context wedges
|
|
883
|
+
// the process. The dropped frames still land in the native transcript.
|
|
884
|
+
return null;
|
|
885
|
+
}
|
|
886
|
+
return openContinuationContext();
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (type === 'result') {
|
|
890
|
+
if (isPhantomResult(sdkMessage)) {
|
|
891
|
+
proc.lastBoundary = null;
|
|
892
|
+
return null;
|
|
893
|
+
}
|
|
894
|
+
if (proc.lastBoundary !== 'self-opened' && proc.pendingDelivered.length) return attachDeliveredContext();
|
|
895
|
+
if (proc.lastBoundary === 'self-opened') return openContinuationContext();
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async function dispatchToContext(ctx, action) {
|
|
903
|
+
// Background-task membership is process state, observed from the raw
|
|
904
|
+
// stream; the normalizer's mirror actions must not reach the relay APIs.
|
|
905
|
+
if (action.channel === 'background_tasks' || action.channel === 'background_task_settled') return;
|
|
906
|
+
if (ctx.discarded) {
|
|
907
|
+
// The turn's relay output is deliberately dropped, but the turn still
|
|
908
|
+
// ends: its result must release the active slot, or every later message
|
|
909
|
+
// routes into this dead context and the process can never idle out.
|
|
910
|
+
if (action.channel === 'result') closeContext(ctx, false);
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
if (!ctx.registered) {
|
|
914
|
+
ctx.bufferedActions.push(action);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
await publisher.dispatchAction(ctx.message, action, ctx.state);
|
|
918
|
+
if (action.channel === 'result') await finalizeContext(ctx);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async function finalizeContext(ctx) {
|
|
922
|
+
if (ctx.finalized) return;
|
|
923
|
+
ctx.finalized = true;
|
|
924
|
+
controlPoller?.stop?.(ctx.controlState);
|
|
925
|
+
// A late continuation flush can land after the process died; usage reads
|
|
926
|
+
// and model fallbacks must survive that.
|
|
927
|
+
const turnRef = proc?.turn || null;
|
|
928
|
+
const procModel = proc?.model || null;
|
|
929
|
+
// The control transport is alive for the process's whole life now, but
|
|
930
|
+
// the snapshot still belongs to this turn's finalize so the composer
|
|
931
|
+
// indicator updates with each reply.
|
|
932
|
+
const [contextUsage, planUsage] = await Promise.all([
|
|
933
|
+
readContextUsageImpl(turnRef, dbg),
|
|
934
|
+
readPlanUsageImpl(turnRef, dbg),
|
|
935
|
+
]);
|
|
936
|
+
ctx.state.contextUsage = contextUsage;
|
|
937
|
+
ctx.state.planUsage = planUsage;
|
|
938
|
+
const responseModel = ctx.state.responseModel || procModel || null;
|
|
939
|
+
await publisher.publishContextUsage({ message: ctx.message, state: ctx.state, model: procModel, sdkSessionId });
|
|
940
|
+
await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId });
|
|
941
|
+
|
|
942
|
+
if (ctx.interrupted || proc?.aborted) {
|
|
943
|
+
// Same shape as the per-turn runner's abort path: surface what streamed,
|
|
944
|
+
// let the server-side abort control own the queue row's fate.
|
|
945
|
+
await publisher.publishFinalStream(ctx.message, ctx.state.lastStreamedText);
|
|
946
|
+
} else if (ctx.state.result?.isError) {
|
|
947
|
+
await publisher.publishErrorResult({ message: ctx.message, state: ctx.state, responseModel });
|
|
948
|
+
} else {
|
|
949
|
+
await publisher.publishCompletedTurn({
|
|
950
|
+
message: ctx.message,
|
|
951
|
+
state: ctx.state,
|
|
952
|
+
responseModel,
|
|
953
|
+
planBoardPosted: ctx.planBoardPosted,
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
closeContext(ctx, true);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** The SDK stream ended while this turn was still open (no result seen). */
|
|
960
|
+
async function finalizeContextOnStreamEnd(ctx, { aborted = false, model = null } = {}) {
|
|
961
|
+
if (ctx.finalized) return;
|
|
962
|
+
ctx.finalized = true;
|
|
963
|
+
controlPoller?.stop?.(ctx.controlState);
|
|
964
|
+
if (ctx.discarded) {
|
|
965
|
+
closeContext(ctx, true);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
// The transport is gone, so nothing new can be read — but usage already
|
|
969
|
+
// captured on this context (a result seen before the stream died) still
|
|
970
|
+
// reaches the relay. Best-effort by definition of this path.
|
|
971
|
+
if (ctx.state.contextUsage || ctx.state.planUsage) {
|
|
972
|
+
await publisher.publishContextUsage({ message: ctx.message, state: ctx.state, model, sdkSessionId }).catch(() => {});
|
|
973
|
+
await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId }).catch(() => {});
|
|
974
|
+
}
|
|
975
|
+
if (ctx.interrupted || aborted) {
|
|
976
|
+
await publisher.publishFinalStream(ctx.message, ctx.state.lastStreamedText);
|
|
977
|
+
closeContext(ctx, true);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
const fallbackText = String(ctx.state.lastStreamedText || ctx.normalizer.finalStreamText() || '').trim();
|
|
981
|
+
if (fallbackText) {
|
|
982
|
+
await publisher.publishFinalStream(ctx.message, fallbackText);
|
|
983
|
+
await publisher.publishResponse(ctx.message, {
|
|
984
|
+
text: fallbackText,
|
|
985
|
+
model: ctx.state.responseModel || model || null,
|
|
986
|
+
});
|
|
987
|
+
} else if (ctx.message?.id) {
|
|
988
|
+
await api('POST', '/api/requeue', { messageId: ctx.message.id }).catch(() => {});
|
|
989
|
+
}
|
|
990
|
+
closeContext(ctx, true);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function detachActiveContext(ctx) {
|
|
994
|
+
if (proc && proc.activeCtx === ctx) {
|
|
995
|
+
proc.activeCtx = null;
|
|
996
|
+
proc.lastBoundary = null;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function closeContext(ctx, handled) {
|
|
1001
|
+
detachActiveContext(ctx);
|
|
1002
|
+
ctx.resolveDone?.(handled);
|
|
1003
|
+
evaluateLifecycle();
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* The active turn absorbed a delivered message (steering): settle the
|
|
1008
|
+
* active context NOW. The caller attaches the delivered context
|
|
1009
|
+
* synchronously; onSdkMessage awaits the returned promise before
|
|
1010
|
+
* dispatching the new context's actions so the two rows' publishes stay
|
|
1011
|
+
* ordered.
|
|
1012
|
+
*/
|
|
1013
|
+
function handoffAbsorbedTurn(ctx) {
|
|
1014
|
+
detachActiveContext(ctx);
|
|
1015
|
+
if (ctx.kind === 'continuation' && !ctx.registered) {
|
|
1016
|
+
// Registration (or its buffered-action drain) is still in flight:
|
|
1017
|
+
// discard, and empty the buffer so the drain loop halts instead of
|
|
1018
|
+
// writing to a row that is about to be dropped. The registration path
|
|
1019
|
+
// drops the server row itself when the id arrives after this.
|
|
1020
|
+
ctx.discarded = true;
|
|
1021
|
+
ctx.bufferedActions = [];
|
|
1022
|
+
}
|
|
1023
|
+
// Captured synchronously: the incoming context inherits this normalizer,
|
|
1024
|
+
// so reading it later would see the absorbed turn's text too.
|
|
1025
|
+
const fallbackText = String(ctx.state.lastStreamedText || ctx.normalizer.finalStreamText() || '').trim();
|
|
1026
|
+
dbg('active turn absorbed a delivered message; handing off', ctx.message?.id || '(unregistered continuation)');
|
|
1027
|
+
return settleHandedOffContext(ctx, fallbackText).catch((error) => {
|
|
1028
|
+
dbg('absorbed-turn handoff settle failed', error?.message || String(error));
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
async function settleHandedOffContext(ctx, fallbackText) {
|
|
1033
|
+
if (ctx.finalized) return;
|
|
1034
|
+
ctx.finalized = true;
|
|
1035
|
+
controlPoller?.stop?.(ctx.controlState);
|
|
1036
|
+
if (ctx.discarded) {
|
|
1037
|
+
// A discarded continuation that already has its server row must still
|
|
1038
|
+
// drop it (requeue fails a processing continuation quietly).
|
|
1039
|
+
if (ctx.message?.id) {
|
|
1040
|
+
await api('POST', '/api/requeue', { messageId: ctx.message.id }).catch(() => {});
|
|
1041
|
+
}
|
|
1042
|
+
closeContext(ctx, true);
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
const model = ctx.state.responseModel || proc?.model || null;
|
|
1046
|
+
if (ctx.kind === 'delivered') {
|
|
1047
|
+
// NEVER requeue an absorbed delivered row: the CLI already consumed
|
|
1048
|
+
// the prompt into the running turn — re-delivery would execute it a
|
|
1049
|
+
// second time (and the requeue route marks the healthy worker errored).
|
|
1050
|
+
const text = fallbackText
|
|
1051
|
+
? `${fallbackText}\n\n_(This turn was merged into the next message — the reply continues there.)_`
|
|
1052
|
+
: '_(This turn was merged into the next message — see the following reply.)_';
|
|
1053
|
+
await publisher.publishFinalStream(ctx.message, text);
|
|
1054
|
+
await publisher.publishResponse(ctx.message, { text, model });
|
|
1055
|
+
} else if (fallbackText) {
|
|
1056
|
+
await publisher.publishFinalStream(ctx.message, fallbackText);
|
|
1057
|
+
await publisher.publishResponse(ctx.message, { text: fallbackText, model });
|
|
1058
|
+
} else if (ctx.message?.id) {
|
|
1059
|
+
await api('POST', '/api/requeue', { messageId: ctx.message.id }).catch(() => {});
|
|
1060
|
+
}
|
|
1061
|
+
closeContext(ctx, true);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
async function interruptActiveTurn(ctx) {
|
|
1065
|
+
ctx.interrupted = true;
|
|
1066
|
+
try {
|
|
1067
|
+
await proc.turn.interrupt();
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
// No interrupt support (or a dead transport): fall back to killing the
|
|
1070
|
+
// process, which is exactly the pre-persistent abort behavior.
|
|
1071
|
+
dbg('interrupt failed, aborting process', error?.message || String(error));
|
|
1072
|
+
hardAbortProcess();
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function hardAbortProcess() {
|
|
1077
|
+
if (!proc) return;
|
|
1078
|
+
proc.aborted = true;
|
|
1079
|
+
proc.abortController.abort();
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// ---------------------------------------------------------------------------
|
|
1083
|
+
// Process-level stream observation
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Ship the live task set (enriched with per-task progress) to the relay,
|
|
1087
|
+
* which renders it as the composer's background-tasks panel. Membership
|
|
1088
|
+
* changes post immediately; chatty progress updates are trailing-edge
|
|
1089
|
+
* throttled. Advisory: failures must never disturb the stream consumer.
|
|
1090
|
+
*/
|
|
1091
|
+
function publishBackgroundTasks({ throttled = false } = {}) {
|
|
1092
|
+
const processRef = proc;
|
|
1093
|
+
if (!processRef) return;
|
|
1094
|
+
const send = () => {
|
|
1095
|
+
processRef.taskPublishTimer = null;
|
|
1096
|
+
const tasks = [...processRef.liveTasks.entries()].map(([taskId, task]) => {
|
|
1097
|
+
// The SDK's task events never carry a model (verified against
|
|
1098
|
+
// 0.3.226): a pinned one lives on the spawning tool_use block
|
|
1099
|
+
// (subagentSpawns, keyed by the task's tool_use_id); without a pin an
|
|
1100
|
+
// agent-like task runs on the session model. Resolved at publish
|
|
1101
|
+
// time so the spawn block landing after task_started still heals on
|
|
1102
|
+
// the next publish. Bash & co. get no model at all.
|
|
1103
|
+
const agentLike = AGENT_TASK_TYPES.has(String(task.taskType || ''));
|
|
1104
|
+
const pinnedModel = (task.toolUseId && processRef.subagentSpawns.get(task.toolUseId)) || '';
|
|
1105
|
+
const model = agentLike ? (pinnedModel || processRef.initModel || null) : null;
|
|
1106
|
+
return {
|
|
1107
|
+
taskId,
|
|
1108
|
+
taskType: task.taskType,
|
|
1109
|
+
description: task.description,
|
|
1110
|
+
startedAt: task.startedAt || null,
|
|
1111
|
+
summary: task.summary || null,
|
|
1112
|
+
lastToolName: task.lastToolName || null,
|
|
1113
|
+
totalTokens: task.totalTokens ?? null,
|
|
1114
|
+
toolUseId: task.toolUseId || null,
|
|
1115
|
+
subagentType: task.subagentType || null,
|
|
1116
|
+
model,
|
|
1117
|
+
modelInherited: Boolean(model && !pinnedModel),
|
|
1118
|
+
// The workflow digest (poller below) rides the same row; the relay
|
|
1119
|
+
// sanitizer whitelists and re-clamps it before storing.
|
|
1120
|
+
...(task.workflowProgress ? { workflowProgress: task.workflowProgress } : {}),
|
|
1121
|
+
};
|
|
1122
|
+
});
|
|
1123
|
+
api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks }).catch((error) => {
|
|
1124
|
+
dbg('background task publish failed', error?.message || String(error));
|
|
1125
|
+
});
|
|
1126
|
+
};
|
|
1127
|
+
if (!throttled) {
|
|
1128
|
+
if (processRef.taskPublishTimer) {
|
|
1129
|
+
clearTimeout(processRef.taskPublishTimer);
|
|
1130
|
+
processRef.taskPublishTimer = null;
|
|
1131
|
+
}
|
|
1132
|
+
send();
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
if (processRef.taskPublishTimer) return;
|
|
1136
|
+
processRef.taskPublishTimer = setTimeout(send, 2_000);
|
|
1137
|
+
processRef.taskPublishTimer.unref?.();
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// ---------------------------------------------------------------------------
|
|
1141
|
+
// Workflow progress poller
|
|
1142
|
+
//
|
|
1143
|
+
// A `local_workflow` task is ONE opaque row on the task-events path; its
|
|
1144
|
+
// per-agent tree lives on disk (see the module-level readers above). While
|
|
1145
|
+
// such a task is live, an interval polls the tree, digests it, and attaches
|
|
1146
|
+
// the digest to the task's published row as `workflowProgress` — publishing
|
|
1147
|
+
// only when the digest actually changed. Same lifecycle discipline as
|
|
1148
|
+
// taskPublishTimer: unref'd, cleared on settle and in cleanupProcess.
|
|
1149
|
+
|
|
1150
|
+
function workflowSessionDirFor(processRef) {
|
|
1151
|
+
const nativeSessionId = String(processRef.nativeSessionId || claudeNativeSessionId || '').trim();
|
|
1152
|
+
if (!nativeSessionId) return '';
|
|
1153
|
+
try {
|
|
1154
|
+
return String(resolveWorkflowSessionDir({ nativeSessionId, cwd }) || '');
|
|
1155
|
+
} catch {
|
|
1156
|
+
return '';
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function getWorkflowState(processRef, taskId) {
|
|
1161
|
+
let state = processRef.workflowStates.get(taskId);
|
|
1162
|
+
if (!state) {
|
|
1163
|
+
state = { runId: '', labels: new Map(), digestJson: '' };
|
|
1164
|
+
processRef.workflowStates.set(taskId, state);
|
|
1165
|
+
}
|
|
1166
|
+
return state;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* The freshest digest for one workflow task, or null. The run record wins
|
|
1171
|
+
* whenever it exists (it is written at completion and carries the full
|
|
1172
|
+
* tree); until then the live journal is the source. `recordOnly` is the
|
|
1173
|
+
* settle-time final read, where a journal re-read could only regress.
|
|
1174
|
+
*/
|
|
1175
|
+
function buildWorkflowDigest(processRef, sessionDir, taskId, task, { recordOnly = false } = {}) {
|
|
1176
|
+
const state = getWorkflowState(processRef, taskId);
|
|
1177
|
+
const startedAt = Number(task.startedAt) || 0;
|
|
1178
|
+
|
|
1179
|
+
if (state.runId) {
|
|
1180
|
+
const record = readWorkflowRunRecord(sessionDir, state.runId);
|
|
1181
|
+
if (record) {
|
|
1182
|
+
const recordTaskId = String(record.taskId || '').trim();
|
|
1183
|
+
if (recordTaskId && recordTaskId !== taskId) {
|
|
1184
|
+
// The mtime heuristic bound this task to another task's run; the
|
|
1185
|
+
// record's taskId is authoritative, so drop the binding and let the
|
|
1186
|
+
// taskId scan re-match on a later poll.
|
|
1187
|
+
state.runId = '';
|
|
1188
|
+
} else {
|
|
1189
|
+
const digest = digestFromRunRecord(record);
|
|
1190
|
+
if (digest) return digest;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
if (!state.runId) {
|
|
1195
|
+
const record = scanWorkflowRunRecords({ sessionDir, taskId, startedAt });
|
|
1196
|
+
const digest = record ? digestFromRunRecord(record) : null;
|
|
1197
|
+
if (digest) {
|
|
1198
|
+
state.runId = digest.runId;
|
|
1199
|
+
return digest;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
if (recordOnly) return null;
|
|
1203
|
+
|
|
1204
|
+
if (!state.runId) state.runId = findLiveWorkflowRunId({ sessionDir, startedAt });
|
|
1205
|
+
if (!state.runId) return null;
|
|
1206
|
+
const runDir = nodePath.join(sessionDir, 'subagents', 'workflows', state.runId);
|
|
1207
|
+
const entries = readWorkflowJournal(runDir);
|
|
1208
|
+
if (!entries) return null;
|
|
1209
|
+
for (const entry of entries) {
|
|
1210
|
+
const agentId = String(entry?.agentId || '').trim();
|
|
1211
|
+
if (!agentId || state.labels.has(agentId)) continue;
|
|
1212
|
+
// Cache label hits only: a miss may just be a first line still being
|
|
1213
|
+
// flushed, and re-reading 8KB per poll until it lands is cheap.
|
|
1214
|
+
const label = readWorkflowAgentLabel(runDir, agentId);
|
|
1215
|
+
if (label) state.labels.set(agentId, label);
|
|
1216
|
+
}
|
|
1217
|
+
return digestFromJournal({
|
|
1218
|
+
entries,
|
|
1219
|
+
labelsByAgentId: state.labels,
|
|
1220
|
+
workflowName: null,
|
|
1221
|
+
runId: state.runId,
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function stopWorkflowPoller(processRef) {
|
|
1226
|
+
if (processRef.workflowPollTimer) {
|
|
1227
|
+
clearInterval(processRef.workflowPollTimer);
|
|
1228
|
+
processRef.workflowPollTimer = null;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function pollWorkflowProgress(processRef) {
|
|
1233
|
+
if (processRef !== proc || processRef.closing) {
|
|
1234
|
+
stopWorkflowPoller(processRef);
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
const workflowTasks = [...processRef.liveTasks.entries()]
|
|
1238
|
+
.filter(([, task]) => String(task.taskType || '') === 'local_workflow');
|
|
1239
|
+
if (!workflowTasks.length) {
|
|
1240
|
+
stopWorkflowPoller(processRef);
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
const sessionDir = workflowSessionDirFor(processRef);
|
|
1244
|
+
if (!sessionDir) return;
|
|
1245
|
+
let changed = false;
|
|
1246
|
+
for (const [taskId, task] of workflowTasks) {
|
|
1247
|
+
let digest = null;
|
|
1248
|
+
try {
|
|
1249
|
+
digest = buildWorkflowDigest(processRef, sessionDir, taskId, task);
|
|
1250
|
+
} catch {
|
|
1251
|
+
// Advisory by contract: a digest failure must never disturb the turn.
|
|
1252
|
+
}
|
|
1253
|
+
if (!digest) continue;
|
|
1254
|
+
const digestJson = JSON.stringify(digest);
|
|
1255
|
+
const state = getWorkflowState(processRef, taskId);
|
|
1256
|
+
if (state.digestJson === digestJson) continue;
|
|
1257
|
+
state.digestJson = digestJson;
|
|
1258
|
+
task.workflowProgress = digest;
|
|
1259
|
+
changed = true;
|
|
1260
|
+
}
|
|
1261
|
+
if (changed) publishBackgroundTasks();
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/** Start/stop the poller to match the live task set; prune settled state. */
|
|
1265
|
+
function syncWorkflowPoller(processRef) {
|
|
1266
|
+
for (const taskId of [...processRef.workflowStates.keys()]) {
|
|
1267
|
+
if (!processRef.liveTasks.has(taskId)) processRef.workflowStates.delete(taskId);
|
|
1268
|
+
}
|
|
1269
|
+
const hasWorkflowTask = [...processRef.liveTasks.values()]
|
|
1270
|
+
.some((task) => String(task.taskType || '') === 'local_workflow');
|
|
1271
|
+
if (!hasWorkflowTask) {
|
|
1272
|
+
stopWorkflowPoller(processRef);
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
if (processRef.workflowPollTimer || processRef.closing) return;
|
|
1276
|
+
processRef.workflowPollTimer = setInterval(() => pollWorkflowProgress(processRef), workflowPollMs);
|
|
1277
|
+
processRef.workflowPollTimer.unref?.();
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// How many settled workflows can wait for their summarizing response at
|
|
1281
|
+
// once — matches the relay-side cap on `workflowRuns` per message.
|
|
1282
|
+
const MAX_SETTLED_WORKFLOW_RUNS = 5;
|
|
1283
|
+
|
|
1284
|
+
/** True for any status that names an outcome (not in-flight, not absent). */
|
|
1285
|
+
function isSettledWorkflowStatus(value) {
|
|
1286
|
+
const status = String(value || '').trim().toLowerCase();
|
|
1287
|
+
return Boolean(status) && status !== 'running';
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Remember a settled workflow's final digest for the next /api/response
|
|
1292
|
+
* publish (the "Finished background task" card on the summarizing turn).
|
|
1293
|
+
* The run record's digest is authoritative; a run that never wrote one
|
|
1294
|
+
* (stopped early) settles with its last live digest, whose stale 'running'
|
|
1295
|
+
* status is replaced by the settle notification's ('stopped', 'failed').
|
|
1296
|
+
* Both settle signals may call this for the same task in either order —
|
|
1297
|
+
* a terminal status, once seen, is never downgraded.
|
|
1298
|
+
*/
|
|
1299
|
+
function bufferSettledWorkflowRun(taskId, task, notificationStatus = '') {
|
|
1300
|
+
if (!proc || !task || String(task.taskType || '') !== 'local_workflow') return;
|
|
1301
|
+
try {
|
|
1302
|
+
const sessionDir = workflowSessionDirFor(proc);
|
|
1303
|
+
const recordDigest = sessionDir
|
|
1304
|
+
? buildWorkflowDigest(proc, sessionDir, taskId, task, { recordOnly: true })
|
|
1305
|
+
: null;
|
|
1306
|
+
const digest = recordDigest
|
|
1307
|
+
|| (task.workflowProgress && typeof task.workflowProgress === 'object' ? task.workflowProgress : null);
|
|
1308
|
+
if (!digest) return;
|
|
1309
|
+
const previous = proc.settledWorkflowDigests.get(taskId) || null;
|
|
1310
|
+
const status = [digest.status, notificationStatus, previous?.digest?.status]
|
|
1311
|
+
.find(isSettledWorkflowStatus) || digest.status || null;
|
|
1312
|
+
proc.settledWorkflowDigests.delete(taskId);
|
|
1313
|
+
proc.settledWorkflowDigests.set(taskId, {
|
|
1314
|
+
digest: { ...digest, status },
|
|
1315
|
+
// A journal-snapshot digest gets one more record-read attempt at
|
|
1316
|
+
// drain time (the CLI can flush the run record moments after the
|
|
1317
|
+
// settle signals); startedAt is what the taskId scan needs then.
|
|
1318
|
+
fromRecord: Boolean(recordDigest),
|
|
1319
|
+
startedAt: Number(task.startedAt) || 0,
|
|
1320
|
+
});
|
|
1321
|
+
while (proc.settledWorkflowDigests.size > MAX_SETTLED_WORKFLOW_RUNS) {
|
|
1322
|
+
proc.settledWorkflowDigests.delete(proc.settledWorkflowDigests.keys().next().value);
|
|
1323
|
+
}
|
|
1324
|
+
} catch {
|
|
1325
|
+
// Advisory by contract: buffering must never disturb the settle path.
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* Removal-first settle order: the digest was already buffered when the task
|
|
1331
|
+
* left the live set, but only the late task_notification knows how it ended.
|
|
1332
|
+
*/
|
|
1333
|
+
function refineSettledWorkflowStatus(taskId, notificationStatus) {
|
|
1334
|
+
const status = String(notificationStatus || '').trim();
|
|
1335
|
+
const buffered = proc?.settledWorkflowDigests.get(taskId);
|
|
1336
|
+
if (!buffered || !status) return;
|
|
1337
|
+
if (!isSettledWorkflowStatus(buffered.digest.status)) buffered.digest.status = status;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Hand the buffered digests to the response publish and clear the buffer.
|
|
1342
|
+
* Entries that settled on a journal snapshot (record not on disk yet — the
|
|
1343
|
+
* CLI flushes it moments after the settle signals, observed live as a card
|
|
1344
|
+
* frozen with a "running" verify agent) retry the authoritative record read
|
|
1345
|
+
* here: the summarizing response publishes seconds later, when the record
|
|
1346
|
+
* exists.
|
|
1347
|
+
*/
|
|
1348
|
+
function drainSettledWorkflowRuns() {
|
|
1349
|
+
if (!proc || !proc.settledWorkflowDigests.size) return null;
|
|
1350
|
+
const entries = [...proc.settledWorkflowDigests.entries()];
|
|
1351
|
+
proc.settledWorkflowDigests.clear();
|
|
1352
|
+
const sessionDir = workflowSessionDirFor(proc);
|
|
1353
|
+
return entries.map(([taskId, entry]) => {
|
|
1354
|
+
if (!entry.fromRecord && sessionDir) {
|
|
1355
|
+
try {
|
|
1356
|
+
const record = buildWorkflowDigest(
|
|
1357
|
+
proc,
|
|
1358
|
+
sessionDir,
|
|
1359
|
+
taskId,
|
|
1360
|
+
{ startedAt: entry.startedAt },
|
|
1361
|
+
{ recordOnly: true },
|
|
1362
|
+
);
|
|
1363
|
+
if (record) {
|
|
1364
|
+
return isSettledWorkflowStatus(record.status)
|
|
1365
|
+
? record
|
|
1366
|
+
: { ...record, status: entry.digest.status };
|
|
1367
|
+
}
|
|
1368
|
+
} catch {
|
|
1369
|
+
// Advisory: the snapshot digest below is still a valid card.
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
return entry.digest;
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Settle-time final read: the run record lands at completion, so the
|
|
1378
|
+
* task_notification is the one moment the completed tree (final states,
|
|
1379
|
+
* tokens, logs) can still ride the row before it clears.
|
|
1380
|
+
*/
|
|
1381
|
+
function publishFinalWorkflowDigest(taskId, notificationStatus = '') {
|
|
1382
|
+
const task = proc?.liveTasks.get(taskId);
|
|
1383
|
+
if (!task || String(task.taskType || '') !== 'local_workflow') {
|
|
1384
|
+
refineSettledWorkflowStatus(taskId, notificationStatus);
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
bufferSettledWorkflowRun(taskId, task, notificationStatus);
|
|
1388
|
+
try {
|
|
1389
|
+
const sessionDir = workflowSessionDirFor(proc);
|
|
1390
|
+
if (!sessionDir) return;
|
|
1391
|
+
const digest = buildWorkflowDigest(proc, sessionDir, taskId, task, { recordOnly: true });
|
|
1392
|
+
if (!digest) return;
|
|
1393
|
+
const digestJson = JSON.stringify(digest);
|
|
1394
|
+
const state = getWorkflowState(proc, taskId);
|
|
1395
|
+
if (state.digestJson === digestJson) return;
|
|
1396
|
+
state.digestJson = digestJson;
|
|
1397
|
+
task.workflowProgress = digest;
|
|
1398
|
+
publishBackgroundTasks();
|
|
1399
|
+
} catch {
|
|
1400
|
+
// Advisory: a failed final read leaves the last live digest standing.
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// Remember the model each subagent spawn pinned explicitly: only the
|
|
1405
|
+
// spawning Agent/Task tool_use block carries it (input.model), and
|
|
1406
|
+
// task_started.tool_use_id links that block to its task. Capped so a
|
|
1407
|
+
// long-lived process holding many spawns can't grow the map unbounded.
|
|
1408
|
+
function recordSubagentSpawns(sdkMessage) {
|
|
1409
|
+
const blocks = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
|
|
1410
|
+
for (const block of blocks) {
|
|
1411
|
+
if (block?.type !== 'tool_use' || !isSubagentToolName(block.name)) continue;
|
|
1412
|
+
const toolUseId = String(block.id || '').trim();
|
|
1413
|
+
const model = String(block.input?.model || '').trim();
|
|
1414
|
+
if (!toolUseId || !model) continue;
|
|
1415
|
+
proc.subagentSpawns.set(toolUseId, model);
|
|
1416
|
+
while (proc.subagentSpawns.size > 100) {
|
|
1417
|
+
proc.subagentSpawns.delete(proc.subagentSpawns.keys().next().value);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// Between-turn notices carried into the next activated context, as either a
|
|
1423
|
+
// plain line or a prepared publisher action (a compaction boundary has to
|
|
1424
|
+
// keep the structured metadata the transcript's break row is built from —
|
|
1425
|
+
// as prose it would render as an ordinary tool-activity line). Capped —
|
|
1426
|
+
// api_retry made this the only accumulator a retry storm could grow
|
|
1427
|
+
// unboundedly (subagentSpawns and settledWorkflowDigests already cap).
|
|
1428
|
+
const MAX_PENDING_ACTIVITIES = 50;
|
|
1429
|
+
function carryPendingActivity(entry) {
|
|
1430
|
+
const carried = entry && typeof entry === 'object'
|
|
1431
|
+
? entry
|
|
1432
|
+
: String(entry || '').trim().slice(0, 2000);
|
|
1433
|
+
if (!carried) return;
|
|
1434
|
+
proc.pendingActivities.push(carried);
|
|
1435
|
+
// Drop the OLDEST PROSE line first, and a structured entry only when
|
|
1436
|
+
// nothing else is left: the cap exists to bound a retry storm's chatter,
|
|
1437
|
+
// while a structured entry carries transcript geometry no later line can
|
|
1438
|
+
// restore (a compaction boundary buffered between turns, then 50 task
|
|
1439
|
+
// notifications, used to lose its break row silently). The client's
|
|
1440
|
+
// capRelayActivityEntries protects structured rows too, but head-caps the
|
|
1441
|
+
// prose it keeps; here the newest lines are the informative ones.
|
|
1442
|
+
while (proc.pendingActivities.length > MAX_PENDING_ACTIVITIES) {
|
|
1443
|
+
const proseIndex = proc.pendingActivities.findIndex((carried) => typeof carried === 'string');
|
|
1444
|
+
proc.pendingActivities.splice(proseIndex === -1 ? 0 : proseIndex, 1);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// ---------------------------------------------------------------------------
|
|
1449
|
+
// Compaction
|
|
1450
|
+
//
|
|
1451
|
+
// Compaction is a long, completely silent stretch of CLI work — 133 s on a
|
|
1452
|
+
// 614k-token session resumed against a 100k window (conv 563e252e,
|
|
1453
|
+
// 2026-08-20) — that produces no turn context of its own. Two things must
|
|
1454
|
+
// survive it: the process (nothing else is "live" while it runs) and the
|
|
1455
|
+
// delivered message that triggered it (the CLI answers it AFTER the
|
|
1456
|
+
// boundary, on a stream the runner would otherwise read as self-opened).
|
|
1457
|
+
|
|
1458
|
+
function isCompacting() {
|
|
1459
|
+
return Boolean(proc?.compactingSince) && Date.now() - proc.compactingSince < compactionStaleMs;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* Whether a between-turn notice can reach the relay through the active
|
|
1464
|
+
* context, or has to be buffered for the next one. A discarded context is
|
|
1465
|
+
* active but publishes nothing (dispatchToContext drops it), so it counts as
|
|
1466
|
+
* no context at all.
|
|
1467
|
+
*/
|
|
1468
|
+
function activeContextPublishes() {
|
|
1469
|
+
return Boolean(proc.activeCtx) && !proc.activeCtx.discarded;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/**
|
|
1473
|
+
* The CLI is compacting, or just did: a delivered turn is still coming.
|
|
1474
|
+
* Armed only while a delivered entry is actually waiting; which turn the
|
|
1475
|
+
* window may then be spent on is decided when that turn opens, by reading
|
|
1476
|
+
* the message itself (isCompactionReplayCandidate).
|
|
1477
|
+
*/
|
|
1478
|
+
function armCompactReplayAdoption() {
|
|
1479
|
+
if (!proc.pendingDelivered.length) return;
|
|
1480
|
+
proc.compactReplayUntil = Date.now() + compactReplayAdoptionMs;
|
|
1481
|
+
// Whatever the entries waited through, they were not idle: the CLI is
|
|
1482
|
+
// about to replay the turn they belong to.
|
|
1483
|
+
for (const entry of proc.pendingDelivered) entry.unattachedSince = 0;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function observeProcessLevel(sdkMessage) {
|
|
1487
|
+
const type = String(sdkMessage?.type || '');
|
|
1488
|
+
if (type === 'assistant') {
|
|
1489
|
+
recordSubagentSpawns(sdkMessage);
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
if (type !== 'system') return;
|
|
1493
|
+
const subtype = String(sdkMessage.subtype || '');
|
|
1494
|
+
if (subtype === 'init') {
|
|
1495
|
+
// The FIRST init of a process is the spawn's, and a spawn is always
|
|
1496
|
+
// driven by a delivered message; a LATER one is the CLI opening a turn
|
|
1497
|
+
// by itself.
|
|
1498
|
+
const firstInit = !proc.sawInit;
|
|
1499
|
+
proc.sawInit = true;
|
|
1500
|
+
persistNativeSessionId(sdkSessionId, sdkMessage.session_id).catch(() => {});
|
|
1501
|
+
// Tracked on the process too (not only through the persist round-trip):
|
|
1502
|
+
// the workflow poller needs the native id to find the session dir even
|
|
1503
|
+
// when the persist call is still in flight or failed.
|
|
1504
|
+
proc.nativeSessionId = String(sdkMessage.session_id || '').trim() || proc.nativeSessionId;
|
|
1505
|
+
// Init always lands before any context is active, so the per-context
|
|
1506
|
+
// normalizer never sees it — capture the CLI's actual model here and
|
|
1507
|
+
// seed it into every context at activation. Without this, a composer
|
|
1508
|
+
// set to `auto` published responses with model: null.
|
|
1509
|
+
proc.initModel = String(sdkMessage.model || '').trim() || proc.initModel;
|
|
1510
|
+
// A fresh init with no active turn AND nothing queued to deliver is the CLI
|
|
1511
|
+
// opening a turn on its own — a background-task continuation ("you will be
|
|
1512
|
+
// notified"). The real SDK does NOT emit a user-message replay for these
|
|
1513
|
+
// (verified against the live SDK: the settle is background_tasks_changed +
|
|
1514
|
+
// task_notification, then a bare init, then assistant/result), so without
|
|
1515
|
+
// marking the boundary here resolveContext() drops the continuation's
|
|
1516
|
+
// assistant frames as between-turn chatter and nothing publishes. A
|
|
1517
|
+
// delivered turn always has its row in pendingDelivered before its init is
|
|
1518
|
+
// processed (spawnProcess + pendingDelivered.push run synchronously ahead
|
|
1519
|
+
// of the async stream consumer), so it is correctly excluded here.
|
|
1520
|
+
//
|
|
1521
|
+
if (!proc.activeCtx && !proc.pendingDelivered.length) {
|
|
1522
|
+
proc.lastBoundary = 'self-opened';
|
|
1523
|
+
} else if (!proc.activeCtx && !firstInit) {
|
|
1524
|
+
// A LATER init, with a delivered row waiting, is the CLI opening a turn
|
|
1525
|
+
// of its own — the live-verified continuation shape is a bare init with
|
|
1526
|
+
// no user replay at all. A delivered turn cannot produce one: the
|
|
1527
|
+
// process is already running, and only a spawn inits (which is
|
|
1528
|
+
// `firstInit`, and always has its row queued before the stream is
|
|
1529
|
+
// read). A compaction inside such a turn would otherwise see no
|
|
1530
|
+
// turn-level boundary and adopt the queued row — and in this shape
|
|
1531
|
+
// nothing later replays to correct it.
|
|
1532
|
+
//
|
|
1533
|
+
// Structural, not a clock. Gating this on "a task settled recently"
|
|
1534
|
+
// fails twice over: the grace (60 s) is shorter than a compaction
|
|
1535
|
+
// (133 s measured), and `activateContext` zeroes the settle timestamps,
|
|
1536
|
+
// so any turn attaching in between erases the signal for good. Every
|
|
1537
|
+
// freshness-clock design in this fix's history failed the same way.
|
|
1538
|
+
//
|
|
1539
|
+
// Deliberately NOT `lastBoundary`: that flag also diverts assistant
|
|
1540
|
+
// traffic away from a waiting delivered row, and a settle timestamp is
|
|
1541
|
+
// far too weak to justify that (it stays fresh for the whole
|
|
1542
|
+
// notification grace after ANY task settles, compaction or not, and
|
|
1543
|
+
// would fail the row over while publishing its answer on a synthetic
|
|
1544
|
+
// continuation). This suppresses adoption and nothing else; if the turn
|
|
1545
|
+
// really is the delivered message's, it attaches exactly as before.
|
|
1546
|
+
proc.continuationInitPending = true;
|
|
1547
|
+
}
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
if (subtype === 'status') {
|
|
1551
|
+
// The status line is the compaction hold's only in-progress signal, so
|
|
1552
|
+
// every spelling that is NOT a permission-mode notice releases it:
|
|
1553
|
+
// `{status:null, compact_result}` is the normal terminator, a BARE
|
|
1554
|
+
// `{status:null}` is what an early-compact-start that produced no
|
|
1555
|
+
// compaction emits (no result, no boundary — the hold would otherwise
|
|
1556
|
+
// stand until the staleness cap, delaying the watchdog and blocking the
|
|
1557
|
+
// mode-change recycle), and 'requesting' comes from the main loop's
|
|
1558
|
+
// stream_request_start, which by definition means the CLI is past
|
|
1559
|
+
// compacting (the compaction fork reports progress as `stream_mode`,
|
|
1560
|
+
// which the SDK drops). `{status:null, permissionMode}` is a mode-change
|
|
1561
|
+
// notice that says nothing about compaction and is ignored.
|
|
1562
|
+
if (String(sdkMessage.status || '') === 'compacting') {
|
|
1563
|
+
proc.compactingSince = Date.now();
|
|
1564
|
+
armCompactReplayAdoption();
|
|
1565
|
+
} else if (sdkMessage.permissionMode === undefined) {
|
|
1566
|
+
proc.compactingSince = 0;
|
|
1567
|
+
// A compaction that failed produces no boundary and no replay: the
|
|
1568
|
+
// turn just carries on uncompacted and attaches the normal way, so
|
|
1569
|
+
// leaving the adoption window open would only widen the chance of a
|
|
1570
|
+
// later self-opened turn being adopted.
|
|
1571
|
+
if (String(sdkMessage.compact_result || '') === 'failed' || sdkMessage.compact_error !== undefined) {
|
|
1572
|
+
proc.compactReplayUntil = 0;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
evaluateLifecycle();
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
if (subtype === 'compact_boundary') {
|
|
1579
|
+
proc.compactingSince = 0;
|
|
1580
|
+
armCompactReplayAdoption();
|
|
1581
|
+
// The boundary is a per-turn normalizer action, but a compaction at
|
|
1582
|
+
// resume — exactly what lowering the window causes — lands with no
|
|
1583
|
+
// context to publish onto and would be dropped. Buffer it for the next
|
|
1584
|
+
// one; with a turn active the normalizer publishes it (resolveContext
|
|
1585
|
+
// routes system messages to the active context and nowhere else), so
|
|
1586
|
+
// the two paths can never both fire. An already-discarded context is
|
|
1587
|
+
// routed here too: it is still `activeCtx`, but dispatchToContext drops
|
|
1588
|
+
// everything it is handed. That does not cover every way a boundary can
|
|
1589
|
+
// still be lost — a context discarded AFTER buffering it (a continuation
|
|
1590
|
+
// whose registration fails all three attempts) drops the whole turn's
|
|
1591
|
+
// output, boundary included — but those paths lose the turn itself, so
|
|
1592
|
+
// the break row is the least of what is missing.
|
|
1593
|
+
if (!activeContextPublishes()) {
|
|
1594
|
+
carryPendingActivity(compactBoundaryActivityAction(sdkMessage));
|
|
1595
|
+
}
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
if (subtype === 'model_refusal_fallback') {
|
|
1599
|
+
// The CLI retried a safeguards-refused request on a fallback model
|
|
1600
|
+
// (Fable 5 → Opus 4.8 in conv 3366b9d3). Track it at process level:
|
|
1601
|
+
// later contexts must seed the model that is actually running, and a
|
|
1602
|
+
// session-scoped switch must be undone on the next delivered turn when
|
|
1603
|
+
// the composer pinned a model (adaptProcess repins via setModel). An
|
|
1604
|
+
// `auto` composer keeps the CLI's own behavior — only the recorded
|
|
1605
|
+
// model follows the switch.
|
|
1606
|
+
const fallbackModel = String(sdkMessage.fallbackModel || '').trim();
|
|
1607
|
+
if (fallbackModel) {
|
|
1608
|
+
proc.initModel = fallbackModel;
|
|
1609
|
+
if (String(sdkMessage.scope || '').trim().toLowerCase() === 'session') {
|
|
1610
|
+
proc.modelFallback = fallbackModel;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
// With a turn active the normalizer surfaces the CLI's notice (and the
|
|
1614
|
+
// attribution update); between turns the notice carries to the next one.
|
|
1615
|
+
if (!proc.activeCtx) {
|
|
1616
|
+
const notice = String(sdkMessage.content || '').trim()
|
|
1617
|
+
|| `Model switched to ${fallbackModel || 'a fallback model'} after a refusal.`;
|
|
1618
|
+
carryPendingActivity(notice);
|
|
1619
|
+
}
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
if (subtype === 'api_retry') {
|
|
1623
|
+
// With a turn active the normalizer surfaces this as an activity on
|
|
1624
|
+
// that turn. Otherwise: a delivered row waiting on this very request
|
|
1625
|
+
// gets the notice NOW — buffering it until attach would publish the
|
|
1626
|
+
// explanation only after the stall is over (silent 529 retries read
|
|
1627
|
+
// as relay freezes on 2026-08-18). With no addressable row, carry it,
|
|
1628
|
+
// collapsing consecutive retry lines — only the latest matters.
|
|
1629
|
+
if (!proc.activeCtx) {
|
|
1630
|
+
const notice = formatApiRetryNotice(sdkMessage);
|
|
1631
|
+
const waiting = proc.pendingDelivered[0]?.ctx.message;
|
|
1632
|
+
if (waiting?.id) {
|
|
1633
|
+
publisher.postActivity(waiting, notice);
|
|
1634
|
+
} else {
|
|
1635
|
+
const last = proc.pendingActivities[proc.pendingActivities.length - 1];
|
|
1636
|
+
if (typeof last === 'string' && last.startsWith('Anthropic API')) proc.pendingActivities.pop();
|
|
1637
|
+
carryPendingActivity(notice);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
if (subtype === 'background_tasks_changed') {
|
|
1643
|
+
const tasks = Array.isArray(sdkMessage.tasks) ? sdkMessage.tasks : [];
|
|
1644
|
+
const previous = proc.liveTasks;
|
|
1645
|
+
proc.liveTasks = new Map(tasks
|
|
1646
|
+
.filter((task) => String(task?.task_id || '').trim())
|
|
1647
|
+
.map((task) => {
|
|
1648
|
+
const taskId = String(task.task_id).trim();
|
|
1649
|
+
const known = previous.get(taskId) || {};
|
|
1650
|
+
return [taskId, {
|
|
1651
|
+
...known,
|
|
1652
|
+
taskType: String(task?.task_type || '').trim() || known.taskType || '',
|
|
1653
|
+
description: String(task?.description || '').trim() || known.description || '',
|
|
1654
|
+
startedAt: known.startedAt || Date.now(),
|
|
1655
|
+
}];
|
|
1656
|
+
}));
|
|
1657
|
+
for (const taskId of proc.liveTasks.keys()) {
|
|
1658
|
+
// A task started after an expiry deserves its own timeout budget —
|
|
1659
|
+
// the one-shot latch would otherwise never cap it.
|
|
1660
|
+
if (!previous.has(taskId)) {
|
|
1661
|
+
proc.tasksExpired = false;
|
|
1662
|
+
proc.tasksExpiredAt = 0;
|
|
1663
|
+
proc.heldForTasksSince = 0;
|
|
1664
|
+
}
|
|
1665
|
+
proc.knownTasks.add(taskId);
|
|
1666
|
+
}
|
|
1667
|
+
// A task the process knew about just left the live set: it settled, and
|
|
1668
|
+
// the CLI is likely about to dequeue its continuation turn ("you will be
|
|
1669
|
+
// notified"). Pin the process for the grace window so it can't idle out in
|
|
1670
|
+
// the gap before that continuation's first traffic arrives. This is the
|
|
1671
|
+
// reliable "continuation imminent" signal and does not depend on the
|
|
1672
|
+
// settling task_notification (which may be silent — skip_transcript — or
|
|
1673
|
+
// arrive after this message); the notification-based pin is secondary.
|
|
1674
|
+
for (const taskId of previous.keys()) {
|
|
1675
|
+
if (!proc.liveTasks.has(taskId)) {
|
|
1676
|
+
proc.taskSettledAt = Date.now();
|
|
1677
|
+
break;
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
// The live SDK usually drops a settled task's row BEFORE its
|
|
1681
|
+
// task_notification arrives, so this removal is the reliable moment to
|
|
1682
|
+
// capture a settling workflow's final digest for the transcript card
|
|
1683
|
+
// (the notification then refines the status when it lands).
|
|
1684
|
+
for (const [taskId, task] of previous) {
|
|
1685
|
+
if (!proc.liveTasks.has(taskId)) bufferSettledWorkflowRun(taskId, task);
|
|
1686
|
+
}
|
|
1687
|
+
syncWorkflowPoller(proc);
|
|
1688
|
+
publishBackgroundTasks();
|
|
1689
|
+
evaluateLifecycle();
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
if (subtype === 'task_started') {
|
|
1693
|
+
const taskId = String(sdkMessage.task_id || '').trim();
|
|
1694
|
+
if (!taskId) return;
|
|
1695
|
+
const known = proc.liveTasks.get(taskId);
|
|
1696
|
+
if (known) {
|
|
1697
|
+
known.description = String(sdkMessage.description || '').trim() || known.description;
|
|
1698
|
+
known.taskType = String(sdkMessage.task_type || '').trim() || known.taskType;
|
|
1699
|
+
known.subagentType = String(sdkMessage.subagent_type || '').trim() || known.subagentType || '';
|
|
1700
|
+
// The task's spawning tool_use id is the same identity the subagent
|
|
1701
|
+
// bubbles key on — recorded so the panel and the bubbles can be
|
|
1702
|
+
// correlated (and a targeted stop can find its task).
|
|
1703
|
+
known.toolUseId = String(sdkMessage.tool_use_id || '').trim() || known.toolUseId || '';
|
|
1704
|
+
// task_started can be the first event carrying the task_type; a
|
|
1705
|
+
// workflow revealed here must start its poller too.
|
|
1706
|
+
syncWorkflowPoller(proc);
|
|
1707
|
+
publishBackgroundTasks({ throttled: true });
|
|
1708
|
+
}
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
if (subtype === 'task_progress') {
|
|
1712
|
+
const taskId = String(sdkMessage.task_id || '').trim();
|
|
1713
|
+
const known = taskId ? proc.liveTasks.get(taskId) : null;
|
|
1714
|
+
if (known) {
|
|
1715
|
+
known.summary = String(sdkMessage.summary || '').trim() || known.summary;
|
|
1716
|
+
known.lastToolName = String(sdkMessage.last_tool_name || '').trim() || known.lastToolName;
|
|
1717
|
+
known.subagentType = String(sdkMessage.subagent_type || '').trim() || known.subagentType || '';
|
|
1718
|
+
known.toolUseId = String(sdkMessage.tool_use_id || '').trim() || known.toolUseId || '';
|
|
1719
|
+
const totalTokens = Number(sdkMessage.usage?.total_tokens);
|
|
1720
|
+
if (Number.isFinite(totalTokens)) known.totalTokens = totalTokens;
|
|
1721
|
+
publishBackgroundTasks({ throttled: true });
|
|
1722
|
+
}
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
if (subtype === 'task_notification') {
|
|
1726
|
+
const taskId = String(sdkMessage.task_id || '').trim();
|
|
1727
|
+
// A settling workflow gets one final run-record read so the completed
|
|
1728
|
+
// tree publishes before the row clears (best-effort: the CLI may have
|
|
1729
|
+
// already removed the task via background_tasks_changed).
|
|
1730
|
+
if (taskId) publishFinalWorkflowDigest(taskId, String(sdkMessage.status || '').trim());
|
|
1731
|
+
// A settled session-level task means the CLI is about to dequeue a
|
|
1732
|
+
// continuation turn — the process must not idle out under it. The SDK
|
|
1733
|
+
// flags silent notifications explicitly: skip_transcript means no
|
|
1734
|
+
// continuation is coming, so nothing should pin the process for one.
|
|
1735
|
+
if (taskId && proc.knownTasks.has(taskId) && sdkMessage.skip_transcript !== true) {
|
|
1736
|
+
proc.notificationPendingAt = Date.now();
|
|
1737
|
+
}
|
|
1738
|
+
if (!activeContextPublishes()) {
|
|
1739
|
+
const status = String(sdkMessage.status || '').trim() || 'unknown';
|
|
1740
|
+
const summary = String(sdkMessage.summary || '').trim();
|
|
1741
|
+
carryPendingActivity(`Background task ${taskId || 'unknown'} ${status}: ${summary}`);
|
|
1742
|
+
}
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
if (subtype === 'session_state_changed') {
|
|
1746
|
+
evaluateLifecycle();
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
async function onSdkMessage(processRef, sdkMessage) {
|
|
1751
|
+
// A superseded process (push-race respawn, mode-recycle timeout) may still
|
|
1752
|
+
// be draining its stream; its late messages must not mutate the state of
|
|
1753
|
+
// the process that replaced it. Its own open contexts are settled by
|
|
1754
|
+
// cleanupProcess when the old stream ends.
|
|
1755
|
+
if (!proc || proc !== processRef) return;
|
|
1756
|
+
proc.lastEventAt = Date.now();
|
|
1757
|
+
observeProcessLevel(sdkMessage);
|
|
1758
|
+
const ctx = resolveContext(sdkMessage);
|
|
1759
|
+
if (proc.handoffSettling) {
|
|
1760
|
+
// An absorbed-steering handoff just settled the previous context;
|
|
1761
|
+
// publishes for the incoming context must not overtake it.
|
|
1762
|
+
const settling = proc.handoffSettling;
|
|
1763
|
+
proc.handoffSettling = null;
|
|
1764
|
+
await settling;
|
|
1765
|
+
}
|
|
1766
|
+
if (!ctx) return;
|
|
1767
|
+
const actions = ctx.normalizer.normalize(sdkMessage);
|
|
1768
|
+
for (const action of actions) {
|
|
1769
|
+
await dispatchToContext(ctx, action);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// ---------------------------------------------------------------------------
|
|
1774
|
+
// Process lifecycle
|
|
1775
|
+
|
|
1776
|
+
/**
|
|
1777
|
+
* A background task settled and the CLI is about to dequeue a turn of its
|
|
1778
|
+
* own ("you will be notified"). Both signals are used: the task leaving the
|
|
1779
|
+
* live set (reliable, see background_tasks_changed) and the settling
|
|
1780
|
+
* task_notification (secondary — it may be silent or late).
|
|
1781
|
+
*/
|
|
1782
|
+
function isContinuationImminent() {
|
|
1783
|
+
if (!proc) return false;
|
|
1784
|
+
const fresh = (at) => Boolean(at) && Date.now() - at < notificationGraceMs;
|
|
1785
|
+
return fresh(proc.notificationPendingAt) || fresh(proc.taskSettledAt);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function hasLiveWork() {
|
|
1789
|
+
if (!proc) return false;
|
|
1790
|
+
return Boolean(
|
|
1791
|
+
proc.activeCtx
|
|
1792
|
+
|| proc.pendingDelivered.length
|
|
1793
|
+
|| proc.liveTasks.size
|
|
1794
|
+
|| isContinuationImminent()
|
|
1795
|
+
// A compaction produces no stream traffic for minutes; an idle shutdown
|
|
1796
|
+
// under it would kill the CLI mid-flight.
|
|
1797
|
+
|| isCompacting()
|
|
1798
|
+
|| proc.pendingControlRequests > 0,
|
|
1799
|
+
);
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
/**
|
|
1803
|
+
* Watchdog: a delivered entry whose replay never attached — the CLI
|
|
1804
|
+
* absorbed the message into a turn that already ended, or dropped it —
|
|
1805
|
+
* would renew its queue row's lease forever while blocking every later
|
|
1806
|
+
* message (the 2026-08-18 deadlock's failure mode). With no active turn,
|
|
1807
|
+
* no live tasks, no control round-trip in flight, and a quiet stream,
|
|
1808
|
+
* nothing can attach it anymore: reject its ctx.done so
|
|
1809
|
+
* handlePendingPayload's catch publishes a terminal error and the row
|
|
1810
|
+
* fails over instead of wedging the conversation.
|
|
1811
|
+
*/
|
|
1812
|
+
/**
|
|
1813
|
+
* Adoption splices a delivered entry out of `pendingDelivered` on an
|
|
1814
|
+
* inference, which also takes it out of the watchdog's reach: if the turn
|
|
1815
|
+
* the CLI opened then produces nothing at all (a retry storm, a dropped
|
|
1816
|
+
* turn), `activeCtx` pins the process, nothing can fail the row over, and
|
|
1817
|
+
* the queue row renews its lease forever — the 2026-08-18 deadlock shape.
|
|
1818
|
+
* The three ways an adoption unwinds all need the CLI to say *something*,
|
|
1819
|
+
* so silence gets its own: a provisional adoption that never produces
|
|
1820
|
+
* output puts the row back, and the ordinary watchdog takes it from there.
|
|
1821
|
+
*/
|
|
1822
|
+
function reapSilentProvisionalAdoption(now) {
|
|
1823
|
+
const ctx = proc?.activeCtx;
|
|
1824
|
+
if (!ctx?.adoptedFromCompaction) return;
|
|
1825
|
+
if (!(pendingDeliveredTimeoutMs > 0)) return;
|
|
1826
|
+
if (isCompacting() || proc.liveTasks.size || proc.pendingControlRequests > 0) {
|
|
1827
|
+
ctx.provisionalSince = now;
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
if (!ctx.provisionalSince) {
|
|
1831
|
+
ctx.provisionalSince = now;
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
if (now - ctx.provisionalSince < pendingDeliveredTimeoutMs) return;
|
|
1835
|
+
dbg('provisional adoption produced nothing; returning the row to the queue', ctx.message?.id || '(no id)');
|
|
1836
|
+
restoreAdoptedContext(ctx);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
function reapStalePendingDelivered(now) {
|
|
1840
|
+
if (!proc || !proc.pendingDelivered.length) return;
|
|
1841
|
+
if (!(pendingDeliveredTimeoutMs > 0)) return; // 0 = watchdog disabled
|
|
1842
|
+
// A silent cold boot (no init yet) is slow, not wedged: a CLI that never
|
|
1843
|
+
// inits dies on its own and cleanupProcess rejects the contexts then.
|
|
1844
|
+
if (!proc.sawInit) return;
|
|
1845
|
+
if (proc.activeCtx || proc.liveTasks.size || proc.pendingControlRequests > 0 || isCompacting()) {
|
|
1846
|
+
// A turn, task, control round-trip, or compaction may still attach these
|
|
1847
|
+
// entries — the unattached clock starts over once the process actually
|
|
1848
|
+
// goes quiet. A compaction is silent work, not idleness: the CLI replays
|
|
1849
|
+
// the turn these entries belong to once it finishes.
|
|
1850
|
+
for (const entry of proc.pendingDelivered) entry.unattachedSince = 0;
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
const stale = [];
|
|
1854
|
+
proc.pendingDelivered = proc.pendingDelivered.filter((entry) => {
|
|
1855
|
+
if (!entry.unattachedSince) {
|
|
1856
|
+
entry.unattachedSince = now;
|
|
1857
|
+
return true;
|
|
1858
|
+
}
|
|
1859
|
+
if (now - entry.unattachedSince < pendingDeliveredTimeoutMs) return true;
|
|
1860
|
+
stale.push(entry);
|
|
1861
|
+
return false;
|
|
1862
|
+
});
|
|
1863
|
+
for (const entry of stale) {
|
|
1864
|
+
dbg('pending delivered watchdog fired', entry.ctx.message?.id || '(no id)');
|
|
1865
|
+
entry.ctx.finalized = true;
|
|
1866
|
+
entry.ctx.rejectDone?.(new Error(
|
|
1867
|
+
`claude worker watchdog: the CLI never opened a turn for this message within ${Math.round(pendingDeliveredTimeoutMs / 1000)}s of going idle (absorbed or dropped); the row is failed — resend the message to retry`,
|
|
1868
|
+
));
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
function evaluateLifecycle() {
|
|
1873
|
+
if (!proc || proc.closing) return;
|
|
1874
|
+
const now = Date.now();
|
|
1875
|
+
reapSilentProvisionalAdoption(now);
|
|
1876
|
+
reapStalePendingDelivered(now);
|
|
1877
|
+
if (hasLiveWork()) {
|
|
1878
|
+
// Track how long background tasks alone have held the process, for the
|
|
1879
|
+
// user-configurable timeout (0 = unlimited).
|
|
1880
|
+
const heldByTasksOnly = !proc.activeCtx && !proc.pendingDelivered.length && proc.liveTasks.size > 0;
|
|
1881
|
+
if (heldByTasksOnly) {
|
|
1882
|
+
if (!proc.heldForTasksSince) proc.heldForTasksSince = now;
|
|
1883
|
+
const timeoutMs = Number(getBackgroundTaskTimeoutMs()) || 0;
|
|
1884
|
+
if (timeoutMs > 0 && now - proc.heldForTasksSince >= timeoutMs) {
|
|
1885
|
+
expireBackgroundTasks(timeoutMs).catch(() => {});
|
|
1886
|
+
}
|
|
1887
|
+
// Hard backstop: stopTask only *requests* the stops. If the CLI
|
|
1888
|
+
// still reports the tasks live well past the expiry, drop them from
|
|
1889
|
+
// the local set so the hold releases and the normal idle wind-down
|
|
1890
|
+
// (endInput → CLI exit) enforces the timeout for real.
|
|
1891
|
+
if (proc.tasksExpired && proc.tasksExpiredAt && now - proc.tasksExpiredAt >= 60_000) {
|
|
1892
|
+
dbg('background tasks still live after expiry; forcing the hold release', [...proc.liveTasks.keys()].join(','));
|
|
1893
|
+
proc.liveTasks = new Map();
|
|
1894
|
+
publishBackgroundTasks();
|
|
1895
|
+
}
|
|
1896
|
+
} else {
|
|
1897
|
+
proc.heldForTasksSince = 0;
|
|
1898
|
+
}
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
proc.heldForTasksSince = 0;
|
|
1902
|
+
if (now - proc.lastEventAt >= idleShutdownMs) {
|
|
1903
|
+
dbg('claude session process idling out', sdkSessionId.slice(0, 8));
|
|
1904
|
+
gracefulShutdown('idle');
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
async function expireBackgroundTasks(timeoutMs) {
|
|
1909
|
+
if (!proc || proc.tasksExpired) return;
|
|
1910
|
+
proc.tasksExpired = true;
|
|
1911
|
+
proc.tasksExpiredAt = Date.now();
|
|
1912
|
+
dbg('background task timeout reached', `${Math.round(timeoutMs / 60000)}min`, [...proc.liveTasks.keys()].join(','));
|
|
1913
|
+
for (const taskId of proc.liveTasks.keys()) {
|
|
1914
|
+
// Each stop emits a task_notification (status 'stopped') — the CLI's own
|
|
1915
|
+
// continuation turn is what tells the user, through the normal path.
|
|
1916
|
+
await Promise.resolve(proc.turn.stopTask?.(taskId)).catch((error) => {
|
|
1917
|
+
dbg('stopTask failed', taskId, error?.message || String(error));
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
function gracefulShutdown(reason) {
|
|
1923
|
+
if (!proc || proc.closing) return;
|
|
1924
|
+
proc.closing = true;
|
|
1925
|
+
dbg('releasing claude session process', reason);
|
|
1926
|
+
try {
|
|
1927
|
+
proc.turn.endInput?.();
|
|
1928
|
+
} catch {}
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
function stopLifecycleTimer(processRef) {
|
|
1932
|
+
if (processRef.lifecycleTimer) {
|
|
1933
|
+
clearInterval(processRef.lifecycleTimer);
|
|
1934
|
+
processRef.lifecycleTimer = null;
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
async function cleanupProcess(processRef, streamError) {
|
|
1939
|
+
const wasCurrent = proc === processRef;
|
|
1940
|
+
stopLifecycleTimer(processRef);
|
|
1941
|
+
if (processRef.taskPublishTimer) {
|
|
1942
|
+
clearTimeout(processRef.taskPublishTimer);
|
|
1943
|
+
processRef.taskPublishTimer = null;
|
|
1944
|
+
}
|
|
1945
|
+
stopWorkflowPoller(processRef);
|
|
1946
|
+
// The process took its background tasks with it; clear the panel — but
|
|
1947
|
+
// only when this process still owns it. A superseded process clearing the
|
|
1948
|
+
// panel would blank the replacement's live task set.
|
|
1949
|
+
if (processRef.liveTasks.size) {
|
|
1950
|
+
processRef.liveTasks = new Map();
|
|
1951
|
+
if (wasCurrent) {
|
|
1952
|
+
api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks: [] }).catch(() => {});
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
const openContexts = [
|
|
1956
|
+
...(processRef.activeCtx ? [processRef.activeCtx] : []),
|
|
1957
|
+
...processRef.pendingDelivered.map((entry) => entry.ctx),
|
|
1958
|
+
];
|
|
1959
|
+
processRef.pendingDelivered = [];
|
|
1960
|
+
processRef.activeCtx = null;
|
|
1961
|
+
if (proc === processRef) proc = null;
|
|
1962
|
+
for (const ctx of openContexts) {
|
|
1963
|
+
if (ctx.finalized) continue;
|
|
1964
|
+
if (streamError && !processRef.aborted && !ctx.interrupted) {
|
|
1965
|
+
controlPoller?.stop?.(ctx.controlState);
|
|
1966
|
+
ctx.finalized = true;
|
|
1967
|
+
if (ctx.kind === 'delivered') {
|
|
1968
|
+
// handlePendingPayload's catch publishes the turn-failed response,
|
|
1969
|
+
// preserving the per-turn runner's error contract.
|
|
1970
|
+
ctx.rejectDone?.(streamError);
|
|
1971
|
+
} else if (ctx.registered && ctx.message?.id) {
|
|
1972
|
+
await publisher.publishTurnException({
|
|
1973
|
+
message: ctx.message,
|
|
1974
|
+
errorText: String(streamError?.message || streamError || 'unknown error'),
|
|
1975
|
+
}).catch(() => {});
|
|
1976
|
+
}
|
|
1977
|
+
continue;
|
|
1978
|
+
}
|
|
1979
|
+
await finalizeContextOnStreamEnd(ctx, {
|
|
1980
|
+
aborted: processRef.aborted,
|
|
1981
|
+
model: processRef.model,
|
|
1982
|
+
}).catch(() => {});
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
// ---------------------------------------------------------------------------
|
|
1987
|
+
// Spawn / adapt
|
|
1988
|
+
|
|
1989
|
+
function spawnProcess(message) {
|
|
1990
|
+
const relayMode = message.relayMode || 'agent';
|
|
1991
|
+
const model = resolvePerTurnModel(message);
|
|
1992
|
+
const effort = normalizeClaudeEffort(message.reasoningEffort);
|
|
1993
|
+
const autoCompactWindow = normalizeAutoCompactWindow(getAutoCompactWindow());
|
|
1994
|
+
const thinkingState = getThinking() || {};
|
|
1995
|
+
const thinkingEnabled = parseThinkingEnabled(thinkingState.enabled);
|
|
1996
|
+
const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
|
|
1997
|
+
const abortController = new AbortController();
|
|
1998
|
+
const processRef = {
|
|
1999
|
+
turn: null,
|
|
2000
|
+
abortController,
|
|
2001
|
+
model,
|
|
2002
|
+
relayMode,
|
|
2003
|
+
effort,
|
|
2004
|
+
autoCompactWindow,
|
|
2005
|
+
// Seeded from what the process is SPAWNED with, so the adapt diff is
|
|
2006
|
+
// against reality rather than against a delivered setting the CLI may
|
|
2007
|
+
// never have honored.
|
|
2008
|
+
thinkingEnabled,
|
|
2009
|
+
thinkingDisplay,
|
|
2010
|
+
// No budget pin exists today, but `setMaxThinkingTokens`' first
|
|
2011
|
+
// argument is positional and re-sent on every display change — thread
|
|
2012
|
+
// the tracked value so a future budget feature cannot be silently
|
|
2013
|
+
// cleared by a display toggle.
|
|
2014
|
+
thinkingBudget: null,
|
|
2015
|
+
appendClass: modeAppendClass(relayMode),
|
|
2016
|
+
permissionMode: permissionModeForRelayMode(relayMode),
|
|
2017
|
+
liveTasks: new Map(),
|
|
2018
|
+
knownTasks: new Set(),
|
|
2019
|
+
// taskId → { runId, labels, digestJson } for live local_workflow tasks;
|
|
2020
|
+
// pruned by syncWorkflowPoller when a task leaves the live set.
|
|
2021
|
+
workflowStates: new Map(),
|
|
2022
|
+
// taskId → final digest of a workflow that settled, waiting to ride the
|
|
2023
|
+
// next /api/response publish as `workflowRuns` (the transcript's
|
|
2024
|
+
// "Finished background task" card). Capped at 5, drained on attach; if
|
|
2025
|
+
// the process dies before a response publishes, these are lost (v1).
|
|
2026
|
+
settledWorkflowDigests: new Map(),
|
|
2027
|
+
// The freshest native session id the process itself has seen (seeded
|
|
2028
|
+
// from `resume`, updated on init) — the workflow poller's key into
|
|
2029
|
+
// `~/.claude/projects`.
|
|
2030
|
+
nativeSessionId: '',
|
|
2031
|
+
// tool_use id → explicitly pinned model of a subagent spawn block; the
|
|
2032
|
+
// task panel's model column resolves through this at publish time.
|
|
2033
|
+
subagentSpawns: new Map(),
|
|
2034
|
+
initModel: '',
|
|
2035
|
+
// Set when the CLI reported a session-scoped refusal fallback; the next
|
|
2036
|
+
// delivered turn repins the composer's model instead of trusting
|
|
2037
|
+
// `model !== proc.model` (the relay's request never changed — the CLI
|
|
2038
|
+
// drifted underneath it).
|
|
2039
|
+
modelFallback: '',
|
|
2040
|
+
notificationPendingAt: 0,
|
|
2041
|
+
// When the CLI last said it was compacting (0 = not) and how long a
|
|
2042
|
+
// delivered entry may still adopt the turn the CLI re-opens afterwards.
|
|
2043
|
+
compactingSince: 0,
|
|
2044
|
+
compactReplayUntil: 0,
|
|
2045
|
+
// A non-spawn init that opened a turn while a delivered row waited: the
|
|
2046
|
+
// turn is the CLI's own, so no compaction inside it may adopt that row.
|
|
2047
|
+
continuationInitPending: false,
|
|
2048
|
+
pendingActivities: [],
|
|
2049
|
+
sawInit: false,
|
|
2050
|
+
handoffSettling: null,
|
|
2051
|
+
activeCtx: null,
|
|
2052
|
+
pendingDelivered: [],
|
|
2053
|
+
lastBoundary: null,
|
|
2054
|
+
pendingControlRequests: 0,
|
|
2055
|
+
lastEventAt: Date.now(),
|
|
2056
|
+
heldForTasksSince: 0,
|
|
2057
|
+
taskSettledAt: 0,
|
|
2058
|
+
tasksExpired: false,
|
|
2059
|
+
tasksExpiredAt: 0,
|
|
2060
|
+
aborted: false,
|
|
2061
|
+
closing: false,
|
|
2062
|
+
lifecycleTimer: null,
|
|
2063
|
+
taskPublishTimer: null,
|
|
2064
|
+
workflowPollTimer: null,
|
|
2065
|
+
consumer: null,
|
|
2066
|
+
};
|
|
2067
|
+
|
|
2068
|
+
const askUserBridge = createAskUserBridge({
|
|
2069
|
+
api,
|
|
2070
|
+
sdkSessionId,
|
|
2071
|
+
getActiveMessage: () => processRef.activeCtx?.message || processRef.pendingDelivered[0]?.ctx.message || null,
|
|
2072
|
+
dbg,
|
|
2073
|
+
...askUserBridgeOptions,
|
|
2074
|
+
});
|
|
2075
|
+
const baseCanUseTool = createCanUseTool({
|
|
2076
|
+
askUserBridge,
|
|
2077
|
+
dbg,
|
|
2078
|
+
onExitPlanMode: async (input) => {
|
|
2079
|
+
const ctx = processRef.activeCtx || processRef.pendingDelivered[0]?.ctx || null;
|
|
2080
|
+
if (!ctx?.message?.id) return false;
|
|
2081
|
+
const posted = await publisher.publishPlanBoard(ctx.message, input);
|
|
2082
|
+
if (posted) ctx.planBoardPosted = true;
|
|
2083
|
+
return posted;
|
|
2084
|
+
},
|
|
2085
|
+
});
|
|
2086
|
+
const canUseTool = async (toolName, input, options) => {
|
|
2087
|
+
// In-flight canUseTool round-trips (AskUserQuestion, permission prompts)
|
|
2088
|
+
// pin the process: a pending question produces no stream traffic while
|
|
2089
|
+
// the human thinks, and an idle shutdown under it would reject the
|
|
2090
|
+
// request with "Tool permission stream closed".
|
|
2091
|
+
processRef.pendingControlRequests += 1;
|
|
2092
|
+
try {
|
|
2093
|
+
// A question from a background agent between turns has no active turn
|
|
2094
|
+
// to attach to, and /api/relay-question requires a processing queue
|
|
2095
|
+
// row — without one the bridge 409s and the question is silently
|
|
2096
|
+
// denied. Register a continuation turn first: it is born processing,
|
|
2097
|
+
// gives the card a real queue row, and the flow's eventual top-level
|
|
2098
|
+
// result (or stream end) closes it like any other continuation.
|
|
2099
|
+
if (
|
|
2100
|
+
toolName === 'AskUserQuestion'
|
|
2101
|
+
&& proc === processRef
|
|
2102
|
+
&& !processRef.activeCtx
|
|
2103
|
+
&& !processRef.pendingDelivered.length
|
|
2104
|
+
) {
|
|
2105
|
+
const ctx = openContinuationContext();
|
|
2106
|
+
await waitForContextRegistration(ctx);
|
|
2107
|
+
}
|
|
2108
|
+
return await baseCanUseTool(toolName, input, options);
|
|
2109
|
+
} finally {
|
|
2110
|
+
processRef.pendingControlRequests -= 1;
|
|
2111
|
+
processRef.lastEventAt = Date.now();
|
|
2112
|
+
}
|
|
2113
|
+
};
|
|
2114
|
+
|
|
2115
|
+
const resume = String(message.claudeNativeSessionId || claudeNativeSessionId || '').trim();
|
|
2116
|
+
processRef.nativeSessionId = resume;
|
|
2117
|
+
// The CLI resolves `resume` inside the project directory for *this* CWD, so
|
|
2118
|
+
// a session whose workspace root changed has to bring its transcript along
|
|
2119
|
+
// or every turn from here on fails with "No conversation found".
|
|
2120
|
+
if (resume) relocateTranscriptImpl({ nativeSessionId: resume, cwd, dbg });
|
|
2121
|
+
|
|
2122
|
+
processRef.turn = startClaudeSessionImpl({
|
|
2123
|
+
content: null,
|
|
2124
|
+
cwd,
|
|
2125
|
+
model,
|
|
2126
|
+
resume,
|
|
2127
|
+
relayMode,
|
|
2128
|
+
reasoningEffort: effort,
|
|
2129
|
+
autoCompactWindow,
|
|
2130
|
+
thinkingEnabled,
|
|
2131
|
+
thinkingDisplay,
|
|
2132
|
+
abortController,
|
|
2133
|
+
canUseTool,
|
|
2134
|
+
pathToClaudeCodeExecutable,
|
|
2135
|
+
api,
|
|
2136
|
+
// Resolved per tool call: one CLI process serves many turns, and a
|
|
2137
|
+
// continuation's row can land on a different conversation than the
|
|
2138
|
+
// worker's session id.
|
|
2139
|
+
getConversationId: () => String(
|
|
2140
|
+
processRef.activeCtx?.message?.conversationId
|
|
2141
|
+
|| processRef.pendingDelivered[0]?.ctx.message?.conversationId
|
|
2142
|
+
|| sdkSessionId
|
|
2143
|
+
|| '',
|
|
2144
|
+
),
|
|
2145
|
+
dbg,
|
|
2146
|
+
});
|
|
2147
|
+
processRef.lifecycleTimer = setInterval(() => evaluateLifecycle(), lifecyclePollMs);
|
|
2148
|
+
processRef.lifecycleTimer.unref?.();
|
|
2149
|
+
processRef.consumer = (async () => {
|
|
2150
|
+
let streamError = null;
|
|
2151
|
+
try {
|
|
2152
|
+
for await (const sdkMessage of processRef.turn) {
|
|
2153
|
+
await onSdkMessage(processRef, sdkMessage);
|
|
2154
|
+
}
|
|
2155
|
+
} catch (error) {
|
|
2156
|
+
if (!processRef.aborted && !abortController.signal.aborted) streamError = error;
|
|
2157
|
+
} finally {
|
|
2158
|
+
try {
|
|
2159
|
+
processRef.turn.endInput?.();
|
|
2160
|
+
} catch {}
|
|
2161
|
+
await cleanupProcess(processRef, streamError);
|
|
2162
|
+
}
|
|
2163
|
+
})();
|
|
2164
|
+
proc = processRef;
|
|
2165
|
+
return processRef;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
async function adaptProcess(message) {
|
|
2169
|
+
const relayMode = message.relayMode || 'agent';
|
|
2170
|
+
const model = resolvePerTurnModel(message);
|
|
2171
|
+
const effort = normalizeClaudeEffort(message.reasoningEffort);
|
|
2172
|
+
|
|
2173
|
+
// A mode change that would alter the spawn-time system prompt append gets
|
|
2174
|
+
// a fresh process — but only when nothing lives in the old one.
|
|
2175
|
+
if (modeAppendClass(relayMode) !== proc.appendClass && !hasLiveWork() && !proc.liveTasks.size) {
|
|
2176
|
+
const previous = proc;
|
|
2177
|
+
gracefulShutdown('mode-change');
|
|
2178
|
+
await Promise.race([
|
|
2179
|
+
previous.consumer,
|
|
2180
|
+
new Promise((resolve) => setTimeout(resolve, 10_000)),
|
|
2181
|
+
]);
|
|
2182
|
+
if (proc === previous) {
|
|
2183
|
+
// The drain timed out: force the old CLI down so it cannot keep
|
|
2184
|
+
// streaming next to the replacement the caller is about to spawn.
|
|
2185
|
+
try { previous.turn.close?.(); } catch {}
|
|
2186
|
+
proc = null;
|
|
2187
|
+
}
|
|
2188
|
+
return null;
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
const permissionMode = permissionModeForRelayMode(relayMode);
|
|
2192
|
+
if (permissionMode !== proc.permissionMode) {
|
|
2193
|
+
await Promise.resolve(proc.turn.setPermissionMode?.(permissionMode)).catch((error) => {
|
|
2194
|
+
dbg('setPermissionMode failed', error?.message || String(error));
|
|
2195
|
+
});
|
|
2196
|
+
proc.permissionMode = permissionMode;
|
|
2197
|
+
}
|
|
2198
|
+
// A falsy model/effort is a real setting too: switching the composer back
|
|
2199
|
+
// to `auto` (model '') or effort `none` must reset the live process to the
|
|
2200
|
+
// SDK default — setModel(undefined) and effortLevel null exist for exactly
|
|
2201
|
+
// that. The old truthiness gate left the previous pin in place forever.
|
|
2202
|
+
// A session-scoped refusal fallback moved the CLI off the pinned model
|
|
2203
|
+
// without the relay's request ever changing, so `model !== proc.model`
|
|
2204
|
+
// alone can never see the drift — a pinned (non-auto) model is re-asserted
|
|
2205
|
+
// on the next delivered turn. The flagged request itself stays on the
|
|
2206
|
+
// fallback (that retry already happened, by API policy); this only stops
|
|
2207
|
+
// the whole session from silently staying there. An `auto` composer
|
|
2208
|
+
// (model '') keeps whatever the CLI chose.
|
|
2209
|
+
const repinAfterFallback = Boolean(proc.modelFallback) && Boolean(model);
|
|
2210
|
+
if (model !== proc.model || repinAfterFallback) {
|
|
2211
|
+
await Promise.resolve(proc.turn.setModel?.(model || undefined)).catch((error) => {
|
|
2212
|
+
dbg('setModel failed', error?.message || String(error));
|
|
2213
|
+
});
|
|
2214
|
+
proc.model = model;
|
|
2215
|
+
if (model) proc.modelFallback = '';
|
|
2216
|
+
}
|
|
2217
|
+
if (effort !== proc.effort) {
|
|
2218
|
+
// 'ultracode' is not an effortLevel the CLI accepts — it maps to the
|
|
2219
|
+
// session-scoped ultracode/enableWorkflows flags (with the effort pinned
|
|
2220
|
+
// to the xhigh the flag implies); every other change clears those flags
|
|
2221
|
+
// alongside the new effortLevel.
|
|
2222
|
+
await Promise.resolve(proc.turn.applyFlagSettings?.(claudeUltracodeFlagSettings(effort))).catch((error) => {
|
|
2223
|
+
dbg('applyFlagSettings effort failed', error?.message || String(error));
|
|
2224
|
+
});
|
|
2225
|
+
proc.effort = effort;
|
|
2226
|
+
}
|
|
2227
|
+
// The window is a delivery-scoped setting, not a per-message one: it only
|
|
2228
|
+
// reaches a live process through the flag layer, and `null` (Auto) has to
|
|
2229
|
+
// be pushed too or clearing the slider would never take effect.
|
|
2230
|
+
const autoCompactWindow = normalizeAutoCompactWindow(getAutoCompactWindow());
|
|
2231
|
+
if (autoCompactWindow !== proc.autoCompactWindow) {
|
|
2232
|
+
await Promise.resolve(proc.turn.applyFlagSettings?.(claudeAutoCompactFlagSettings(autoCompactWindow))).catch((error) => {
|
|
2233
|
+
dbg('applyFlagSettings autoCompactWindow failed', error?.message || String(error));
|
|
2234
|
+
});
|
|
2235
|
+
proc.autoCompactWindow = autoCompactWindow;
|
|
2236
|
+
}
|
|
2237
|
+
// Thinking is NOT a symmetric copy of the window block: the probe
|
|
2238
|
+
// (2026-08-26, docs/plans/claude-thinking-control.md) showed the two
|
|
2239
|
+
// directions of the on/off axis are not equally reachable mid-session.
|
|
2240
|
+
const thinkingState = getThinking() || {};
|
|
2241
|
+
const thinkingEnabled = parseThinkingEnabled(thinkingState.enabled);
|
|
2242
|
+
const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
|
|
2243
|
+
let reassertDisplay = false;
|
|
2244
|
+
if (thinkingEnabled !== proc.thinkingEnabled) {
|
|
2245
|
+
if (thinkingEnabled === true) {
|
|
2246
|
+
// Enabling applies live. The display MUST be re-sent afterwards, in
|
|
2247
|
+
// this order: a session spawned with thinking off carries no display
|
|
2248
|
+
// mode, so enabling without it yields visible-but-empty thinking.
|
|
2249
|
+
await Promise.resolve(proc.turn.applyFlagSettings?.(claudeThinkingFlagSettings(true))).catch((error) => {
|
|
2250
|
+
dbg('applyFlagSettings thinkingEnabled failed', error?.message || String(error));
|
|
2251
|
+
});
|
|
2252
|
+
proc.thinkingEnabled = true;
|
|
2253
|
+
reassertDisplay = true;
|
|
2254
|
+
} else {
|
|
2255
|
+
// Disabling: the CLI accepts the flag
|
|
2256
|
+
// call and silently ignores it (probe 2026-08-26), so both sending it
|
|
2257
|
+
// and recording it as applied would be lies — the second would desync
|
|
2258
|
+
// `proc.thinkingEnabled` from reality and make the next enable a
|
|
2259
|
+
// no-op diff. Nothing needs to be remembered here: the request is not
|
|
2260
|
+
// lost, because `spawnProcess` re-reads `getThinking()` and the next
|
|
2261
|
+
// process is born with it. Deliberately no bookkeeping field — an
|
|
2262
|
+
// earlier revision carried one that nothing ever read.
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
if (thinkingDisplay !== proc.thinkingDisplay || reassertDisplay) {
|
|
2266
|
+
await applyThinkingDisplay(proc.turn, thinkingDisplay, { budget: proc.thinkingBudget, dbg });
|
|
2267
|
+
proc.thinkingDisplay = thinkingDisplay;
|
|
2268
|
+
}
|
|
2269
|
+
proc.relayMode = relayMode;
|
|
2270
|
+
return proc;
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
// ---------------------------------------------------------------------------
|
|
2274
|
+
// Relay contract
|
|
2275
|
+
|
|
2276
|
+
async function handlePendingPayload(pending) {
|
|
2277
|
+
const message = pending?.message || null;
|
|
2278
|
+
if (!message) return false;
|
|
2279
|
+
try {
|
|
2280
|
+
if (proc && !proc.closing && !proc.aborted) {
|
|
2281
|
+
await adaptProcess(message);
|
|
2282
|
+
}
|
|
2283
|
+
if (!proc || proc.closing || proc.aborted) {
|
|
2284
|
+
// A closing process drains on its own; wait so two CLIs never share
|
|
2285
|
+
// the native session transcript.
|
|
2286
|
+
const previous = proc;
|
|
2287
|
+
if (previous) {
|
|
2288
|
+
await Promise.race([
|
|
2289
|
+
previous.consumer,
|
|
2290
|
+
new Promise((resolve) => setTimeout(resolve, 15_000)),
|
|
2291
|
+
]);
|
|
2292
|
+
if (proc === previous) {
|
|
2293
|
+
try { previous.turn.close?.(); } catch {}
|
|
2294
|
+
proc = null;
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
spawnProcess(message);
|
|
2298
|
+
// A cold start is the one moment a turn can sit silent for many
|
|
2299
|
+
// seconds (transcript load + first request); say so instead of
|
|
2300
|
+
// showing bare dots. Posted once here — not in spawnProcess — so a
|
|
2301
|
+
// push-race respawn cannot duplicate the line.
|
|
2302
|
+
publisher.postActivity(message, (message.claudeNativeSessionId || claudeNativeSessionId)
|
|
2303
|
+
? 'Starting the Claude CLI (cold start — resuming the session transcript)…'
|
|
2304
|
+
: 'Starting the Claude CLI (cold start)…');
|
|
2305
|
+
}
|
|
2306
|
+
const content = buildClaudeUserContent(message);
|
|
2307
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
2308
|
+
const procRef = proc;
|
|
2309
|
+
const ctx = createDeliveredContext(message);
|
|
2310
|
+
procRef.pendingDelivered.push({ ctx, expectedText: contentText(content), pushedAt: Date.now() });
|
|
2311
|
+
try {
|
|
2312
|
+
procRef.turn.pushUserMessage(content);
|
|
2313
|
+
} catch (pushError) {
|
|
2314
|
+
// The stream ended under us (process wound down between the
|
|
2315
|
+
// liveness check and the push): retire this context and respawn.
|
|
2316
|
+
procRef.pendingDelivered = procRef.pendingDelivered.filter((entry) => entry.ctx !== ctx);
|
|
2317
|
+
dbg('push raced process teardown', pushError?.message || String(pushError));
|
|
2318
|
+
if (proc === procRef) proc = null;
|
|
2319
|
+
// Belt and braces: the stream was already ending, but make sure the
|
|
2320
|
+
// superseded CLI cannot linger next to its replacement.
|
|
2321
|
+
try { procRef.turn.close?.(); } catch {}
|
|
2322
|
+
spawnProcess(message);
|
|
2323
|
+
continue;
|
|
2324
|
+
}
|
|
2325
|
+
return await ctx.done;
|
|
2326
|
+
}
|
|
2327
|
+
throw new Error('claude session process closed while accepting the message');
|
|
2328
|
+
} catch (error) {
|
|
2329
|
+
const errorText = String(error?.message || error || 'unknown error');
|
|
2330
|
+
dbg('claude turn failed', message.id, errorText);
|
|
2331
|
+
await publisher.publishTurnException({ message, errorText });
|
|
2332
|
+
return true;
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
async function shutdown({ graceful = true } = {}) {
|
|
2337
|
+
const processRef = proc;
|
|
2338
|
+
if (!processRef) return;
|
|
2339
|
+
if (graceful && !hasLiveWork()) {
|
|
2340
|
+
gracefulShutdown('worker-shutdown');
|
|
2341
|
+
await Promise.race([
|
|
2342
|
+
processRef.consumer,
|
|
2343
|
+
new Promise((resolve) => setTimeout(resolve, 5_000)),
|
|
2344
|
+
]);
|
|
2345
|
+
}
|
|
2346
|
+
try { processRef.turn.close?.(); } catch {}
|
|
2347
|
+
try { processRef.abortController.abort(); } catch {}
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
/** Stop one live background task (the panel's per-task Stop button). */
|
|
2351
|
+
async function stopBackgroundTask(taskId) {
|
|
2352
|
+
const normalized = String(taskId || '').trim();
|
|
2353
|
+
if (!normalized || !proc?.turn?.stopTask) return false;
|
|
2354
|
+
try {
|
|
2355
|
+
await proc.turn.stopTask(normalized);
|
|
2356
|
+
return true;
|
|
2357
|
+
} catch (error) {
|
|
2358
|
+
dbg('stopTask failed', normalized, error?.message || String(error));
|
|
2359
|
+
return false;
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
/**
|
|
2364
|
+
* Targeted subagent stop: the relay's subagentRunId IS the spawning
|
|
2365
|
+
* tool_use id, and task_started/task_progress report the same id per task —
|
|
2366
|
+
* so a BACKGROUNDED subagent maps to a stoppable SDK task. In-turn
|
|
2367
|
+
* subagents have no task id and remain full-turn-Stop only.
|
|
2368
|
+
*/
|
|
2369
|
+
async function stopBackgroundTaskByToolUseId(toolUseId) {
|
|
2370
|
+
const normalized = String(toolUseId || '').trim();
|
|
2371
|
+
if (!normalized || !proc) return false;
|
|
2372
|
+
for (const [taskId, task] of proc.liveTasks.entries()) {
|
|
2373
|
+
if (String(task?.toolUseId || '').trim() === normalized) {
|
|
2374
|
+
return stopBackgroundTask(taskId);
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
return false;
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
return {
|
|
2381
|
+
handlePendingPayload,
|
|
2382
|
+
getActiveQueueMessageId,
|
|
2383
|
+
getActiveQueueMessageIds,
|
|
2384
|
+
isTurnActive,
|
|
2385
|
+
stopBackgroundTask,
|
|
2386
|
+
stopBackgroundTaskByToolUseId,
|
|
2387
|
+
shutdown,
|
|
2388
|
+
// Test seams / observability.
|
|
2389
|
+
_getProcess: () => proc,
|
|
2390
|
+
};
|
|
2391
|
+
}
|