mixdog 0.8.0 → 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/README.md +47 -23
- package/package.json +33 -27
- package/scripts/_test-folder-dialog.mjs +30 -0
- package/scripts/agent-parallel-smoke.mjs +388 -0
- package/scripts/agent-tag-reuse-smoke.mjs +183 -0
- package/scripts/background-task-meta-smoke.mjs +38 -0
- package/scripts/boot-smoke.mjs +52 -9
- package/scripts/build-runtime-linux.sh +348 -0
- package/scripts/build-runtime-macos.sh +217 -0
- package/scripts/build-runtime-windows.ps1 +242 -0
- package/scripts/compact-active-turn-test.mjs +68 -0
- package/scripts/compact-smoke.mjs +859 -129
- package/scripts/compact-trigger-migration-smoke.mjs +187 -0
- package/scripts/fix-brief-fn.mjs +35 -0
- package/scripts/fix-format-tool-surface.mjs +24 -0
- package/scripts/fix-tool-exec-visible.mjs +42 -0
- package/scripts/generate-runtime-manifest.mjs +166 -0
- package/scripts/hook-bus-test.mjs +330 -0
- package/scripts/lead-workflow-smoke.mjs +33 -39
- package/scripts/live-worker-smoke.mjs +43 -37
- package/scripts/llm-trace-summary.mjs +315 -0
- package/scripts/memory-meta-concurrency-test.mjs +20 -0
- package/scripts/output-style-smoke.mjs +56 -15
- package/scripts/parent-abort-link-test.mjs +44 -0
- package/scripts/patch-agent-brief.mjs +48 -0
- package/scripts/patch-app.mjs +21 -0
- package/scripts/patch-app2.mjs +18 -0
- package/scripts/patch-dist-brief.mjs +96 -0
- package/scripts/patch-tool-exec.mjs +70 -0
- package/scripts/pretool-ask-runtime-test.mjs +54 -0
- package/scripts/provider-toolcall-test.mjs +376 -0
- package/scripts/reactive-compact-persist-smoke.mjs +124 -0
- package/scripts/sanitize-tool-pairs-test.mjs +260 -0
- package/scripts/session-context-bench.mjs +344 -0
- package/scripts/session-ingest-smoke.mjs +177 -0
- package/scripts/set-effort-config-test.mjs +41 -0
- package/scripts/smoke-runtime-negative.ps1 +106 -0
- package/scripts/smoke-runtime-negative.sh +97 -0
- package/scripts/smoke.mjs +25 -0
- package/scripts/tool-result-hook-test.mjs +48 -0
- package/scripts/tool-smoke.mjs +1223 -95
- package/scripts/toolcall-args-test.mjs +150 -0
- package/scripts/tui-background-failure-smoke.mjs +73 -0
- package/scripts/usage-metrics-epoch-smoke.mjs +114 -0
- package/src/agents/debugger/AGENT.md +8 -0
- package/src/agents/explore/AGENT.md +4 -0
- package/src/agents/heavy-worker/AGENT.md +9 -3
- package/src/agents/maintainer/AGENT.md +4 -0
- package/src/agents/reviewer/AGENT.md +8 -0
- package/src/agents/scheduler-task/AGENT.md +12 -0
- package/src/agents/scheduler-task/agent.json +6 -0
- package/src/agents/webhook-handler/AGENT.md +12 -0
- package/src/agents/webhook-handler/agent.json +6 -0
- package/src/agents/worker/AGENT.md +9 -3
- package/src/app.mjs +77 -3
- package/src/defaults/hidden-roles.json +17 -12
- package/src/headless-role.mjs +117 -0
- package/src/help.mjs +30 -0
- package/src/hooks/lib/permission-evaluator.cjs +11 -475
- package/src/lib/keychain-cjs.cjs +9 -1
- package/src/lib/mixdog-debug.cjs +0 -7
- package/src/lib/rules-builder.cjs +240 -96
- package/src/lib/text-utils.cjs +1 -1
- package/src/mixdog-session-runtime.mjs +2325 -450
- package/src/output-styles/default.md +12 -28
- package/src/output-styles/extreme-simple.md +9 -6
- package/src/output-styles/simple.md +22 -9
- package/src/repl.mjs +118 -59
- package/src/rules/agent/00-common.md +15 -0
- package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
- package/src/rules/agent/30-explorer.md +22 -0
- package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
- package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
- package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
- package/src/rules/lead/01-general.md +9 -5
- package/src/rules/lead/04-workflow.md +51 -12
- package/src/rules/lead/lead-tool.md +6 -0
- package/src/rules/shared/01-tool.md +12 -1
- package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
- package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
- package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
- package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
- package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
- package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
- package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
- package/src/runtime/agent/orchestrator/config.mjs +174 -55
- package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
- package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
- package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
- package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
- package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +377 -243
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +146 -93
- package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
- package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +221 -70
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +477 -147
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -496
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +31 -11
- package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
- package/src/runtime/agent/orchestrator/session/cache/post-edit-marks.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
- package/src/runtime/agent/orchestrator/session/loop.mjs +851 -674
- package/src/runtime/agent/orchestrator/session/manager.mjs +1593 -466
- package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +291 -46
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
- package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
- package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +34 -7
- package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +9 -9
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
- package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
- package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
- package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
- package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
- package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
- package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
- package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
- package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
- package/src/runtime/channels/index.mjs +152 -24
- package/src/runtime/channels/lib/scheduler.mjs +18 -14
- package/src/runtime/channels/lib/session-discovery.mjs +3 -2
- package/src/runtime/channels/lib/tool-format.mjs +0 -2
- package/src/runtime/channels/lib/transcript-discovery.mjs +3 -2
- package/src/runtime/channels/lib/webhook.mjs +1 -1
- package/src/runtime/channels/tool-defs.mjs +29 -29
- package/src/runtime/memory/index.mjs +635 -107
- package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
- package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
- package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
- package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
- package/src/runtime/memory/lib/memory-cycle1.mjs +38 -17
- package/src/runtime/memory/lib/memory-cycle2.mjs +6 -7
- package/src/runtime/memory/lib/memory-cycle3.mjs +4 -4
- package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -1
- package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
- package/src/runtime/memory/lib/memory.mjs +88 -9
- package/src/runtime/memory/lib/model-profile.mjs +1 -1
- package/src/runtime/memory/lib/pg/adapter.mjs +15 -1
- package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
- package/src/runtime/memory/lib/runtime-fetcher.mjs +37 -3
- package/src/runtime/memory/lib/session-ingest.mjs +194 -0
- package/src/runtime/memory/lib/trace-store.mjs +96 -51
- package/src/runtime/memory/tool-defs.mjs +46 -37
- package/src/runtime/search/index.mjs +102 -466
- package/src/runtime/search/lib/web-tools.mjs +45 -25
- package/src/runtime/search/tool-defs.mjs +16 -23
- package/src/runtime/shared/abort-controller.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +4 -3
- package/src/runtime/shared/background-tasks.mjs +122 -11
- package/src/runtime/shared/child-spawn-gate.mjs +145 -0
- package/src/runtime/shared/config.mjs +7 -4
- package/src/runtime/shared/err-text.mjs +131 -4
- package/src/runtime/shared/llm/cost.mjs +2 -2
- package/src/runtime/shared/llm/http-agent.mjs +23 -7
- package/src/runtime/shared/llm/index.mjs +34 -11
- package/src/runtime/shared/llm/usage-log.mjs +4 -4
- package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
- package/src/runtime/shared/singleton-owner.mjs +104 -0
- package/src/runtime/shared/tool-execution-contract.mjs +199 -20
- package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
- package/src/runtime/shared/tool-surface.mjs +624 -98
- package/src/runtime/shared/user-data-guard.mjs +0 -2
- package/src/standalone/agent-task-status.mjs +203 -0
- package/src/standalone/agent-task-status.test.mjs +76 -0
- package/src/standalone/agent-tool.mjs +1913 -0
- package/src/standalone/channel-worker.mjs +370 -14
- package/src/standalone/explore-tool.mjs +165 -70
- package/src/standalone/folder-dialog.mjs +314 -0
- package/src/standalone/hook-bus.mjs +898 -22
- package/src/standalone/memory-runtime-proxy.mjs +320 -0
- package/src/standalone/projects.mjs +226 -0
- package/src/standalone/provider-admin.mjs +41 -24
- package/src/standalone/seeds.mjs +2 -69
- package/src/standalone/usage-dashboard.mjs +96 -8
- package/src/tui/App.jsx +4798 -2153
- package/src/tui/components/AnsiText.jsx +39 -28
- package/src/tui/components/ContextPanel.jsx +87 -29
- package/src/tui/components/Markdown.jsx +43 -77
- package/src/tui/components/MarkdownTable.jsx +9 -184
- package/src/tui/components/Message.jsx +28 -11
- package/src/tui/components/Picker.jsx +95 -56
- package/src/tui/components/PromptInput.jsx +367 -239
- package/src/tui/components/QueuedCommands.jsx +1 -1
- package/src/tui/components/SlashCommandPalette.jsx +27 -21
- package/src/tui/components/Spinner.jsx +67 -38
- package/src/tui/components/StatusLine.jsx +606 -38
- package/src/tui/components/TextEntryPanel.jsx +128 -9
- package/src/tui/components/ToolExecution.jsx +617 -368
- package/src/tui/components/TurnDone.jsx +3 -3
- package/src/tui/components/UsagePanel.jsx +3 -5
- package/src/tui/components/tool-output-format.mjs +365 -0
- package/src/tui/components/tool-output-format.test.mjs +220 -0
- package/src/tui/dist/index.mjs +8915 -2418
- package/src/tui/engine-runtime-notification.test.mjs +115 -0
- package/src/tui/engine-tool-result-text.test.mjs +75 -0
- package/src/tui/engine.mjs +1455 -279
- package/src/tui/figures.mjs +21 -40
- package/src/tui/index.jsx +75 -31
- package/src/tui/input-editing.mjs +25 -0
- package/src/tui/markdown/format-token.mjs +511 -68
- package/src/tui/markdown/format-token.test.mjs +216 -0
- package/src/tui/markdown/render-ansi.mjs +94 -0
- package/src/tui/markdown/render-ansi.test.mjs +108 -0
- package/src/tui/markdown/stream-fence.mjs +34 -0
- package/src/tui/markdown/stream-fence.test.mjs +26 -0
- package/src/tui/markdown/table-layout.mjs +250 -0
- package/src/tui/paste-attachments.mjs +0 -7
- package/src/tui/spinner-verbs.mjs +1 -2
- package/src/tui/statusline-ansi-bridge.mjs +172 -0
- package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
- package/src/tui/theme.mjs +746 -24
- package/src/tui/time-format.mjs +1 -1
- package/src/tui/transcript-tool-failures.mjs +67 -0
- package/src/tui/transcript-tool-failures.test.mjs +111 -0
- package/src/ui/ansi.mjs +1 -2
- package/src/ui/markdown.mjs +85 -26
- package/src/ui/markdown.test.mjs +70 -0
- package/src/ui/model-display.mjs +121 -0
- package/src/ui/session-stats.mjs +44 -0
- package/src/ui/statusline-context-label.test.mjs +15 -0
- package/src/ui/statusline.mjs +386 -178
- package/src/ui/tool-card.mjs +3 -16
- package/src/vendor/statusline/bin/statusline-lib.mjs +8 -4
- package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
- package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
- package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
- package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
- package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
- package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
- package/src/workflows/default/WORKFLOW.md +12 -5
- package/src/workflows/default/workflow.json +0 -1
- package/src/workflows/solo/WORKFLOW.md +15 -0
- package/src/workflows/solo/workflow.json +7 -0
- package/vendor/ink/build/output.js +6 -1
- package/src/agents/scheduler-task.md +0 -3
- package/src/agents/web-researcher/AGENT.md +0 -3
- package/src/agents/web-researcher/agent.json +0 -6
- package/src/agents/webhook-handler.md +0 -3
- package/src/rules/bridge/00-common.md +0 -5
- package/src/rules/bridge/30-explorer.md +0 -4
- package/src/rules/lead/00-tool-lead.md +0 -5
- package/src/rules/shared/00-language.md +0 -3
- package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
- package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
- package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
- package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
- package/src/standalone/bridge-tool.mjs +0 -1414
- package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
|
@@ -0,0 +1,1913 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import {
|
|
5
|
+
cancelBackgroundTask,
|
|
6
|
+
cleanupBackgroundTasks,
|
|
7
|
+
getBackgroundTask,
|
|
8
|
+
listBackgroundTasks,
|
|
9
|
+
renderBackgroundTask,
|
|
10
|
+
reconcileBackgroundTask,
|
|
11
|
+
setBackgroundTaskEnqueueFallback,
|
|
12
|
+
startBackgroundTask,
|
|
13
|
+
sanitizeTaskMeta,
|
|
14
|
+
taskIdFromArgs,
|
|
15
|
+
} from '../runtime/shared/background-tasks.mjs';
|
|
16
|
+
import { modelVisibleToolCompletionMessage } from '../runtime/shared/tool-execution-contract.mjs';
|
|
17
|
+
import { presentErrorText } from '../runtime/shared/err-text.mjs';
|
|
18
|
+
import { updateJsonAtomicSync } from '../runtime/shared/atomic-file.mjs';
|
|
19
|
+
import {
|
|
20
|
+
normalizeAgentPermission,
|
|
21
|
+
normalizeAgentPermissionOrNone,
|
|
22
|
+
parseMarkdownFrontmatter,
|
|
23
|
+
} from '../runtime/shared/markdown-frontmatter.mjs';
|
|
24
|
+
import { prepareAgentSession } from '../runtime/agent/orchestrator/agent-runtime/session-builder.mjs';
|
|
25
|
+
import {
|
|
26
|
+
agentWatchdogPolicyActive,
|
|
27
|
+
evaluateAgentWatchdogAbort,
|
|
28
|
+
resolveAgentWatchdogPolicy,
|
|
29
|
+
} from '../runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs';
|
|
30
|
+
import {
|
|
31
|
+
appendAgentProgressKv,
|
|
32
|
+
buildAgentTaskProgressFields,
|
|
33
|
+
} from './agent-task-status.mjs';
|
|
34
|
+
import { AGENT_OWNER } from '../runtime/agent/orchestrator/agent-owner.mjs';
|
|
35
|
+
import { clearGatewaySessionRoute, writeGatewaySessionRoute } from '../vendor/statusline/src/gateway/session-routes.mjs';
|
|
36
|
+
|
|
37
|
+
const STANDALONE_SOURCE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
38
|
+
|
|
39
|
+
const PRESET_ALIASES = new Map([
|
|
40
|
+
['opus-xhigh', { base: 'opus-high', effort: 'xhigh', id: 'opus-xhigh', name: 'OPUS XHIGH' }],
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const DEFAULT_AGENT_PRESETS = Object.freeze({
|
|
44
|
+
explore: 'sonnet-high',
|
|
45
|
+
maintainer: 'haiku',
|
|
46
|
+
worker: 'sonnet-high',
|
|
47
|
+
'heavy-worker': 'opus-high',
|
|
48
|
+
reviewer: 'opus-xhigh',
|
|
49
|
+
debugger: 'opus-xhigh',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const AGENT_TOOL = {
|
|
53
|
+
name: 'agent',
|
|
54
|
+
title: 'Agent',
|
|
55
|
+
annotations: {
|
|
56
|
+
title: 'Agent',
|
|
57
|
+
readOnlyHint: false,
|
|
58
|
+
destructiveHint: true,
|
|
59
|
+
idempotentHint: false,
|
|
60
|
+
openWorldHint: true,
|
|
61
|
+
agentHidden: true,
|
|
62
|
+
},
|
|
63
|
+
description: 'Delegate scoped work. Agent handoffs always start background tasks and return task IDs immediately. Spawn independent agents in parallel with distinct tags; keep Lead work moving. Wait for the completion notification before dependent work. Do not call status/read after spawn; status/read are manual recovery only.',
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
properties: {
|
|
67
|
+
type: { type: 'string', enum: ['spawn', 'send', 'list', 'close', 'cancel', 'status', 'read', 'cleanup'], description: 'Action. Default spawn.' },
|
|
68
|
+
task_id: { type: 'string', description: 'Manual recovery task ID.' },
|
|
69
|
+
agent: { type: 'string', description: 'Workflow agent id.' },
|
|
70
|
+
tag: { type: 'string', description: 'Stable distinct handle; choose a role-index tag by role, e.g. worker01, heavy-worker01, reviewer01.' },
|
|
71
|
+
sessionId: { type: 'string', description: 'Raw sess_ id.' },
|
|
72
|
+
prompt: { type: 'string', description: 'Scoped task brief.' },
|
|
73
|
+
message: { type: 'string', description: 'Follow-up or brief.' },
|
|
74
|
+
file: { type: 'string', description: 'Prompt file.' },
|
|
75
|
+
cwd: { type: 'string', description: 'Working directory.' },
|
|
76
|
+
context: { type: 'string', description: 'Extra agent context.' },
|
|
77
|
+
},
|
|
78
|
+
additionalProperties: true,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const WORKER_INDEX_FILE = 'agent-workers.json';
|
|
83
|
+
function envTimeoutMs(name, fallback) {
|
|
84
|
+
const raw = process.env[name];
|
|
85
|
+
if (raw === undefined || raw === '') return fallback;
|
|
86
|
+
const n = Number(raw);
|
|
87
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Grace window during which a terminated/idle worker row is kept around so the
|
|
91
|
+
// same terminal can re-use (or cleanly re-spawn) the same tag. Cached as a
|
|
92
|
+
// constant like the other timeouts; override with MIXDOG_AGENT_TERMINAL_REAP_MS.
|
|
93
|
+
const TERMINAL_REAP_MS = envTimeoutMs('MIXDOG_AGENT_TERMINAL_REAP_MS', 60 * 60_000);
|
|
94
|
+
// Independent hard cap for the spawn *prep* phase (ensureProvider /
|
|
95
|
+
// prepareAgentSession / catalog+rules load). Kept separate from the
|
|
96
|
+
// first-response watchdog so prep cannot hang a whole fanout before the model
|
|
97
|
+
// request starts. Set MIXDOG_AGENT_SPAWN_PREP_TIMEOUT_MS=0 to fully disable the
|
|
98
|
+
// cap and restore strictly-unbounded prep.
|
|
99
|
+
const DEFAULT_SPAWN_PREP_TIMEOUT_MS = envTimeoutMs('MIXDOG_AGENT_SPAWN_PREP_TIMEOUT_MS', 120_000);
|
|
100
|
+
const ACTIVE_STAGES = new Set(['connecting', 'requesting', 'streaming', 'tool_running', 'running', 'cancelling']);
|
|
101
|
+
|
|
102
|
+
// Process-wide TTL cache for agent AGENT.md frontmatter permission. The file
|
|
103
|
+
// rarely changes, but readAgentFrontmatterPermission() otherwise pays several
|
|
104
|
+
// existsSync()+readFileSync() calls on EVERY spawn — multiplied across a
|
|
105
|
+
// parallel fanout that is pure redundant synchronous I/O on the event loop,
|
|
106
|
+
// which blocks every other concurrent spawn. Short TTL keeps edits picked up
|
|
107
|
+
// quickly while collapsing burst reads.
|
|
108
|
+
const _frontmatterPermCache = new Map(); // key -> { value, atMs }
|
|
109
|
+
const FRONTMATTER_PERM_CACHE_TTL_MS = envTimeoutMs('MIXDOG_AGENT_FRONTMATTER_TTL_MS', 5_000);
|
|
110
|
+
|
|
111
|
+
function clean(value) {
|
|
112
|
+
return String(value ?? '').trim();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function agentTagOf(session) {
|
|
116
|
+
return clean(session?.agentTag);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeAgentName(value) {
|
|
120
|
+
const id = clean(value).toLowerCase().replace(/[\s_]+/g, '-');
|
|
121
|
+
if (id === 'explorer') return 'explore';
|
|
122
|
+
if (id === 'maint' || id === 'maintenance' || id === 'memory') return 'maintainer';
|
|
123
|
+
if (id === 'heavy' || id === 'heavyworker') return 'heavy-worker';
|
|
124
|
+
if (id === 'review') return 'reviewer';
|
|
125
|
+
if (id === 'debug') return 'debugger';
|
|
126
|
+
return id;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readAgentFrontmatterPermission(role, dataDir) {
|
|
130
|
+
const cleanRole = clean(role);
|
|
131
|
+
if (!cleanRole) return null;
|
|
132
|
+
const cacheKey = `${dataDir || ''}\u0000${cleanRole}`;
|
|
133
|
+
const cached = _frontmatterPermCache.get(cacheKey);
|
|
134
|
+
if (cached && Date.now() - cached.atMs < FRONTMATTER_PERM_CACHE_TTL_MS) {
|
|
135
|
+
return cached.value;
|
|
136
|
+
}
|
|
137
|
+
const candidates = [];
|
|
138
|
+
if (dataDir) {
|
|
139
|
+
candidates.push(join(dataDir, 'agents', cleanRole, 'AGENT.md'));
|
|
140
|
+
candidates.push(join(dataDir, 'agents', `${cleanRole}.md`));
|
|
141
|
+
}
|
|
142
|
+
candidates.push(join(STANDALONE_SOURCE_ROOT, 'agents', cleanRole, 'AGENT.md'));
|
|
143
|
+
candidates.push(join(STANDALONE_SOURCE_ROOT, 'agents', `${cleanRole}.md`));
|
|
144
|
+
let resolved = null;
|
|
145
|
+
for (const file of candidates) {
|
|
146
|
+
if (!existsSync(file)) continue;
|
|
147
|
+
const fm = parseMarkdownFrontmatter(readFileSync(file, 'utf8'));
|
|
148
|
+
const permission = normalizeAgentPermissionOrNone(fm.permission);
|
|
149
|
+
if (permission) { resolved = permission; break; }
|
|
150
|
+
}
|
|
151
|
+
_frontmatterPermCache.set(cacheKey, { value: resolved, atMs: Date.now() });
|
|
152
|
+
return resolved;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function positiveInt(value) {
|
|
156
|
+
const n = Number(value);
|
|
157
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function terminalPidForContext(context = {}) {
|
|
161
|
+
return positiveInt(context?.clientHostPid);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function agentScope(args = {}, context = {}) {
|
|
165
|
+
const scope = clean(args.scope || args.terminal || args.term).toLowerCase();
|
|
166
|
+
if (args.allTerminals === true || scope === 'all' || scope === 'global') return {};
|
|
167
|
+
return context || {};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function sessionMatchesContext(session, context = {}) {
|
|
171
|
+
const wantedPid = terminalPidForContext(context);
|
|
172
|
+
if (!wantedPid) return true;
|
|
173
|
+
const sessionPid = positiveInt(session?.clientHostPid);
|
|
174
|
+
return !!sessionPid && sessionPid === wantedPid;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function rowMatchesContext(row, context = {}) {
|
|
178
|
+
const wantedPid = terminalPidForContext(context);
|
|
179
|
+
if (!wantedPid) return true;
|
|
180
|
+
const rowPid = positiveInt(row?.clientHostPid);
|
|
181
|
+
return !!rowPid && rowPid === wantedPid;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function nonNegativeInt(value) {
|
|
185
|
+
if (value === undefined || value === null || value === '') return null;
|
|
186
|
+
const n = Number(value);
|
|
187
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function presetKey(preset) {
|
|
191
|
+
return clean(preset?.id || preset?.name);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function bridgeRouteForStatusline(preset = {}) {
|
|
195
|
+
const provider = clean(preset.provider);
|
|
196
|
+
const model = clean(preset.model);
|
|
197
|
+
if (!provider || !model) return null;
|
|
198
|
+
const out = {
|
|
199
|
+
mode: 'fixed',
|
|
200
|
+
defaultProvider: provider,
|
|
201
|
+
defaultModel: model,
|
|
202
|
+
};
|
|
203
|
+
const id = clean(preset.id);
|
|
204
|
+
const name = clean(preset.name);
|
|
205
|
+
const modelDisplay = clean(preset.modelDisplay || preset.display || preset.displayName);
|
|
206
|
+
const effort = clean(preset.effort);
|
|
207
|
+
if (id) out.presetId = id;
|
|
208
|
+
if (name) out.presetName = name;
|
|
209
|
+
if (modelDisplay) out.modelDisplay = modelDisplay;
|
|
210
|
+
if (effort) {
|
|
211
|
+
out.effort = effort;
|
|
212
|
+
out.displayEffort = effort;
|
|
213
|
+
}
|
|
214
|
+
if (preset.fast === true || preset.fast === false) out.fast = preset.fast;
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function writeAgentStatuslineRoute(sessionId, preset) {
|
|
219
|
+
const route = bridgeRouteForStatusline(preset);
|
|
220
|
+
if (!sessionId || !route) return false;
|
|
221
|
+
try { return writeGatewaySessionRoute(sessionId, route); } catch { return false; }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function clearAgentStatuslineRoute(sessionId) {
|
|
225
|
+
if (!sessionId) return false;
|
|
226
|
+
try { return clearGatewaySessionRoute(sessionId); } catch { return false; }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function findPreset(config, key) {
|
|
230
|
+
const wanted = clean(key).toLowerCase();
|
|
231
|
+
if (!wanted) return null;
|
|
232
|
+
const presets = Array.isArray(config?.presets) ? config.presets : [];
|
|
233
|
+
return presets.find((p) => {
|
|
234
|
+
return clean(p?.id).toLowerCase() === wanted || clean(p?.name).toLowerCase() === wanted;
|
|
235
|
+
}) || null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function synthesizePreset(config, key) {
|
|
239
|
+
const alias = PRESET_ALIASES.get(clean(key).toLowerCase());
|
|
240
|
+
if (!alias) return null;
|
|
241
|
+
const base = findPreset(config, alias.base);
|
|
242
|
+
if (!base) return null;
|
|
243
|
+
return {
|
|
244
|
+
...base,
|
|
245
|
+
id: alias.id,
|
|
246
|
+
name: alias.name,
|
|
247
|
+
effort: alias.effort,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function normalizeAgentRoute(routeLike) {
|
|
252
|
+
const provider = clean(routeLike?.provider);
|
|
253
|
+
const model = clean(routeLike?.model);
|
|
254
|
+
if (!provider || !model) return null;
|
|
255
|
+
return {
|
|
256
|
+
provider,
|
|
257
|
+
model,
|
|
258
|
+
effort: clean(routeLike?.effort) || undefined,
|
|
259
|
+
fast: routeLike?.fast === true,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function agentPresetName(role) {
|
|
264
|
+
return `AGENT ${String(role || '').toUpperCase()}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function resolvePrompt(args, cwd) {
|
|
268
|
+
const prompt = clean(args.prompt || args.message);
|
|
269
|
+
const file = clean(args.file);
|
|
270
|
+
if (prompt && file) throw new Error('agent: provide only one of prompt/message or file');
|
|
271
|
+
if (prompt) return prompt;
|
|
272
|
+
if (file) {
|
|
273
|
+
const target = isAbsolute(file) ? file : resolve(cwd || process.cwd(), file);
|
|
274
|
+
return readFileSync(target, 'utf8');
|
|
275
|
+
}
|
|
276
|
+
throw new Error('agent: prompt/message/file is required');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function withCwdHeader(prompt, cwd) {
|
|
280
|
+
if (!cwd) return prompt;
|
|
281
|
+
if (String(prompt).startsWith('[effective-cwd]')) return prompt;
|
|
282
|
+
return `[effective-cwd] ${cwd}\n\n${prompt}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function oneLine(value, max = 180) {
|
|
286
|
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
287
|
+
return text.length > max ? `${text.slice(0, max)}...` : text;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function compactIso(value) {
|
|
291
|
+
const text = clean(value);
|
|
292
|
+
if (!text) return '';
|
|
293
|
+
return text.replace('T', ' ').replace(/\.\d+Z$/, 'Z');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function formatElapsedMs(ms) {
|
|
297
|
+
const n = Number(ms);
|
|
298
|
+
if (!Number.isFinite(n) || n < 0) return '';
|
|
299
|
+
const totalSec = Math.floor(n / 1000);
|
|
300
|
+
if (totalSec < 60) return `${totalSec}s`;
|
|
301
|
+
const min = Math.floor(totalSec / 60);
|
|
302
|
+
const sec = totalSec % 60;
|
|
303
|
+
if (min < 60) return sec ? `${min}m${sec}s` : `${min}m`;
|
|
304
|
+
const hr = Math.floor(min / 60);
|
|
305
|
+
const remMin = min % 60;
|
|
306
|
+
return remMin ? `${hr}h${remMin}m` : `${hr}h`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function elapsedFromStamps(startedAt, finishedAt, status) {
|
|
310
|
+
const start = Date.parse(clean(startedAt));
|
|
311
|
+
if (!Number.isFinite(start)) return null;
|
|
312
|
+
const finish = Date.parse(clean(finishedAt));
|
|
313
|
+
const end = Number.isFinite(finish) ? finish : Date.now();
|
|
314
|
+
const label = formatElapsedMs(end - start);
|
|
315
|
+
if (!label) return null;
|
|
316
|
+
return Number.isFinite(finish) ? label : `${label} (running)`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function stripFinalAnswerWrapper(value) {
|
|
320
|
+
const text = String(value ?? '').trim();
|
|
321
|
+
const match = /^<final-answer\b[^>]*>([\s\S]*?)<\/final-answer>\s*$/i.exec(text);
|
|
322
|
+
return match ? match[1].trim() : text;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function renderResult(value) {
|
|
326
|
+
if (value === undefined || value === null) return '';
|
|
327
|
+
if (typeof value === 'string') return value;
|
|
328
|
+
if (value && typeof value === 'object') {
|
|
329
|
+
const lines = [];
|
|
330
|
+
|
|
331
|
+
if (Array.isArray(value.workers) || Array.isArray(value.jobs)) {
|
|
332
|
+
const workers = Array.isArray(value.workers) ? value.workers : [];
|
|
333
|
+
lines.push(`agents: ${workers.length}`);
|
|
334
|
+
for (const worker of workers) {
|
|
335
|
+
const tokens = worker.windowTokens ? ` ctx=${worker.windowTokens}${worker.windowCap ? `/${worker.windowCap}` : ''}` : '';
|
|
336
|
+
const terminal = worker.clientHostPid ? ` term=${worker.clientHostPid}` : '';
|
|
337
|
+
const base = `- ${worker.tag} ${worker.role || 'agent'} ${worker.status || 'idle'}/${worker.worker_stage || worker.stage || 'idle'} ${worker.provider}/${worker.model}${terminal}${tokens}`;
|
|
338
|
+
lines.push(appendAgentProgressKv(base, worker));
|
|
339
|
+
}
|
|
340
|
+
const jobs = Array.isArray(value.jobs) ? value.jobs : [];
|
|
341
|
+
lines.push(`tasks: ${jobs.length}`);
|
|
342
|
+
for (const job of jobs) {
|
|
343
|
+
const target = job.tag || job.sessionId || '-';
|
|
344
|
+
const terminal = job.clientHostPid ? ` term=${job.clientHostPid}` : '';
|
|
345
|
+
const base = `- ${job.task_id} ${job.type} ${job.status} target=${target}${terminal}${job.error ? ` error=${presentErrorText(job.error, { surface: 'agent' })}` : ''}`;
|
|
346
|
+
lines.push(appendAgentProgressKv(base, job));
|
|
347
|
+
}
|
|
348
|
+
if (workers.length === 0 && jobs.length === 0) lines.push('(no agents or tasks)');
|
|
349
|
+
return lines.join('\n');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (value.task_id) {
|
|
353
|
+
lines.push(`agent task: ${value.task_id}`);
|
|
354
|
+
lines.push(`status: ${value.status}`);
|
|
355
|
+
if (value.type) lines.push(`type: ${value.type}`);
|
|
356
|
+
if (value.reused) lines.push('reused: true');
|
|
357
|
+
if (value.tag || value.sessionId) lines.push(`target: ${value.tag || '-'} ${value.sessionId || ''}`.trim());
|
|
358
|
+
if (value.role) lines.push(`agent: ${value.role}`);
|
|
359
|
+
if (value.provider && value.model) lines.push(`model: ${value.provider}/${value.model}`);
|
|
360
|
+
if (value.effort) lines.push(`effort: ${value.effort}`);
|
|
361
|
+
if (value.fast === true || value.fast === false) lines.push(`fast: ${value.fast ? 'on' : 'off'}`);
|
|
362
|
+
if (value.maxLoopIterations) {
|
|
363
|
+
const limitParts = [];
|
|
364
|
+
if (value.maxLoopIterations) limitParts.push(`loop=${value.maxLoopIterations}`);
|
|
365
|
+
lines.push(`limits: ${limitParts.join(' ')}`);
|
|
366
|
+
}
|
|
367
|
+
if (value.stage || value.workerStatus) lines.push(`worker: ${value.workerStatus || 'unknown'}/${value.stage || 'unknown'}`);
|
|
368
|
+
if (value.worker_stage) lines.push(`worker_stage: ${value.worker_stage}`);
|
|
369
|
+
if (value.last_progress) lines.push(`last_progress: ${value.last_progress}`);
|
|
370
|
+
if (Number.isFinite(value.silent_for)) lines.push(`silent_for: ${value.silent_for}s`);
|
|
371
|
+
if (value.watchdog) lines.push(`watchdog: ${value.watchdog}`);
|
|
372
|
+
if (Number.isFinite(value.queued_followups)) lines.push(`queued_followups: ${value.queued_followups}`);
|
|
373
|
+
if (value.diagnostic) lines.push(`diagnostic: ${value.diagnostic}`);
|
|
374
|
+
if (value.startedAt) lines.push(`started: ${compactIso(value.startedAt)}`);
|
|
375
|
+
if (value.finishedAt) lines.push(`finished: ${compactIso(value.finishedAt)}`);
|
|
376
|
+
{
|
|
377
|
+
const elapsed = elapsedFromStamps(value.startedAt, value.finishedAt, value.status);
|
|
378
|
+
if (elapsed) lines.push(`elapsed: ${elapsed}`);
|
|
379
|
+
}
|
|
380
|
+
if (value.error) lines.push(`error: ${presentErrorText(value.error, { surface: 'agent' })}`);
|
|
381
|
+
if (value.status === 'running') lines.push('notification: completion will be delivered to the owner session; use read/status only for manual recovery.');
|
|
382
|
+
if (value.result !== undefined) {
|
|
383
|
+
const result = value.result;
|
|
384
|
+
const content = typeof result === 'string' ? result : result?.content;
|
|
385
|
+
if (content) lines.push('', stripFinalAnswerWrapper(content));
|
|
386
|
+
else lines.push('', JSON.stringify(result, null, 2));
|
|
387
|
+
}
|
|
388
|
+
return lines.join('\n');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (value.queued) {
|
|
392
|
+
return [
|
|
393
|
+
'agent message queued',
|
|
394
|
+
value.reused ? 'reused: true' : null,
|
|
395
|
+
`target: ${value.tag || '-'} ${value.sessionId || ''}`.trim(),
|
|
396
|
+
value.role ? `agent: ${value.role}` : null,
|
|
397
|
+
`queueDepth: ${value.queueDepth ?? 1}`,
|
|
398
|
+
].filter(Boolean).join('\n');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (value.closed !== undefined) {
|
|
402
|
+
return [
|
|
403
|
+
`agent close: ${value.closed ? 'ok' : 'not closed'}`,
|
|
404
|
+
value.tag ? `tag: ${value.tag}` : null,
|
|
405
|
+
value.sessionId ? `sessionId: ${value.sessionId}` : null,
|
|
406
|
+
value.task_id ? `task_id: ${value.task_id}` : null,
|
|
407
|
+
value.forgotten ? 'forgotten: true' : null,
|
|
408
|
+
].filter(Boolean).join('\n');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (value.content !== undefined) {
|
|
412
|
+
const header = [
|
|
413
|
+
value.respawned ? 'agent respawned' : 'agent result',
|
|
414
|
+
value.tag ? `tag=${value.tag}` : null,
|
|
415
|
+
value.role ? `agent=${value.role}` : null,
|
|
416
|
+
value.provider && value.model ? `${value.provider}/${value.model}` : null,
|
|
417
|
+
].filter(Boolean).join(' ');
|
|
418
|
+
return `${header}\n${stripFinalAnswerWrapper(value.content)}`;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return JSON.stringify(value, null, 2);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultCwd }) {
|
|
425
|
+
const tags = new Map();
|
|
426
|
+
const tagRoles = new Map();
|
|
427
|
+
const tagCwds = new Map();
|
|
428
|
+
const reapTimers = new Map();
|
|
429
|
+
const workerIndexMutators = [];
|
|
430
|
+
let workerIndexFlushTimer = null;
|
|
431
|
+
function workerIndexPath() {
|
|
432
|
+
return dataDir ? resolve(dataDir, WORKER_INDEX_FILE) : null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function workerRowKey(row = {}) {
|
|
436
|
+
return clean(row.sessionId) || clean(row.tag);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function workerRowTime(row = {}) {
|
|
440
|
+
return Date.parse(row.updatedAt || row.finishedAt || row.lastUsedAt || row.createdAt || '') || 0;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function isTerminalWorkerStatus(status) {
|
|
444
|
+
return /^(idle|closed|completed|failed|error|cancelled|canceled|killed|timeout)$/i.test(clean(status));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function keepWorkerRow(row = {}) {
|
|
448
|
+
if (!clean(row.tag) || !clean(row.sessionId)) return false;
|
|
449
|
+
const t = workerRowTime(row);
|
|
450
|
+
if (!t) return true;
|
|
451
|
+
if (!isTerminalWorkerStatus(row.status || row.stage)) return true;
|
|
452
|
+
return Date.now() - t < TERMINAL_REAP_MS;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function normalizeWorkerRows(value) {
|
|
456
|
+
const source = Array.isArray(value?.workers)
|
|
457
|
+
? value.workers
|
|
458
|
+
: (value?.workers && typeof value.workers === 'object'
|
|
459
|
+
? Object.values(value.workers)
|
|
460
|
+
: (Array.isArray(value) ? value : []));
|
|
461
|
+
return source
|
|
462
|
+
.filter((row) => row && typeof row === 'object')
|
|
463
|
+
.map((row) => ({
|
|
464
|
+
tag: clean(row.tag),
|
|
465
|
+
sessionId: clean(row.sessionId),
|
|
466
|
+
role: clean(row.role) || null,
|
|
467
|
+
provider: clean(row.provider) || null,
|
|
468
|
+
model: clean(row.model) || null,
|
|
469
|
+
preset: clean(row.preset) || null,
|
|
470
|
+
effort: clean(row.effort) || null,
|
|
471
|
+
fast: row.fast === true ? true : (row.fast === false ? false : null),
|
|
472
|
+
status: clean(row.status) || 'idle',
|
|
473
|
+
stage: clean(row.stage) || clean(row.status) || 'idle',
|
|
474
|
+
createdAt: clean(row.createdAt) || null,
|
|
475
|
+
updatedAt: clean(row.updatedAt) || null,
|
|
476
|
+
lastUsedAt: clean(row.lastUsedAt) || null,
|
|
477
|
+
finishedAt: clean(row.finishedAt) || null,
|
|
478
|
+
clientHostPid: positiveInt(row.clientHostPid),
|
|
479
|
+
cwd: clean(row.cwd) || null,
|
|
480
|
+
task_id: clean(row.task_id || row.taskId) || null,
|
|
481
|
+
error: clean(row.error) || null,
|
|
482
|
+
permission: clean(row.permission) || null,
|
|
483
|
+
toolPermission: clean(row.toolPermission) || null,
|
|
484
|
+
messages: positiveInt(row.messages) || 0,
|
|
485
|
+
tools: positiveInt(row.tools) || 0,
|
|
486
|
+
}))
|
|
487
|
+
.filter(keepWorkerRow);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Mtime-keyed parse cache for the worker index. A single spawn calls
|
|
491
|
+
// refreshTagsFromSessions()/resolveTag()/nextTag() which each re-read and
|
|
492
|
+
// re-JSON.parse this file; across a parallel fanout that is O(spawns^2)
|
|
493
|
+
// synchronous reads of the same bytes on the event loop. Cache the parsed,
|
|
494
|
+
// normalized rows and reuse them while the file mtime+size is unchanged.
|
|
495
|
+
// Writes bump _workerRowsCacheDirty so the very next read re-parses.
|
|
496
|
+
let _workerRowsCache = null; // { mtimeMs, size, rows }
|
|
497
|
+
let _workerRowsCacheDirty = true;
|
|
498
|
+
function invalidateWorkerRowsCache() {
|
|
499
|
+
_workerRowsCacheDirty = true;
|
|
500
|
+
}
|
|
501
|
+
function readAllWorkerRows() {
|
|
502
|
+
const file = workerIndexPath();
|
|
503
|
+
if (!file) return [];
|
|
504
|
+
let st = null;
|
|
505
|
+
try { st = statSync(file); } catch { _workerRowsCache = null; return []; }
|
|
506
|
+
if (!_workerRowsCacheDirty
|
|
507
|
+
&& _workerRowsCache
|
|
508
|
+
&& _workerRowsCache.mtimeMs === st.mtimeMs
|
|
509
|
+
&& _workerRowsCache.size === st.size) {
|
|
510
|
+
return _workerRowsCache.rows;
|
|
511
|
+
}
|
|
512
|
+
let rows = [];
|
|
513
|
+
try {
|
|
514
|
+
rows = normalizeWorkerRows(JSON.parse(readFileSync(file, 'utf8')));
|
|
515
|
+
} catch {
|
|
516
|
+
rows = [];
|
|
517
|
+
}
|
|
518
|
+
_workerRowsCache = { mtimeMs: st.mtimeMs, size: st.size, rows };
|
|
519
|
+
_workerRowsCacheDirty = false;
|
|
520
|
+
return rows;
|
|
521
|
+
}
|
|
522
|
+
function readWorkerRows(context = {}) {
|
|
523
|
+
const rows = readAllWorkerRows();
|
|
524
|
+
if (rows.length === 0) return rows;
|
|
525
|
+
return rows.filter((row) => rowMatchesContext(row, context));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function workerRowsForUpdate(cur) {
|
|
529
|
+
return normalizeWorkerRows(cur);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function writeWorkerRows(mutator) {
|
|
533
|
+
const file = workerIndexPath();
|
|
534
|
+
if (!file || typeof mutator !== 'function') return null;
|
|
535
|
+
try {
|
|
536
|
+
const result = updateJsonAtomicSync(file, (cur) => {
|
|
537
|
+
const byKey = new Map();
|
|
538
|
+
for (const row of workerRowsForUpdate(cur)) {
|
|
539
|
+
const key = workerRowKey(row);
|
|
540
|
+
if (key) byKey.set(key, row);
|
|
541
|
+
}
|
|
542
|
+
mutator(byKey);
|
|
543
|
+
const workers = {};
|
|
544
|
+
for (const row of [...byKey.values()].filter(keepWorkerRow)) {
|
|
545
|
+
const key = workerRowKey(row);
|
|
546
|
+
if (key) workers[key] = row;
|
|
547
|
+
}
|
|
548
|
+
return { version: 1, updatedAt: new Date().toISOString(), workers };
|
|
549
|
+
}, { lock: true });
|
|
550
|
+
// This process just rewrote the index; force the next read to re-parse
|
|
551
|
+
// even if the new mtime/size happen to collide with the cached stat.
|
|
552
|
+
invalidateWorkerRowsCache();
|
|
553
|
+
return result;
|
|
554
|
+
} catch {
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function applyWorkerRowUpsert(byKey, normalized) {
|
|
560
|
+
if (!normalized) return;
|
|
561
|
+
const key = workerRowKey(normalized);
|
|
562
|
+
if (!key) return;
|
|
563
|
+
const prev = byKey.get(key) || {};
|
|
564
|
+
const merged = { ...prev, ...normalized };
|
|
565
|
+
for (const field of ['role', 'provider', 'model', 'preset', 'effort', 'fast', 'clientHostPid', 'cwd', 'task_id', 'permission', 'toolPermission']) {
|
|
566
|
+
if ((merged[field] === null || merged[field] === '') && prev[field] != null && prev[field] !== '') {
|
|
567
|
+
merged[field] = prev[field];
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
byKey.set(key, {
|
|
571
|
+
...merged,
|
|
572
|
+
createdAt: normalized.createdAt || prev.createdAt || new Date().toISOString(),
|
|
573
|
+
updatedAt: normalized.updatedAt || new Date().toISOString(),
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function flushWorkerIndexMutations() {
|
|
578
|
+
if (workerIndexFlushTimer) {
|
|
579
|
+
try { clearImmediate(workerIndexFlushTimer); } catch {}
|
|
580
|
+
workerIndexFlushTimer = null;
|
|
581
|
+
}
|
|
582
|
+
if (workerIndexMutators.length === 0) return;
|
|
583
|
+
const batch = workerIndexMutators.splice(0, workerIndexMutators.length);
|
|
584
|
+
writeWorkerRows((byKey) => {
|
|
585
|
+
for (const mutator of batch) {
|
|
586
|
+
try { mutator(byKey); } catch {}
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function queueWorkerIndexMutation(mutator) {
|
|
592
|
+
if (typeof mutator !== 'function') return false;
|
|
593
|
+
if (!workerIndexPath()) return false;
|
|
594
|
+
workerIndexMutators.push(mutator);
|
|
595
|
+
if (!workerIndexFlushTimer) {
|
|
596
|
+
workerIndexFlushTimer = setImmediate(flushWorkerIndexMutations);
|
|
597
|
+
workerIndexFlushTimer.unref?.();
|
|
598
|
+
}
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function workerRowFromSession(session, fallbackTag = '', extra = {}) {
|
|
603
|
+
const tag = agentTagOf(session) || clean(fallbackTag) || clean(extra.tag);
|
|
604
|
+
const sessionId = clean(session?.id || extra.sessionId);
|
|
605
|
+
if (!tag || !sessionId) return null;
|
|
606
|
+
const runtime = mgr.getSessionRuntime?.(sessionId);
|
|
607
|
+
const status = clean(extra.status) || (session?.closed === true ? 'closed' : clean(session?.status) || 'idle');
|
|
608
|
+
const stage = clean(extra.stage) || clean(runtime?.stage) || status;
|
|
609
|
+
const nowIso = new Date().toISOString();
|
|
610
|
+
return {
|
|
611
|
+
tag,
|
|
612
|
+
sessionId,
|
|
613
|
+
role: clean(extra.role) || clean(session?.role) || null,
|
|
614
|
+
provider: clean(extra.provider) || clean(session?.provider) || null,
|
|
615
|
+
model: clean(extra.model) || clean(session?.model) || null,
|
|
616
|
+
preset: clean(extra.preset) || clean(session?.presetName) || null,
|
|
617
|
+
effort: clean(extra.effort) || clean(session?.effort) || null,
|
|
618
|
+
fast: extra.fast === true || extra.fast === false ? extra.fast : (session?.fast === true ? true : null),
|
|
619
|
+
status,
|
|
620
|
+
stage,
|
|
621
|
+
createdAt: clean(session?.createdAt) || clean(extra.createdAt) || nowIso,
|
|
622
|
+
updatedAt: clean(extra.updatedAt) || nowIso,
|
|
623
|
+
lastUsedAt: clean(session?.lastUsedAt) || null,
|
|
624
|
+
finishedAt: clean(extra.finishedAt) || null,
|
|
625
|
+
clientHostPid: positiveInt(extra.clientHostPid) || positiveInt(session?.clientHostPid),
|
|
626
|
+
cwd: clean(session?.cwd) || clean(extra.cwd) || null,
|
|
627
|
+
task_id: clean(extra.task_id || extra.taskId) || null,
|
|
628
|
+
error: clean(extra.error) || null,
|
|
629
|
+
permission: clean(session?.permission) || null,
|
|
630
|
+
toolPermission: clean(session?.toolPermission) || null,
|
|
631
|
+
messages: Array.isArray(session?.messages) ? session.messages.length : 0,
|
|
632
|
+
tools: Array.isArray(session?.tools) ? session.tools.length : 0,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function workerRowToSession(row = {}) {
|
|
637
|
+
return {
|
|
638
|
+
id: row.sessionId,
|
|
639
|
+
agentTag: row.tag,
|
|
640
|
+
role: row.role || null,
|
|
641
|
+
provider: row.provider || null,
|
|
642
|
+
model: row.model || null,
|
|
643
|
+
presetName: row.preset || null,
|
|
644
|
+
effort: row.effort || null,
|
|
645
|
+
fast: row.fast === true,
|
|
646
|
+
status: row.status || 'idle',
|
|
647
|
+
stage: row.stage || row.status || 'idle',
|
|
648
|
+
createdAt: row.createdAt || null,
|
|
649
|
+
updatedAt: row.updatedAt || null,
|
|
650
|
+
lastUsedAt: row.lastUsedAt || null,
|
|
651
|
+
clientHostPid: row.clientHostPid || null,
|
|
652
|
+
cwd: row.cwd || null,
|
|
653
|
+
permission: row.permission || null,
|
|
654
|
+
toolPermission: row.toolPermission || null,
|
|
655
|
+
messageCount: Math.max(0, Number(row.messages || 0)),
|
|
656
|
+
toolCount: Math.max(0, Number(row.tools || 0)),
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function upsertWorkerRow(row, { defer = false } = {}) {
|
|
661
|
+
const normalized = normalizeWorkerRows({ workers: [row] })[0];
|
|
662
|
+
if (!normalized) return false;
|
|
663
|
+
tags.set(normalized.tag, normalized.sessionId);
|
|
664
|
+
if (normalized.role) tagRoles.set(normalized.tag, normalized.role);
|
|
665
|
+
if (normalized.cwd) tagCwds.set(normalized.tag, normalized.cwd);
|
|
666
|
+
if (defer) {
|
|
667
|
+
return queueWorkerIndexMutation((byKey) => applyWorkerRowUpsert(byKey, normalized));
|
|
668
|
+
}
|
|
669
|
+
writeWorkerRows((byKey) => {
|
|
670
|
+
applyWorkerRowUpsert(byKey, normalized);
|
|
671
|
+
});
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function upsertWorkerSession(session, fallbackTag = '', extra = {}) {
|
|
676
|
+
return upsertWorkerRow(workerRowFromSession(session, fallbackTag, extra));
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function upsertWorkerSessionDeferred(session, fallbackTag = '', extra = {}) {
|
|
680
|
+
return upsertWorkerRow(workerRowFromSession(session, fallbackTag, extra), { defer: true });
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function removeWorkerRow({ tag = '', sessionId = '' } = {}) {
|
|
684
|
+
const targetTag = clean(tag);
|
|
685
|
+
const targetSessionId = clean(sessionId);
|
|
686
|
+
flushWorkerIndexMutations();
|
|
687
|
+
writeWorkerRows((byKey) => {
|
|
688
|
+
for (const [key, row] of [...byKey.entries()]) {
|
|
689
|
+
if ((targetSessionId && row.sessionId === targetSessionId) || (targetTag && row.tag === targetTag)) {
|
|
690
|
+
byKey.delete(key);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function refreshTagsFromIndex(context = {}) {
|
|
697
|
+
const rows = readWorkerRows(context);
|
|
698
|
+
for (const row of rows) {
|
|
699
|
+
if (!row.tag || !row.sessionId) continue;
|
|
700
|
+
tags.set(row.tag, row.sessionId);
|
|
701
|
+
if (row.role) tagRoles.set(row.tag, row.role);
|
|
702
|
+
if (row.cwd) tagCwds.set(row.tag, row.cwd);
|
|
703
|
+
}
|
|
704
|
+
return rows;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function wantsSessionScan(args = {}) {
|
|
708
|
+
return args.recover === true || args.scanSessions === true || args.scan_sessions === true;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function resolveTag(target, context = {}, options = {}) {
|
|
712
|
+
const scanSessions = options.scanSessions === true;
|
|
713
|
+
const excludeTerminalTraces = options.excludeTerminalTraces === true;
|
|
714
|
+
refreshTagsFromSessions({ scanSessions, context });
|
|
715
|
+
const value = clean(target);
|
|
716
|
+
if (!value) return null;
|
|
717
|
+
if (value.startsWith('sess_')) {
|
|
718
|
+
const session = getLiveSession(value);
|
|
719
|
+
if (session && sessionMatchesContext(session, context)) return value;
|
|
720
|
+
const row = readWorkerRows(context).find((item) => item.sessionId === value);
|
|
721
|
+
return row ? value : null;
|
|
722
|
+
}
|
|
723
|
+
const matches = agentSessionEntries({ scanSessions, context, excludeTerminalTraces })
|
|
724
|
+
.filter((entry) => entry.tag === value);
|
|
725
|
+
if (matches.length === 1) return matches[0].session.id;
|
|
726
|
+
if (matches.length > 1) {
|
|
727
|
+
throw new Error(`agent: tag "${value}" is ambiguous across terminals; use sessionId`);
|
|
728
|
+
}
|
|
729
|
+
const sessionId = tags.get(value) || null;
|
|
730
|
+
const session = getLiveSession(sessionId);
|
|
731
|
+
return session && sessionMatchesContext(session, context) ? sessionId : null;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function getLiveSession(sessionId) {
|
|
735
|
+
if (!sessionId) return null;
|
|
736
|
+
const session = mgr.getSession(sessionId);
|
|
737
|
+
return session && session.closed !== true ? session : null;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function tagForSession(sessionId) {
|
|
741
|
+
const session = getLiveSession(sessionId);
|
|
742
|
+
const persistedTag = agentTagOf(session);
|
|
743
|
+
if (persistedTag) return persistedTag;
|
|
744
|
+
for (const [tag, sid] of tags.entries()) {
|
|
745
|
+
if (sid === sessionId) return tag;
|
|
746
|
+
}
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function agentSessionEntries({ scanSessions = false, context = {}, excludeTerminalTraces = false } = {}) {
|
|
751
|
+
const rows = [];
|
|
752
|
+
const seen = new Set();
|
|
753
|
+
const add = (session, fallbackTag = '') => {
|
|
754
|
+
const tag = agentTagOf(session) || clean(fallbackTag);
|
|
755
|
+
if (!tag || !session?.id || session.closed === true) return;
|
|
756
|
+
if (!sessionMatchesContext(session, context)) return;
|
|
757
|
+
if (seen.has(session.id)) return;
|
|
758
|
+
seen.add(session.id);
|
|
759
|
+
rows.push({ tag, session });
|
|
760
|
+
};
|
|
761
|
+
const addIndexRow = (row) => {
|
|
762
|
+
const tag = clean(row?.tag);
|
|
763
|
+
const sessionId = clean(row?.sessionId);
|
|
764
|
+
if (!tag || !sessionId || !rowMatchesContext(row, context)) return;
|
|
765
|
+
if (seen.has(sessionId)) return;
|
|
766
|
+
// Collision/resolution enumeration only: a row that is in a terminal
|
|
767
|
+
// (or idle-but-finished) state AND has no live session behind it is a
|
|
768
|
+
// lingering trace kept for the reap grace window. excludeTerminalTraces drops those
|
|
769
|
+
// rows so live-session reuse/spawn resolution can proceed; list/status
|
|
770
|
+
// keep excludeTerminalTraces=false so finished workers still appear.
|
|
771
|
+
if (excludeTerminalTraces
|
|
772
|
+
&& isTerminalWorkerStatus(row.status || row.stage)
|
|
773
|
+
&& !getLiveSession(sessionId)) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
seen.add(sessionId);
|
|
777
|
+
rows.push({ tag, session: workerRowToSession(row), indexRow: row });
|
|
778
|
+
};
|
|
779
|
+
for (const row of readWorkerRows(context)) addIndexRow(row);
|
|
780
|
+
if (scanSessions) {
|
|
781
|
+
for (const session of mgr.listSessions({ includeClosed: false }) || []) {
|
|
782
|
+
const tag = agentTagOf(session);
|
|
783
|
+
add(session, tag);
|
|
784
|
+
if (tag) upsertWorkerSessionDeferred(session, tag);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
for (const [tag, sessionId] of tags.entries()) {
|
|
788
|
+
add(getLiveSession(sessionId), tag);
|
|
789
|
+
}
|
|
790
|
+
return rows;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function nextTag(role, context = {}) {
|
|
794
|
+
refreshTagsFromSessions({ context });
|
|
795
|
+
// Auto tags are role + a per-role local index with NO hyphen
|
|
796
|
+
// ("worker3", "heavy-worker7", or "agent1" when the role is unset). The
|
|
797
|
+
// index is the max existing `^role(\d+)$` + 1, escaping the role so a
|
|
798
|
+
// hyphenated role ("heavy-worker") is matched literally. Keep incrementing
|
|
799
|
+
// on any live collision.
|
|
800
|
+
const base = clean(role) || 'agent';
|
|
801
|
+
const escaped = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
802
|
+
const re = new RegExp(`^${escaped}(\\d+)$`);
|
|
803
|
+
let maxN = 0;
|
|
804
|
+
for (const existing of tags.keys()) {
|
|
805
|
+
const match = re.exec(existing);
|
|
806
|
+
if (!match) continue;
|
|
807
|
+
const n = Number(match[1]);
|
|
808
|
+
if (Number.isFinite(n) && n > maxN) maxN = n;
|
|
809
|
+
}
|
|
810
|
+
let n = maxN + 1;
|
|
811
|
+
let tag = `${base}${n}`;
|
|
812
|
+
while (resolveTag(tag, context)) tag = `${base}${++n}`;
|
|
813
|
+
return tag;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function refreshTagsFromSessions({ scanSessions = false, context = {} } = {}) {
|
|
817
|
+
const indexedRows = refreshTagsFromIndex(context);
|
|
818
|
+
const indexedKeys = new Set(indexedRows.map((row) => `${row.tag}\0${row.sessionId}`));
|
|
819
|
+
for (const [tag, sessionId] of [...tags.entries()]) {
|
|
820
|
+
if (indexedKeys.has(`${tag}\0${sessionId}`)) continue;
|
|
821
|
+
const session = getLiveSession(sessionId);
|
|
822
|
+
if (!session || session.closed) tags.delete(tag);
|
|
823
|
+
}
|
|
824
|
+
if (!scanSessions) return;
|
|
825
|
+
for (const session of mgr.listSessions({ includeClosed: false }) || []) {
|
|
826
|
+
const tag = agentTagOf(session);
|
|
827
|
+
if (!tag || tags.has(tag)) continue;
|
|
828
|
+
if (!sessionMatchesContext(session, context)) continue;
|
|
829
|
+
tags.set(tag, session.id);
|
|
830
|
+
if (session.role) tagRoles.set(tag, session.role);
|
|
831
|
+
if (session.cwd) tagCwds.set(tag, session.cwd);
|
|
832
|
+
upsertWorkerSessionDeferred(session, tag);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function bindTag(tag, session, extra = {}) {
|
|
837
|
+
if (!tag || !session?.id) return;
|
|
838
|
+
tags.set(tag, session.id);
|
|
839
|
+
if (session.role) tagRoles.set(tag, session.role);
|
|
840
|
+
if (session.cwd) tagCwds.set(tag, session.cwd);
|
|
841
|
+
upsertWorkerSessionDeferred(session, tag, extra);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function forgetTag(tag) {
|
|
845
|
+
if (!tag) return;
|
|
846
|
+
const sessionId = tags.get(tag) || '';
|
|
847
|
+
tags.delete(tag);
|
|
848
|
+
tagRoles.delete(tag);
|
|
849
|
+
tagCwds.delete(tag);
|
|
850
|
+
removeWorkerRow({ tag, sessionId });
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function cancelReap(sessionId) {
|
|
854
|
+
const handle = reapTimers.get(sessionId);
|
|
855
|
+
if (!handle) return false;
|
|
856
|
+
clearTimeout(handle);
|
|
857
|
+
reapTimers.delete(sessionId);
|
|
858
|
+
return true;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function scheduleReap(sessionId) {
|
|
862
|
+
if (!sessionId) return;
|
|
863
|
+
cancelReap(sessionId);
|
|
864
|
+
const handle = setTimeout(() => {
|
|
865
|
+
reapTimers.delete(sessionId);
|
|
866
|
+
try { mgr.hideSessionFromList?.(sessionId); } catch {}
|
|
867
|
+
const tag = tagForSession(sessionId);
|
|
868
|
+
if (tag) forgetTag(tag);
|
|
869
|
+
removeWorkerRow({ tag, sessionId });
|
|
870
|
+
clearAgentStatuslineRoute(sessionId);
|
|
871
|
+
try { mgr.closeSession(sessionId, 'terminal-reap'); } catch {}
|
|
872
|
+
}, TERMINAL_REAP_MS);
|
|
873
|
+
handle.unref?.();
|
|
874
|
+
reapTimers.set(sessionId, handle);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function isSessionBusy(sessionId) {
|
|
878
|
+
const runtime = mgr.getSessionRuntime?.(sessionId);
|
|
879
|
+
if (runtime?.controller?.signal && !runtime.controller.signal.aborted) return true;
|
|
880
|
+
if (runtime?.stage) return ACTIVE_STAGES.has(runtime.stage);
|
|
881
|
+
const session = getLiveSession(sessionId);
|
|
882
|
+
return ACTIVE_STAGES.has(session?.status || '');
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Provider init de-dup. Four goals that must not conflict:
|
|
886
|
+
// (a) a parallel spawn fanout that all targets the SAME provider with the
|
|
887
|
+
// SAME effective config performs at most ONE initProviders() pass
|
|
888
|
+
// instead of N serially-awaited registry rebuilds,
|
|
889
|
+
// (b) a provider CONFIG CHANGE still reaches initProviders() so the
|
|
890
|
+
// registry's own signature guard can re-initialize it,
|
|
891
|
+
// (c) two DIFFERENT config signatures for the same provider never init
|
|
892
|
+
// concurrently — otherwise a slow init of the OLD config could land
|
|
893
|
+
// after a fast init of the NEW config and revert the live registry to
|
|
894
|
+
// stale config, and
|
|
895
|
+
// (d) a SUPERSEDED request never resolves before the provider is actually
|
|
896
|
+
// ready: even when its own (stale) init is dropped to satisfy (c), the
|
|
897
|
+
// caller (a spawn about to run prepareSpawn) must still WAIT for the
|
|
898
|
+
// latest init to finish, or it would proceed against an unprepared /
|
|
899
|
+
// stale provider.
|
|
900
|
+
//
|
|
901
|
+
// Skip cache + in-flight collapse are keyed on `provider + signature(effective
|
|
902
|
+
// config)`. To satisfy (c) we SERIALIZE all inits per provider on a chain
|
|
903
|
+
// promise and re-check the latest-requested signature inside the chain: a
|
|
904
|
+
// request superseded by a newer signature drops its own init. To satisfy (d)
|
|
905
|
+
// such a dropped request does not resolve immediately — it awaits the
|
|
906
|
+
// provider's latest settled init (tracked as a rolling "ready" promise) so the
|
|
907
|
+
// caller only proceeds once the newest config is live.
|
|
908
|
+
// Per-provider state. `chain` serializes the ACTUAL initProviders() calls so
|
|
909
|
+
// two different config signatures never run concurrently (goal c). `latestGen`
|
|
910
|
+
// / `latestSig` track the newest requested config. `ready` is a rolling
|
|
911
|
+
// deferred that resolves only when the LATEST requested init has completed —
|
|
912
|
+
// a superseded caller awaits the ready deferred captured at call time, and
|
|
913
|
+
// when a newer request arrives the older deferred ADOPTS the newer one, so a
|
|
914
|
+
// superseded caller transitively waits for the latest init (goal d).
|
|
915
|
+
const _providerState = new Map(); // provider -> state
|
|
916
|
+
const _providerInitPending = new Map(); // provider -> { sigKey, promise } identical-sig collapse
|
|
917
|
+
// Upper bound on how long a queued init waits for the PRIOR chain link before
|
|
918
|
+
// proceeding anyway. A prior init that HANGS (never settles) must not poison
|
|
919
|
+
// the chain and wedge every later request behind it. A hung init can never
|
|
920
|
+
// *complete* against the registry, so it cannot land-after and clobber a
|
|
921
|
+
// newer config (goal c only fears slow-but-completing inits) — so proceeding
|
|
922
|
+
// once the gate expires is safe. Defaults to the spawn-prep cap; 0 disables.
|
|
923
|
+
const PROVIDER_CHAIN_GATE_MS = DEFAULT_SPAWN_PREP_TIMEOUT_MS;
|
|
924
|
+
function providerRegistered(provider) {
|
|
925
|
+
return typeof reg.getProvider !== 'function' || Boolean(reg.getProvider(provider));
|
|
926
|
+
}
|
|
927
|
+
function effectiveProviderConfig(config, provider) {
|
|
928
|
+
const providers = { ...(config.providers || {}) };
|
|
929
|
+
providers[provider] = { ...(providers[provider] || {}), enabled: true };
|
|
930
|
+
return providers;
|
|
931
|
+
}
|
|
932
|
+
function providerStateFor(provider) {
|
|
933
|
+
let s = _providerState.get(provider);
|
|
934
|
+
if (!s) {
|
|
935
|
+
s = { chain: Promise.resolve(), completedSig: null, latestSig: null, latestGen: 0, ready: null };
|
|
936
|
+
_providerState.set(provider, s);
|
|
937
|
+
}
|
|
938
|
+
return s;
|
|
939
|
+
}
|
|
940
|
+
function providerInitSignature(provider, effectiveProviders) {
|
|
941
|
+
let body;
|
|
942
|
+
try { body = JSON.stringify(effectiveProviders); }
|
|
943
|
+
catch { body = String(Date.now()); } // unserializable → force a fresh init
|
|
944
|
+
return `${provider}\u0000${body}`;
|
|
945
|
+
}
|
|
946
|
+
function gateOnPrior(prior) {
|
|
947
|
+
const settled = Promise.resolve(prior).catch(() => {});
|
|
948
|
+
if (!(PROVIDER_CHAIN_GATE_MS > 0)) return settled;
|
|
949
|
+
return new Promise((resolve) => {
|
|
950
|
+
let done = false;
|
|
951
|
+
const finish = () => { if (!done) { done = true; resolve(); } };
|
|
952
|
+
const timer = setTimeout(finish, PROVIDER_CHAIN_GATE_MS);
|
|
953
|
+
timer.unref?.();
|
|
954
|
+
settled.then(() => { clearTimeout(timer); finish(); }, () => { clearTimeout(timer); finish(); });
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
function ensureProvider(config, provider) {
|
|
958
|
+
const effective = effectiveProviderConfig(config, provider);
|
|
959
|
+
const sigKey = providerInitSignature(provider, effective);
|
|
960
|
+
const registered = () => providerRegistered(provider);
|
|
961
|
+
const s = providerStateFor(provider);
|
|
962
|
+
// Completed-skip: this exact effective config is already live for this
|
|
963
|
+
// provider. A config change flips sigKey so we fall through; a torn-down
|
|
964
|
+
// provider (no longer registered) also does.
|
|
965
|
+
if (s.completedSig === sigKey && registered()) return Promise.resolve();
|
|
966
|
+
// Identical-sig collapse: a request with the SAME sigKey is already in
|
|
967
|
+
// flight — share its caller promise.
|
|
968
|
+
const pending = _providerInitPending.get(provider);
|
|
969
|
+
if (pending && pending.sigKey === sigKey) return pending.promise;
|
|
970
|
+
// New generation. Repoint the rolling `ready` deferred to THIS gen and make
|
|
971
|
+
// the previous gen's deferred ADOPT the new one, so any superseded caller
|
|
972
|
+
// awaiting an older deferred transitively waits for the newest init (d).
|
|
973
|
+
const gen = ++s.latestGen;
|
|
974
|
+
s.latestSig = sigKey;
|
|
975
|
+
const prevReady = s.ready;
|
|
976
|
+
let resolveReady;
|
|
977
|
+
const readyPromise = new Promise((r) => { resolveReady = r; });
|
|
978
|
+
s.ready = { gen, promise: readyPromise, resolve: resolveReady };
|
|
979
|
+
if (prevReady && prevReady.gen < gen) {
|
|
980
|
+
try { prevReady.resolve(readyPromise); } catch { /* already settled */ }
|
|
981
|
+
}
|
|
982
|
+
// Serialize the ACTUAL init behind the prior chain link (gated so a hung
|
|
983
|
+
// prior cannot wedge the chain). A superseded gen's chain link settles
|
|
984
|
+
// quickly — it never awaits a later gen — so there is no deadlock.
|
|
985
|
+
const prior = s.chain;
|
|
986
|
+
const chainLink = gateOnPrior(prior).then(async () => {
|
|
987
|
+
if (s.latestGen !== gen) {
|
|
988
|
+
// Superseded before we ran: drop our (stale) init entirely (goal c).
|
|
989
|
+
// Our `ready` deferred already adopts the newer gen, so the caller below
|
|
990
|
+
// still waits for the latest init. Settle now to release the chain.
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
try {
|
|
994
|
+
if (!(s.completedSig === sigKey && registered())) {
|
|
995
|
+
await reg.initProviders(effective);
|
|
996
|
+
s.completedSig = sigKey;
|
|
997
|
+
}
|
|
998
|
+
} finally {
|
|
999
|
+
// ALWAYS release this gen's waiters once we are the latest — even on a
|
|
1000
|
+
// registry init failure. Adopting (superseded) callers chained onto this
|
|
1001
|
+
// deferred would otherwise hang forever; instead they proceed and their
|
|
1002
|
+
// own createSession()/prep-timeout surfaces the unprepared provider.
|
|
1003
|
+
resolveReady();
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
// Next chain link waits on us (settled, never poisoned).
|
|
1007
|
+
s.chain = chainLink.catch(() => {});
|
|
1008
|
+
// The CALLER awaits the ready deferred (resolves only when the LATEST init
|
|
1009
|
+
// for this provider completes), not just the chain link — so a superseded
|
|
1010
|
+
// caller blocks until the newest config is live (goal d). chainLink is
|
|
1011
|
+
// awaited first so a registry init error surfaces to this caller.
|
|
1012
|
+
const callerPromise = chainLink.then(() => readyPromise).finally(() => {
|
|
1013
|
+
const cur = _providerInitPending.get(provider);
|
|
1014
|
+
if (cur && cur.promise === callerPromise) _providerInitPending.delete(provider);
|
|
1015
|
+
});
|
|
1016
|
+
_providerInitPending.set(provider, { sigKey, promise: callerPromise });
|
|
1017
|
+
return callerPromise;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function resolvePreset(config, args) {
|
|
1021
|
+
if (args.provider && args.model) {
|
|
1022
|
+
return {
|
|
1023
|
+
presetName: args.preset || '__direct__',
|
|
1024
|
+
preset: {
|
|
1025
|
+
id: '__direct__',
|
|
1026
|
+
name: '__DIRECT__',
|
|
1027
|
+
type: 'agent',
|
|
1028
|
+
provider: clean(args.provider),
|
|
1029
|
+
model: clean(args.model),
|
|
1030
|
+
effort: clean(args.effort) || undefined,
|
|
1031
|
+
fast: args.fast === true,
|
|
1032
|
+
tools: 'full',
|
|
1033
|
+
},
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const agentName = normalizeAgentName(args.agent || args.role);
|
|
1038
|
+
const agentRoute = !clean(args.preset)
|
|
1039
|
+
? (normalizeAgentRoute(config?.agents?.[agentName])
|
|
1040
|
+
|| (agentName === 'maintainer' ? normalizeAgentRoute(config?.agents?.maintenance) : null))
|
|
1041
|
+
: null;
|
|
1042
|
+
if (agentRoute) {
|
|
1043
|
+
return {
|
|
1044
|
+
presetName: agentPresetName(agentName),
|
|
1045
|
+
preset: {
|
|
1046
|
+
id: `agent-${agentName}`,
|
|
1047
|
+
name: agentPresetName(agentName),
|
|
1048
|
+
type: 'agent',
|
|
1049
|
+
provider: agentRoute.provider,
|
|
1050
|
+
model: agentRoute.model,
|
|
1051
|
+
effort: agentRoute.effort,
|
|
1052
|
+
fast: agentRoute.fast === true,
|
|
1053
|
+
tools: 'full',
|
|
1054
|
+
},
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const presetName = clean(args.preset) || DEFAULT_AGENT_PRESETS[agentName];
|
|
1059
|
+
if (!presetName) throw new Error(`agent: agent "${agentName}" has no model assignment`);
|
|
1060
|
+
const preset = findPreset(config, presetName) || synthesizePreset(config, presetName);
|
|
1061
|
+
if (!preset) throw new Error(`agent: preset "${presetName}" not found`);
|
|
1062
|
+
return { presetName, preset };
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function list({ scanSessions = false, context = {} } = {}) {
|
|
1066
|
+
refreshTagsFromSessions({ scanSessions, context });
|
|
1067
|
+
const now = Date.now();
|
|
1068
|
+
const rows = [];
|
|
1069
|
+
for (const { tag, session } of agentSessionEntries({ scanSessions, context })) {
|
|
1070
|
+
const sessionId = session.id;
|
|
1071
|
+
const runtime = mgr.getSessionRuntime?.(sessionId);
|
|
1072
|
+
const status = session.closed === true ? 'closed' : (session.status || 'idle');
|
|
1073
|
+
const stage = session.stage || (status === 'idle' || status === 'error' || status === 'closed'
|
|
1074
|
+
? status
|
|
1075
|
+
: (runtime?.stage || status));
|
|
1076
|
+
const progress = sessionProgressExtras(sessionId, session.role || null, now);
|
|
1077
|
+
rows.push({
|
|
1078
|
+
tag,
|
|
1079
|
+
sessionId,
|
|
1080
|
+
role: session.role || null,
|
|
1081
|
+
provider: session.provider,
|
|
1082
|
+
model: session.model,
|
|
1083
|
+
preset: session.presetName || null,
|
|
1084
|
+
effort: session.effort || null,
|
|
1085
|
+
fast: session.fast === true,
|
|
1086
|
+
status,
|
|
1087
|
+
stage,
|
|
1088
|
+
...progress,
|
|
1089
|
+
createdAt: session.createdAt || null,
|
|
1090
|
+
updatedAt: session.updatedAt || null,
|
|
1091
|
+
lastUsedAt: session.lastUsedAt || null,
|
|
1092
|
+
clientHostPid: session.clientHostPid || null,
|
|
1093
|
+
lastStreamDeltaAt: runtime?.lastStreamDeltaAt ? new Date(runtime.lastStreamDeltaAt).toISOString() : null,
|
|
1094
|
+
staleSeconds: runtime?.lastStreamDeltaAt ? Math.floor((now - runtime.lastStreamDeltaAt) / 1000) : null,
|
|
1095
|
+
windowTokens: Number(session.lastContextTokens ?? session.lastInputTokens) || 0,
|
|
1096
|
+
windowCap: Number(session.contextWindow) || null,
|
|
1097
|
+
permission: session.permission || null,
|
|
1098
|
+
toolPermission: session.toolPermission || null,
|
|
1099
|
+
messages: Array.isArray(session.messages) ? session.messages.length : Math.max(0, Number(session.messageCount || 0)),
|
|
1100
|
+
tools: Array.isArray(session.tools) ? session.tools.length : Math.max(0, Number(session.toolCount || 0)),
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
return rows;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function sessionProgressExtras(sessionId, role, now = Date.now(), taskStatus = null) {
|
|
1107
|
+
if (!sessionId) return {};
|
|
1108
|
+
const session = mgr.getSession(sessionId);
|
|
1109
|
+
const runtime = mgr.getSessionRuntime?.(sessionId) || null;
|
|
1110
|
+
const snapshot = typeof mgr.getSessionProgressSnapshot === 'function'
|
|
1111
|
+
? mgr.getSessionProgressSnapshot(sessionId)
|
|
1112
|
+
: null;
|
|
1113
|
+
const policy = role ? resolveAgentWatchdogPolicy(role) : null;
|
|
1114
|
+
const queuedFollowups = typeof mgr.getSessionPendingMessageDepth === 'function'
|
|
1115
|
+
? mgr.getSessionPendingMessageDepth(sessionId)
|
|
1116
|
+
: null;
|
|
1117
|
+
return buildAgentTaskProgressFields({
|
|
1118
|
+
now,
|
|
1119
|
+
sessionStatus: session?.status || null,
|
|
1120
|
+
runtimeStage: runtime?.stage || snapshot?.stage || session?.status || null,
|
|
1121
|
+
snapshot,
|
|
1122
|
+
runtime,
|
|
1123
|
+
policy,
|
|
1124
|
+
queuedFollowups,
|
|
1125
|
+
taskStatus,
|
|
1126
|
+
lastToolCall: runtime?.lastToolCall || null,
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function jobWorkerSnapshot(sessionId) {
|
|
1131
|
+
if (!sessionId) return null;
|
|
1132
|
+
const session = mgr.getSession(sessionId);
|
|
1133
|
+
if (!session) return null;
|
|
1134
|
+
const runtime = mgr.getSessionRuntime?.(sessionId);
|
|
1135
|
+
const status = session.closed === true ? 'closed' : (session.status || 'idle');
|
|
1136
|
+
const progress = sessionProgressExtras(sessionId, session.role || null);
|
|
1137
|
+
return {
|
|
1138
|
+
workerStatus: status,
|
|
1139
|
+
stage: progress.worker_stage || runtime?.stage || status,
|
|
1140
|
+
clientHostPid: session.clientHostPid || null,
|
|
1141
|
+
lastStreamDeltaAt: runtime?.lastStreamDeltaAt ? new Date(runtime.lastStreamDeltaAt).toISOString() : null,
|
|
1142
|
+
...progress,
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function listJobs(context = {}) {
|
|
1147
|
+
const wantedPid = terminalPidForContext(context);
|
|
1148
|
+
const rows = listBackgroundTasks({ surface: 'agent', context }).map((task) => ({
|
|
1149
|
+
task_id: task.task_id,
|
|
1150
|
+
type: task.operation,
|
|
1151
|
+
status: task.status,
|
|
1152
|
+
tag: task.tag || null,
|
|
1153
|
+
sessionId: task.sessionId || null,
|
|
1154
|
+
role: task.role || null,
|
|
1155
|
+
preset: task.preset || null,
|
|
1156
|
+
provider: task.provider || null,
|
|
1157
|
+
model: task.model || null,
|
|
1158
|
+
effort: task.effort || null,
|
|
1159
|
+
fast: task.fast === true || task.fast === false ? task.fast : null,
|
|
1160
|
+
maxLoopIterations: task.maxLoopIterations || null,
|
|
1161
|
+
startedAt: task.startedAt,
|
|
1162
|
+
finishedAt: task.finishedAt || null,
|
|
1163
|
+
error: task.error || null,
|
|
1164
|
+
...jobWorkerSnapshot(task.sessionId),
|
|
1165
|
+
...sessionProgressExtras(task.sessionId, task.role || null, Date.now(), task.status),
|
|
1166
|
+
}));
|
|
1167
|
+
return wantedPid ? rows.filter((row) => positiveInt(row.clientHostPid) === wantedPid) : rows;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function getJob(args, context = {}) {
|
|
1171
|
+
const taskId = taskIdFromArgs(args);
|
|
1172
|
+
if (!taskId) throw new Error('agent read/status: task_id is required');
|
|
1173
|
+
const task = getBackgroundTask(taskId, { surface: 'agent', context });
|
|
1174
|
+
if (!task) throw new Error(`agent read/status: task "${taskId}" not found`);
|
|
1175
|
+
return task;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function renderJob(job, includeResult = false) {
|
|
1179
|
+
const meta = job.meta || {};
|
|
1180
|
+
let progress = sessionProgressExtras(meta.sessionId, meta.role || null, Date.now(), job.status);
|
|
1181
|
+
// Spawn is deferred: before the worker session exists, sessionProgressExtras
|
|
1182
|
+
// returns {} and the status card would show only "status: running" with no
|
|
1183
|
+
// stage/progress. Fill a minimal stage so the caller can tell the job is
|
|
1184
|
+
// still spinning up rather than silently stalled.
|
|
1185
|
+
if (!meta.sessionId && (!progress || Object.keys(progress).length === 0)) {
|
|
1186
|
+
const spawning = job.status === 'running';
|
|
1187
|
+
progress = {
|
|
1188
|
+
worker_stage: spawning ? 'spawning' : (job.status || 'unknown'),
|
|
1189
|
+
last_progress: spawning ? 'spawning worker session' : (job.status || 'unknown'),
|
|
1190
|
+
diagnostic: spawning ? 'worker session not started yet' : (job.status || 'unknown'),
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
return {
|
|
1194
|
+
task_id: job.taskId,
|
|
1195
|
+
type: job.operation,
|
|
1196
|
+
status: job.status,
|
|
1197
|
+
tag: meta.tag || null,
|
|
1198
|
+
sessionId: meta.sessionId || null,
|
|
1199
|
+
role: meta.role || null,
|
|
1200
|
+
preset: meta.preset || null,
|
|
1201
|
+
provider: meta.provider || null,
|
|
1202
|
+
model: meta.model || null,
|
|
1203
|
+
effort: meta.effort || null,
|
|
1204
|
+
fast: meta.fast === true || meta.fast === false ? meta.fast : null,
|
|
1205
|
+
maxLoopIterations: meta.maxLoopIterations || null,
|
|
1206
|
+
startedAt: job.startedAt,
|
|
1207
|
+
finishedAt: job.finishedAt || null,
|
|
1208
|
+
error: job.error || null,
|
|
1209
|
+
...jobWorkerSnapshot(meta.sessionId),
|
|
1210
|
+
...progress,
|
|
1211
|
+
...(includeResult && job.result !== undefined ? { result: job.result } : {}),
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function preparedSpawnMeta(prepared, extras = {}) {
|
|
1216
|
+
return sanitizeTaskMeta({
|
|
1217
|
+
...(extras || {}),
|
|
1218
|
+
tag: prepared.tag,
|
|
1219
|
+
sessionId: prepared.session.id,
|
|
1220
|
+
role: prepared.role,
|
|
1221
|
+
preset: presetKey(prepared.preset) || prepared.presetName,
|
|
1222
|
+
provider: prepared.preset.provider,
|
|
1223
|
+
model: prepared.preset.model,
|
|
1224
|
+
effort: prepared.preset.effort || null,
|
|
1225
|
+
fast: prepared.preset.fast === true,
|
|
1226
|
+
maxLoopIterations: prepared.maxLoopIterations || null,
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function pendingSpawnMeta(args = {}, extras = {}) {
|
|
1231
|
+
const role = normalizeAgentName(args.agent || args.role);
|
|
1232
|
+
// Best-effort resolve the default preset so the pending "Spawn …" card can
|
|
1233
|
+
// already show the model (e.g. "Spawn Heavy Worker (Opus 4.8)") even when
|
|
1234
|
+
// the caller did not pass an explicit provider/model. Never throw: fall back
|
|
1235
|
+
// to whatever raw args carry.
|
|
1236
|
+
let resolved = null;
|
|
1237
|
+
if (!clean(args.model) || !clean(args.provider)) {
|
|
1238
|
+
try { resolved = resolvePreset(cfgMod.loadConfig(), args)?.preset || null; }
|
|
1239
|
+
catch { resolved = null; }
|
|
1240
|
+
}
|
|
1241
|
+
return sanitizeTaskMeta({
|
|
1242
|
+
...(extras || {}),
|
|
1243
|
+
tag: clean(args.tag) || null,
|
|
1244
|
+
sessionId: null,
|
|
1245
|
+
role: role || null,
|
|
1246
|
+
preset: clean(args.preset) || presetKey(resolved) || null,
|
|
1247
|
+
provider: clean(args.provider) || clean(resolved?.provider) || null,
|
|
1248
|
+
model: clean(args.model) || clean(resolved?.model) || null,
|
|
1249
|
+
effort: clean(args.effort) || clean(resolved?.effort) || null,
|
|
1250
|
+
fast: args.fast === true ? true : (resolved?.fast === true ? true : null),
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function mergeJobMeta(job, meta = {}) {
|
|
1255
|
+
if (!job || !meta || typeof meta !== 'object') return;
|
|
1256
|
+
const next = sanitizeTaskMeta({ ...(job.meta || {}), ...meta });
|
|
1257
|
+
job.meta = next;
|
|
1258
|
+
if (job.input && typeof job.input === 'object') {
|
|
1259
|
+
job.input = {
|
|
1260
|
+
...job.input,
|
|
1261
|
+
tag: next.tag || job.input.tag || null,
|
|
1262
|
+
sessionId: next.sessionId || job.input.sessionId || null,
|
|
1263
|
+
role: next.role || job.input.role || null,
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
job.label = next.tag || next.sessionId || job.label;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function closePreparedSpawn(prepared, reason = 'agent-task-cancel') {
|
|
1270
|
+
if (!prepared?.session?.id) return;
|
|
1271
|
+
try { mgr.closeSession(prepared.session.id, reason); } catch {}
|
|
1272
|
+
try { clearAgentStatuslineRoute(prepared.session.id); } catch {}
|
|
1273
|
+
if (prepared.tag) forgetTag(prepared.tag);
|
|
1274
|
+
removeWorkerRow({ tag: prepared.tag, sessionId: prepared.session.id });
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function enqueueCompletionMessage(sessionId, text, meta = {}) {
|
|
1278
|
+
const target = clean(sessionId);
|
|
1279
|
+
if (!target || typeof mgr.enqueuePendingMessage !== 'function') return false;
|
|
1280
|
+
try {
|
|
1281
|
+
const visible = modelVisibleToolCompletionMessage(text, meta);
|
|
1282
|
+
return Boolean(visible && mgr.enqueuePendingMessage(target, visible) > 0);
|
|
1283
|
+
} catch {
|
|
1284
|
+
return false;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Wire the canonical completion fallback to this agent surface's owner-session
|
|
1289
|
+
// enqueue so notifyTaskCompletion can deliver via callerSessionId when no
|
|
1290
|
+
// notifyFn is present or it declines. Registered once per agent (the closure
|
|
1291
|
+
// captures mgr); signatures align: (callerSessionId, message, meta).
|
|
1292
|
+
setBackgroundTaskEnqueueFallback((sessionId, text, meta) => enqueueCompletionMessage(sessionId, text, meta));
|
|
1293
|
+
|
|
1294
|
+
function workerNotifyFn(workerSessionId, notifyContext = {}) {
|
|
1295
|
+
const workerId = clean(workerSessionId);
|
|
1296
|
+
const ownerSessionId = clean(notifyContext?.callerSessionId || notifyContext?.sessionId);
|
|
1297
|
+
const upstream = typeof notifyContext?.notifyFn === 'function' ? notifyContext.notifyFn : null;
|
|
1298
|
+
return (text, meta = {}) => {
|
|
1299
|
+
let ownerDelivered = false;
|
|
1300
|
+
if (upstream) {
|
|
1301
|
+
try {
|
|
1302
|
+
const result = upstream(text, meta);
|
|
1303
|
+
ownerDelivered = result !== false;
|
|
1304
|
+
if (ownerDelivered) Promise.resolve(result).catch(() => {});
|
|
1305
|
+
} catch {
|
|
1306
|
+
ownerDelivered = false;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
if (!ownerDelivered && ownerSessionId) {
|
|
1310
|
+
ownerDelivered = enqueueCompletionMessage(ownerSessionId, text, meta);
|
|
1311
|
+
}
|
|
1312
|
+
const workerDelivered = workerId && workerId !== ownerSessionId
|
|
1313
|
+
? enqueueCompletionMessage(workerId, text, meta)
|
|
1314
|
+
: ownerDelivered;
|
|
1315
|
+
return ownerSessionId ? ownerDelivered : workerDelivered;
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function notifyOwnerAgentCompletionEarly(job, resultValue, notifyContext = {}) {
|
|
1320
|
+
if (!job || job._earlyCompletionNotified === true) return false;
|
|
1321
|
+
const ownerSessionId = clean(notifyContext?.callerSessionId || notifyContext?.sessionId);
|
|
1322
|
+
const upstream = typeof notifyContext?.notifyFn === 'function' ? notifyContext.notifyFn : null;
|
|
1323
|
+
const finishedAt = new Date().toISOString();
|
|
1324
|
+
const snapshot = {
|
|
1325
|
+
...job,
|
|
1326
|
+
status: 'completed',
|
|
1327
|
+
finishedAt,
|
|
1328
|
+
finishedAtMs: Date.now(),
|
|
1329
|
+
result: resultValue,
|
|
1330
|
+
resultType: job.resultType || 'agent_task_result',
|
|
1331
|
+
meta: sanitizeTaskMeta(job.meta || {}),
|
|
1332
|
+
};
|
|
1333
|
+
// An early notification is only a header-only *preview*: it fires before
|
|
1334
|
+
// the worker's session is persisted to signal the running→completed
|
|
1335
|
+
// transition. It deliberately carries NO result body — the canonical
|
|
1336
|
+
// notifyTaskCompletion delivers the body exactly once via the
|
|
1337
|
+
// reconcile/finally path, so omitting it here keeps notifications
|
|
1338
|
+
// exact-once with no duplicate body.
|
|
1339
|
+
const text = renderBackgroundTask(snapshot, { includeResult: false });
|
|
1340
|
+
const meta = {
|
|
1341
|
+
type: snapshot.resultType,
|
|
1342
|
+
execution_surface: 'agent',
|
|
1343
|
+
execution_id: job.taskId || null,
|
|
1344
|
+
status: 'completed',
|
|
1345
|
+
instruction: `The async agent task ${job.taskId || ''} has finished (completed) - review this result in your next step.`,
|
|
1346
|
+
...(ownerSessionId ? { caller_session_id: ownerSessionId } : {}),
|
|
1347
|
+
};
|
|
1348
|
+
let delivered = false;
|
|
1349
|
+
if (upstream) {
|
|
1350
|
+
try {
|
|
1351
|
+
const result = upstream(text, meta);
|
|
1352
|
+
delivered = result !== false;
|
|
1353
|
+
if (delivered) Promise.resolve(result).catch(() => {});
|
|
1354
|
+
} catch {
|
|
1355
|
+
delivered = false;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
if (!delivered && ownerSessionId) {
|
|
1359
|
+
delivered = enqueueCompletionMessage(ownerSessionId, text, meta);
|
|
1360
|
+
}
|
|
1361
|
+
if (delivered) {
|
|
1362
|
+
// Mark only that a header-only preview fired. The canonical
|
|
1363
|
+
// notifyTaskCompletion still owns the single body-carrying notification.
|
|
1364
|
+
job._earlyCompletionNotified = true;
|
|
1365
|
+
}
|
|
1366
|
+
return delivered;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function startJob(type, meta, run, notifyContext = null) {
|
|
1370
|
+
const clientHostPid = terminalPidForContext(notifyContext);
|
|
1371
|
+
const jobMeta = sanitizeTaskMeta({
|
|
1372
|
+
...(meta || {}),
|
|
1373
|
+
...(clientHostPid ? { clientHostPid } : {}),
|
|
1374
|
+
});
|
|
1375
|
+
let task;
|
|
1376
|
+
task = startBackgroundTask({
|
|
1377
|
+
surface: 'agent',
|
|
1378
|
+
operation: type,
|
|
1379
|
+
label: jobMeta?.tag || jobMeta?.sessionId || type,
|
|
1380
|
+
input: { type, tag: jobMeta?.tag || null, sessionId: jobMeta?.sessionId || null, role: jobMeta?.role || null },
|
|
1381
|
+
context: notifyContext,
|
|
1382
|
+
meta: jobMeta,
|
|
1383
|
+
resultType: 'agent_task_result',
|
|
1384
|
+
renderResult: (result) => renderResult(result),
|
|
1385
|
+
cancel: () => {
|
|
1386
|
+
const currentMeta = task?.meta || jobMeta;
|
|
1387
|
+
if (currentMeta?.sessionId) {
|
|
1388
|
+
try { mgr.closeSession(currentMeta.sessionId, 'agent-task-cancel'); } catch {}
|
|
1389
|
+
}
|
|
1390
|
+
},
|
|
1391
|
+
run: async () => {
|
|
1392
|
+
// Yield one macrotask before doing agent work. startBackgroundTask uses
|
|
1393
|
+
// a Promise microtask, which otherwise begins CPU-heavy spawn prep
|
|
1394
|
+
// before the TUI receives/render the "running" result.
|
|
1395
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1396
|
+
if (task?.status === 'cancelled') return null;
|
|
1397
|
+
return await run(task);
|
|
1398
|
+
},
|
|
1399
|
+
});
|
|
1400
|
+
return task;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function startDeferredSpawnJob(args, callerCwd, context, notifyContext, extras = {}) {
|
|
1404
|
+
return startJob('spawn', pendingSpawnMeta(args, extras), async (job) => {
|
|
1405
|
+
// prepareSpawn (ensureProvider/prepareAgentSession) runs before runSpawn
|
|
1406
|
+
// installs its progress watchdog, so guard prep with an internal env-
|
|
1407
|
+
// backed cap rather than exposing per-call timeout knobs on the agent
|
|
1408
|
+
// tool surface.
|
|
1409
|
+
const prepDeadlineMs = DEFAULT_SPAWN_PREP_TIMEOUT_MS;
|
|
1410
|
+
let prepared;
|
|
1411
|
+
if (prepDeadlineMs > 0) {
|
|
1412
|
+
let prepTimer = null;
|
|
1413
|
+
let timedOut = false;
|
|
1414
|
+
// If prep wins the race we use its result. If the timeout wins, the
|
|
1415
|
+
// prepareSpawn promise may still resolve later with a fully-built
|
|
1416
|
+
// session/tag/route — attach a cleanup so the late-arriving prepared is
|
|
1417
|
+
// torn down, otherwise the orphaned tag would collide on re-spawn.
|
|
1418
|
+
const prepPromise = prepareSpawn(args, callerCwd, context);
|
|
1419
|
+
prepPromise.then((late) => {
|
|
1420
|
+
if (timedOut) closePreparedSpawn(late, 'agent-spawn-prep-timeout');
|
|
1421
|
+
}, () => {});
|
|
1422
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
1423
|
+
prepTimer = setTimeout(() => {
|
|
1424
|
+
timedOut = true;
|
|
1425
|
+
reject(new Error(`agent spawn prep timed out (${prepDeadlineMs}ms) before model request`));
|
|
1426
|
+
}, prepDeadlineMs);
|
|
1427
|
+
prepTimer.unref?.();
|
|
1428
|
+
});
|
|
1429
|
+
try {
|
|
1430
|
+
prepared = await Promise.race([prepPromise, timeout]);
|
|
1431
|
+
} finally {
|
|
1432
|
+
if (prepTimer) clearTimeout(prepTimer);
|
|
1433
|
+
}
|
|
1434
|
+
} else {
|
|
1435
|
+
prepared = await prepareSpawn(args, callerCwd, context);
|
|
1436
|
+
}
|
|
1437
|
+
mergeJobMeta(job, preparedSpawnMeta(prepared, extras));
|
|
1438
|
+
upsertWorkerSessionDeferred(prepared.session, prepared.tag, {
|
|
1439
|
+
...preparedSpawnMeta(prepared, extras),
|
|
1440
|
+
status: 'running',
|
|
1441
|
+
stage: 'running',
|
|
1442
|
+
task_id: job.taskId,
|
|
1443
|
+
startedAt: job.startedAt,
|
|
1444
|
+
});
|
|
1445
|
+
if (job?.status === 'cancelled') {
|
|
1446
|
+
closePreparedSpawn(prepared);
|
|
1447
|
+
return null;
|
|
1448
|
+
}
|
|
1449
|
+
return await runSpawn(prepared, notifyContext, job);
|
|
1450
|
+
}, notifyContext);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function startProgressIdleWatchdog(sessionId, watchdogPolicy) {
|
|
1454
|
+
if (!sessionId || !agentWatchdogPolicyActive(watchdogPolicy)) return null;
|
|
1455
|
+
if (typeof mgr.getSessionProgressSnapshot !== 'function' && typeof mgr.getSessionLastProgressAt !== 'function') return null;
|
|
1456
|
+
if (typeof mgr.linkParentSignalToSession !== 'function') return null;
|
|
1457
|
+
const controller = new AbortController();
|
|
1458
|
+
try { mgr.linkParentSignalToSession(sessionId, controller.signal); } catch { return null; }
|
|
1459
|
+
const timer = setInterval(() => {
|
|
1460
|
+
const now = Date.now();
|
|
1461
|
+
const snapshot = typeof mgr.getSessionProgressSnapshot === 'function'
|
|
1462
|
+
? mgr.getSessionProgressSnapshot(sessionId)
|
|
1463
|
+
: null;
|
|
1464
|
+
const abortErr = snapshot
|
|
1465
|
+
? evaluateAgentWatchdogAbort(snapshot, now, watchdogPolicy)
|
|
1466
|
+
: null;
|
|
1467
|
+
if (!abortErr && !snapshot) {
|
|
1468
|
+
const last = mgr.getSessionLastProgressAt(sessionId);
|
|
1469
|
+
if (watchdogPolicy.idleStaleMs > 0 && last && now - last > watchdogPolicy.idleStaleMs) {
|
|
1470
|
+
try { controller.abort(new Error(`agent task stale (${watchdogPolicy.idleStaleMs}ms without progress)`)); } catch {}
|
|
1471
|
+
}
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
if (abortErr) {
|
|
1475
|
+
try { controller.abort(abortErr); } catch {}
|
|
1476
|
+
}
|
|
1477
|
+
}, 1000);
|
|
1478
|
+
timer.unref?.();
|
|
1479
|
+
return {
|
|
1480
|
+
stop: () => {
|
|
1481
|
+
try { clearInterval(timer); } catch {}
|
|
1482
|
+
},
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
async function prepareSpawn(args, callerCwd = null, context = {}) {
|
|
1487
|
+
refreshTagsFromSessions({ context });
|
|
1488
|
+
const config = cfgMod.loadConfig();
|
|
1489
|
+
const role = normalizeAgentName(args.agent || args.role);
|
|
1490
|
+
if (!role) throw new Error('agent spawn: agent is required');
|
|
1491
|
+
const agentPermission = readAgentFrontmatterPermission(role, dataDir);
|
|
1492
|
+
const rolePermission = normalizeAgentPermission(agentPermission) || null;
|
|
1493
|
+
const { presetName, preset } = resolvePreset(config, args);
|
|
1494
|
+
await ensureProvider(config, preset.provider);
|
|
1495
|
+
|
|
1496
|
+
const tag = clean(args.tag) || nextTag(role, context);
|
|
1497
|
+
// Any resolved same-tag binding in this terminal (live or lingering trace)
|
|
1498
|
+
// blocks a fresh spawn. execute() routes live reuse before prepareSpawn.
|
|
1499
|
+
if (resolveTag(tag, context, { scanSessions: wantsSessionScan(args) })) {
|
|
1500
|
+
throw new Error(`agent spawn: tag "${tag}" already exists`);
|
|
1501
|
+
}
|
|
1502
|
+
const baseCwd = resolve(callerCwd || defaultCwd || process.cwd());
|
|
1503
|
+
const workerCwd = clean(args.cwd) ? resolve(baseCwd, args.cwd) : baseCwd;
|
|
1504
|
+
const prompt = withCwdHeader(await resolvePrompt(args, workerCwd), workerCwd);
|
|
1505
|
+
const runtimeSpec = cfgMod.resolveRuntimeSpec(preset, { lane: 'agent', agentId: tag });
|
|
1506
|
+
const maxLoopIterations = positiveInt(args.maxLoopIterations) || null;
|
|
1507
|
+
const watchdogPolicy = resolveAgentWatchdogPolicy(role);
|
|
1508
|
+
const { session, effectiveCwd } = prepareAgentSession({
|
|
1509
|
+
role,
|
|
1510
|
+
presetName,
|
|
1511
|
+
preset,
|
|
1512
|
+
runtimeSpec,
|
|
1513
|
+
owner: AGENT_OWNER,
|
|
1514
|
+
cwd: workerCwd,
|
|
1515
|
+
sourceType: 'cli',
|
|
1516
|
+
sourceName: role,
|
|
1517
|
+
parentSessionId: clean(context?.callerSessionId || context?.sessionId) || null,
|
|
1518
|
+
ownerSessionId: clean(context?.callerSessionId || context?.sessionId) || null,
|
|
1519
|
+
clientHostPid: terminalPidForContext(context) || null,
|
|
1520
|
+
agentTag: tag,
|
|
1521
|
+
taskType: clean(args.taskType) || clean(args.typeHint) || undefined,
|
|
1522
|
+
maxLoopIterations: maxLoopIterations || undefined,
|
|
1523
|
+
permission: rolePermission || undefined,
|
|
1524
|
+
cacheKeyOverride: args.cacheKey || undefined,
|
|
1525
|
+
});
|
|
1526
|
+
// Lead sessions write a gateway-session route when created; agent sessions
|
|
1527
|
+
// are built through prepareAgentSession(), so mirror that registration here
|
|
1528
|
+
// or the vendored L1/L2 statusline cannot resolve the agent route/model.
|
|
1529
|
+
writeAgentStatuslineRoute(session.id, preset);
|
|
1530
|
+
bindTag(tag, session, {
|
|
1531
|
+
role,
|
|
1532
|
+
preset: presetKey(preset) || presetName,
|
|
1533
|
+
provider: preset.provider,
|
|
1534
|
+
model: preset.model,
|
|
1535
|
+
effort: preset.effort || null,
|
|
1536
|
+
fast: preset.fast === true,
|
|
1537
|
+
status: 'idle',
|
|
1538
|
+
stage: 'idle',
|
|
1539
|
+
});
|
|
1540
|
+
cancelReap(session.id);
|
|
1541
|
+
return {
|
|
1542
|
+
args,
|
|
1543
|
+
tag,
|
|
1544
|
+
session,
|
|
1545
|
+
role,
|
|
1546
|
+
preset,
|
|
1547
|
+
presetName,
|
|
1548
|
+
workerCwd: effectiveCwd || workerCwd,
|
|
1549
|
+
prompt,
|
|
1550
|
+
maxLoopIterations,
|
|
1551
|
+
watchdogPolicy,
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
async function runSpawn(prepared, notifyContext = null, job = null) {
|
|
1556
|
+
const { args, tag, session, role, preset, presetName, workerCwd, prompt, watchdogPolicy } = prepared;
|
|
1557
|
+
const watchdog = startProgressIdleWatchdog(session.id, watchdogPolicy);
|
|
1558
|
+
let finalStatus = 'idle';
|
|
1559
|
+
upsertWorkerSessionDeferred(session, tag, {
|
|
1560
|
+
role,
|
|
1561
|
+
preset: presetKey(preset) || presetName,
|
|
1562
|
+
provider: preset.provider,
|
|
1563
|
+
model: preset.model,
|
|
1564
|
+
effort: preset.effort || null,
|
|
1565
|
+
fast: preset.fast === true,
|
|
1566
|
+
status: 'running',
|
|
1567
|
+
stage: 'running',
|
|
1568
|
+
});
|
|
1569
|
+
try {
|
|
1570
|
+
const completionValue = (result) => ({
|
|
1571
|
+
tag,
|
|
1572
|
+
sessionId: session.id,
|
|
1573
|
+
role,
|
|
1574
|
+
preset: presetKey(preset) || presetName,
|
|
1575
|
+
provider: preset.provider,
|
|
1576
|
+
model: preset.model,
|
|
1577
|
+
effort: preset.effort || null,
|
|
1578
|
+
fast: preset.fast === true,
|
|
1579
|
+
content: result?.content || '',
|
|
1580
|
+
});
|
|
1581
|
+
const result = await mgr.askSession(session.id, prompt, args.context || null, null, workerCwd, null, {
|
|
1582
|
+
notifyFn: workerNotifyFn(session.id, notifyContext || {}),
|
|
1583
|
+
...(job ? {
|
|
1584
|
+
onTerminalResult: (terminalResult) => {
|
|
1585
|
+
const value = completionValue(terminalResult);
|
|
1586
|
+
if (job) job._terminalResultValue = value;
|
|
1587
|
+
notifyOwnerAgentCompletionEarly(job, value, notifyContext || {});
|
|
1588
|
+
// Mark the task terminal the moment the worker produces its final
|
|
1589
|
+
// result, so a hung/slow post-result session save cannot strand the
|
|
1590
|
+
// task (and the status card) in `running`. Idempotent; the finally
|
|
1591
|
+
// reconcile remains a backup for the error/no-terminal-result path.
|
|
1592
|
+
if (job?.taskId) {
|
|
1593
|
+
try {
|
|
1594
|
+
reconcileBackgroundTask(job.taskId, {
|
|
1595
|
+
status: 'completed',
|
|
1596
|
+
result: value,
|
|
1597
|
+
terminalReason: 'agent-terminal-result',
|
|
1598
|
+
});
|
|
1599
|
+
} catch {}
|
|
1600
|
+
}
|
|
1601
|
+
},
|
|
1602
|
+
} : {}),
|
|
1603
|
+
});
|
|
1604
|
+
// The early preview no longer promises body suppression, so the canonical
|
|
1605
|
+
// notifyTaskCompletion is left to fire exactly once with output via the
|
|
1606
|
+
// resolve/reconcile/finally path.
|
|
1607
|
+
return completionValue(result);
|
|
1608
|
+
} catch (error) {
|
|
1609
|
+
finalStatus = 'error';
|
|
1610
|
+
throw error;
|
|
1611
|
+
} finally {
|
|
1612
|
+
watchdog?.stop?.();
|
|
1613
|
+
upsertWorkerSessionDeferred(session, tag, {
|
|
1614
|
+
role,
|
|
1615
|
+
preset: presetKey(preset) || presetName,
|
|
1616
|
+
provider: preset.provider,
|
|
1617
|
+
model: preset.model,
|
|
1618
|
+
effort: preset.effort || null,
|
|
1619
|
+
fast: preset.fast === true,
|
|
1620
|
+
status: finalStatus,
|
|
1621
|
+
stage: finalStatus,
|
|
1622
|
+
finishedAt: new Date().toISOString(),
|
|
1623
|
+
});
|
|
1624
|
+
// Safety net: if a post-result step (session save) hung or threw after the
|
|
1625
|
+
// worker already produced a terminal result, the task could otherwise be
|
|
1626
|
+
// stranded in `running`. Reconcile it to a terminal state using the
|
|
1627
|
+
// captured result so the owner gets a completion notification + the
|
|
1628
|
+
// statusline clears. Idempotent once the task is already terminal.
|
|
1629
|
+
if (job && job._terminalResultValue !== undefined) {
|
|
1630
|
+
try {
|
|
1631
|
+
reconcileBackgroundTask(job.taskId, {
|
|
1632
|
+
status: finalStatus === 'error' ? 'failed' : 'completed',
|
|
1633
|
+
result: job._terminalResultValue,
|
|
1634
|
+
terminalReason: 'agent-finally-reconcile',
|
|
1635
|
+
});
|
|
1636
|
+
} catch {}
|
|
1637
|
+
}
|
|
1638
|
+
scheduleReap(session.id);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
async function spawn(args) {
|
|
1643
|
+
return await runSpawn(await prepareSpawn(args));
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
async function prepareSend(args, context = {}) {
|
|
1647
|
+
refreshTagsFromSessions({ scanSessions: wantsSessionScan(args), context });
|
|
1648
|
+
const target = clean(args.tag || args.sessionId);
|
|
1649
|
+
if (!target) throw new Error('agent send: tag or sessionId is required');
|
|
1650
|
+
const sessionId = resolveTag(target, context, { scanSessions: wantsSessionScan(args) });
|
|
1651
|
+
if (!sessionId) throw new Error(`agent send: target "${target}" not found`);
|
|
1652
|
+
const session = mgr.getSession(sessionId);
|
|
1653
|
+
if (!session || session.closed) throw new Error(`agent send: session "${sessionId}" is closed`);
|
|
1654
|
+
cancelReap(sessionId);
|
|
1655
|
+
const prompt = await resolvePrompt(args, session.cwd || defaultCwd);
|
|
1656
|
+
return { args, session, sessionId, prompt };
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
async function runSend(prepared, notifyContext = null, job = null) {
|
|
1660
|
+
const { args, session, sessionId, prompt } = prepared;
|
|
1661
|
+
const sendRole = session.role || normalizeAgentName(args.agent || args.role);
|
|
1662
|
+
const watchdog = startProgressIdleWatchdog(sessionId, resolveAgentWatchdogPolicy(sendRole));
|
|
1663
|
+
const tag = tagForSession(sessionId);
|
|
1664
|
+
let finalStatus = 'idle';
|
|
1665
|
+
upsertWorkerSessionDeferred(session, tag, { status: 'running', stage: 'running' });
|
|
1666
|
+
try {
|
|
1667
|
+
const completionValue = (result) => ({
|
|
1668
|
+
tag,
|
|
1669
|
+
sessionId,
|
|
1670
|
+
role: session.role || null,
|
|
1671
|
+
provider: session.provider,
|
|
1672
|
+
model: session.model,
|
|
1673
|
+
content: result?.content || '',
|
|
1674
|
+
});
|
|
1675
|
+
const result = await mgr.askSession(sessionId, prompt, args.context || null, null, session.cwd || defaultCwd, null, {
|
|
1676
|
+
notifyFn: workerNotifyFn(sessionId, notifyContext || {}),
|
|
1677
|
+
...(job ? {
|
|
1678
|
+
onTerminalResult: (terminalResult) => {
|
|
1679
|
+
const value = completionValue(terminalResult);
|
|
1680
|
+
if (job) job._terminalResultValue = value;
|
|
1681
|
+
notifyOwnerAgentCompletionEarly(job, value, notifyContext || {});
|
|
1682
|
+
// Mark terminal as soon as the worker's final result lands so a slow
|
|
1683
|
+
// post-result save can't strand the task in `running`. Idempotent.
|
|
1684
|
+
if (job?.taskId) {
|
|
1685
|
+
try {
|
|
1686
|
+
reconcileBackgroundTask(job.taskId, {
|
|
1687
|
+
status: 'completed',
|
|
1688
|
+
result: value,
|
|
1689
|
+
terminalReason: 'agent-terminal-result',
|
|
1690
|
+
});
|
|
1691
|
+
} catch {}
|
|
1692
|
+
}
|
|
1693
|
+
},
|
|
1694
|
+
} : {}),
|
|
1695
|
+
});
|
|
1696
|
+
// Early preview no longer suppresses the canonical body notification;
|
|
1697
|
+
// notifyTaskCompletion fires once with output via resolve/reconcile.
|
|
1698
|
+
return completionValue(result);
|
|
1699
|
+
} catch (error) {
|
|
1700
|
+
finalStatus = 'error';
|
|
1701
|
+
throw error;
|
|
1702
|
+
} finally {
|
|
1703
|
+
watchdog?.stop?.();
|
|
1704
|
+
upsertWorkerSessionDeferred(session, tag, {
|
|
1705
|
+
status: finalStatus,
|
|
1706
|
+
stage: finalStatus,
|
|
1707
|
+
finishedAt: new Date().toISOString(),
|
|
1708
|
+
});
|
|
1709
|
+
// Safety net mirror of runSpawn: reconcile a stranded task if a post-result
|
|
1710
|
+
// step hung/threw after a terminal result was already produced.
|
|
1711
|
+
if (job && job._terminalResultValue !== undefined) {
|
|
1712
|
+
try {
|
|
1713
|
+
reconcileBackgroundTask(job.taskId, {
|
|
1714
|
+
status: finalStatus === 'error' ? 'failed' : 'completed',
|
|
1715
|
+
result: job._terminalResultValue,
|
|
1716
|
+
terminalReason: 'agent-finally-reconcile',
|
|
1717
|
+
});
|
|
1718
|
+
} catch {}
|
|
1719
|
+
}
|
|
1720
|
+
scheduleReap(sessionId);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
async function send(args) {
|
|
1725
|
+
return await runSend(await prepareSend(args));
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
// Shared send dispatch for an already-resolved live session. Used by the
|
|
1729
|
+
// `send` branch AND by the `spawn` branch when an explicit tag maps to a
|
|
1730
|
+
// live session (reuse path). Busy sessions queue the prompt; idle ones run a
|
|
1731
|
+
// background send job that continues the existing session (context kept).
|
|
1732
|
+
function dispatchToExistingSession(prepared, notifyContext, extras = {}) {
|
|
1733
|
+
if (isSessionBusy(prepared.sessionId) && typeof mgr.enqueuePendingMessage === 'function') {
|
|
1734
|
+
const queueDepth = mgr.enqueuePendingMessage(prepared.sessionId, prepared.prompt);
|
|
1735
|
+
return renderResult({
|
|
1736
|
+
queued: true,
|
|
1737
|
+
...extras,
|
|
1738
|
+
tag: tagForSession(prepared.sessionId),
|
|
1739
|
+
sessionId: prepared.sessionId,
|
|
1740
|
+
role: prepared.session.role || null,
|
|
1741
|
+
queueDepth,
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
const job = startJob('send', {
|
|
1745
|
+
tag: tagForSession(prepared.sessionId),
|
|
1746
|
+
sessionId: prepared.sessionId,
|
|
1747
|
+
role: prepared.session.role || null,
|
|
1748
|
+
provider: prepared.session.provider || null,
|
|
1749
|
+
model: prepared.session.model || null,
|
|
1750
|
+
preset: prepared.session.presetName || null,
|
|
1751
|
+
effort: prepared.session.effort || null,
|
|
1752
|
+
fast: prepared.session.fast === true,
|
|
1753
|
+
}, (job) => runSend(prepared, notifyContext, job), notifyContext);
|
|
1754
|
+
return renderResult({ ...extras, ...renderJob(job, false) });
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
function close(args, context = {}) {
|
|
1758
|
+
const scopedContext = agentScope(args, context);
|
|
1759
|
+
refreshTagsFromSessions({ scanSessions: wantsSessionScan(args), context: scopedContext });
|
|
1760
|
+
const taskId = taskIdFromArgs(args);
|
|
1761
|
+
const task = taskId ? getBackgroundTask(taskId, { surface: 'agent', context }) : null;
|
|
1762
|
+
const taskMeta = task?.meta || {};
|
|
1763
|
+
const target = clean(args.tag || args.sessionId || taskMeta.sessionId);
|
|
1764
|
+
if (!target) {
|
|
1765
|
+
if (task?.taskId) {
|
|
1766
|
+
cancelBackgroundTask(task.taskId, 'cancelled by agent close');
|
|
1767
|
+
return { closed: true, tag: taskMeta.tag || null, sessionId: null, task_id: task.taskId };
|
|
1768
|
+
}
|
|
1769
|
+
throw new Error('agent close: tag or sessionId is required');
|
|
1770
|
+
}
|
|
1771
|
+
const sessionId = resolveTag(target, scopedContext, { scanSessions: wantsSessionScan(args) });
|
|
1772
|
+
if (!sessionId) {
|
|
1773
|
+
if (!target.startsWith('sess_') && tagRoles.has(target)) {
|
|
1774
|
+
forgetTag(target);
|
|
1775
|
+
if (task?.taskId) cancelBackgroundTask(task.taskId, 'cancelled by agent close');
|
|
1776
|
+
return { closed: true, forgotten: true, tag: target, sessionId: null, task_id: task?.taskId || null };
|
|
1777
|
+
}
|
|
1778
|
+
throw new Error(`agent close: target "${target}" not found`);
|
|
1779
|
+
}
|
|
1780
|
+
cancelReap(sessionId);
|
|
1781
|
+
const tag = tagForSession(sessionId);
|
|
1782
|
+
if (tag) forgetTag(tag);
|
|
1783
|
+
removeWorkerRow({ tag, sessionId });
|
|
1784
|
+
clearAgentStatuslineRoute(sessionId);
|
|
1785
|
+
const ok = mgr.closeSession(sessionId, 'cli-agent-close');
|
|
1786
|
+
if (task?.taskId) cancelBackgroundTask(task.taskId, 'cancelled by agent close');
|
|
1787
|
+
return { closed: ok, tag, sessionId, task_id: task?.taskId || null };
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
function cleanup(args = {}, context = {}) {
|
|
1791
|
+
const scopedContext = agentScope(args, context);
|
|
1792
|
+
const beforeTags = tags.size;
|
|
1793
|
+
refreshTagsFromSessions({ scanSessions: wantsSessionScan(args), context: scopedContext });
|
|
1794
|
+
const cleaned = cleanupBackgroundTasks({ surface: 'agent', context: scopedContext, force: args.force === true });
|
|
1795
|
+
return {
|
|
1796
|
+
tasksRemoved: cleaned.removed,
|
|
1797
|
+
tagsRemoved: beforeTags - tags.size,
|
|
1798
|
+
tasks: listJobs(scopedContext).length,
|
|
1799
|
+
workers: list({ scanSessions: wantsSessionScan(args), context: scopedContext }).length,
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
function closeAll(reason = 'cli-agent-close-all') {
|
|
1804
|
+
refreshTagsFromSessions({ scanSessions: false });
|
|
1805
|
+
const closed = [];
|
|
1806
|
+
const failed = [];
|
|
1807
|
+
for (const { tag, session } of agentSessionEntries({ scanSessions: false, context: {} })) {
|
|
1808
|
+
try {
|
|
1809
|
+
closed.push(close({ sessionId: session.id }));
|
|
1810
|
+
} catch (err) {
|
|
1811
|
+
failed.push({ tag, error: presentErrorText(err, { surface: 'agent' }) });
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
for (const task of listBackgroundTasks({ surface: 'agent' })) {
|
|
1815
|
+
if (task?.status !== 'running') continue;
|
|
1816
|
+
cancelBackgroundTask(task.task_id, reason);
|
|
1817
|
+
closed.push({ closed: true, tag: task.tag || null, sessionId: task.sessionId || null, task_id: task.task_id });
|
|
1818
|
+
}
|
|
1819
|
+
for (const timer of reapTimers.values()) clearTimeout(timer);
|
|
1820
|
+
reapTimers.clear();
|
|
1821
|
+
flushWorkerIndexMutations();
|
|
1822
|
+
writeWorkerRows((byKey) => byKey.clear());
|
|
1823
|
+
return { closed, failed };
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// True when a tag has a lingering worker-index / role trace but no live
|
|
1827
|
+
// session in this terminal (finished worker still inside the reap grace window).
|
|
1828
|
+
function hasTerminalTrace(tag, context = {}) {
|
|
1829
|
+
const value = clean(tag);
|
|
1830
|
+
if (!value || value.startsWith('sess_')) return false;
|
|
1831
|
+
if (resolveTag(value, context, { excludeTerminalTraces: true })) return false; // live -> reuse, not trace
|
|
1832
|
+
if (tagRoles.has(value)) return true;
|
|
1833
|
+
return readWorkerRows(context).some((row) => clean(row.tag) === value);
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
function terminalTraceSpawnError(tag) {
|
|
1837
|
+
return new Error(`agent spawn: tag "${tag}" refers to a finished or closed worker; wait for reap or use a new tag`);
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
async function execute(args = {}, context = {}) {
|
|
1841
|
+
try {
|
|
1842
|
+
const type = clean(args.type) || 'spawn';
|
|
1843
|
+
const callerCwd = clean(context.cwd || context.callerCwd);
|
|
1844
|
+
const scopedContext = agentScope(args, context);
|
|
1845
|
+
const notifyContext = context;
|
|
1846
|
+
if (type === 'list') return renderResult({ workers: list({ scanSessions: wantsSessionScan(args), context: scopedContext }), jobs: listJobs(scopedContext) });
|
|
1847
|
+
if (type === 'status') return renderResult(renderJob(getJob(args, scopedContext), false));
|
|
1848
|
+
if (type === 'read') return renderResult(renderJob(getJob(args, scopedContext), true));
|
|
1849
|
+
if (type === 'cleanup') return renderResult(cleanup(args, scopedContext));
|
|
1850
|
+
if (type === 'cancel') return renderResult(close(args, scopedContext));
|
|
1851
|
+
if (type === 'close') return renderResult(close(args, scopedContext));
|
|
1852
|
+
if (type === 'send') {
|
|
1853
|
+
const prepared = await prepareSend(args, scopedContext);
|
|
1854
|
+
return dispatchToExistingSession(prepared, notifyContext);
|
|
1855
|
+
}
|
|
1856
|
+
if (type === 'spawn') {
|
|
1857
|
+
// Explicit-tag spawn priority (auto nextTag always creates a fresh session):
|
|
1858
|
+
// 1) live + busy -> queue the prompt (reuse)
|
|
1859
|
+
// 2) live + idle -> continue existing session (reuse)
|
|
1860
|
+
// 3) lingering terminal trace -> error (no defensive respawn)
|
|
1861
|
+
// 4) genuinely new tag -> fresh deferred spawn
|
|
1862
|
+
const explicitTag = clean(args.tag);
|
|
1863
|
+
if (explicitTag) {
|
|
1864
|
+
// Resolve a LIVE same-tag session in this terminal (busy or idle).
|
|
1865
|
+
let liveSessionId = null;
|
|
1866
|
+
try {
|
|
1867
|
+
liveSessionId = resolveTag(explicitTag, scopedContext, {
|
|
1868
|
+
scanSessions: wantsSessionScan(args),
|
|
1869
|
+
excludeTerminalTraces: true,
|
|
1870
|
+
});
|
|
1871
|
+
} catch {
|
|
1872
|
+
// Ambiguous across terminals — fall through to the normal spawn
|
|
1873
|
+
// path which surfaces the same error consistently.
|
|
1874
|
+
liveSessionId = null;
|
|
1875
|
+
}
|
|
1876
|
+
if (liveSessionId && getLiveSession(liveSessionId)) {
|
|
1877
|
+
// Reuse the existing session via the send path (context preserved).
|
|
1878
|
+
const prepared = await prepareSend({ ...args, tag: explicitTag }, scopedContext);
|
|
1879
|
+
return dispatchToExistingSession(prepared, notifyContext, { reused: true });
|
|
1880
|
+
}
|
|
1881
|
+
if (hasTerminalTrace(explicitTag, scopedContext)) {
|
|
1882
|
+
throw terminalTraceSpawnError(explicitTag);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
const job = startDeferredSpawnJob(args, callerCwd, context, notifyContext);
|
|
1886
|
+
return renderResult(renderJob(job, false));
|
|
1887
|
+
}
|
|
1888
|
+
throw new Error(`agent: unknown type "${type}"`);
|
|
1889
|
+
} catch (err) {
|
|
1890
|
+
return `Error: ${presentErrorText(err, { surface: 'agent' })}`;
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
return {
|
|
1895
|
+
tools: [AGENT_TOOL],
|
|
1896
|
+
execute,
|
|
1897
|
+
getStatus: (context = {}) => {
|
|
1898
|
+
const scopedContext = agentScope({}, context);
|
|
1899
|
+
const pid = terminalPidForContext(scopedContext);
|
|
1900
|
+
return {
|
|
1901
|
+
workers: list({ scanSessions: false, context: scopedContext }),
|
|
1902
|
+
jobs: listJobs(scopedContext),
|
|
1903
|
+
scope: pid ? { clientHostPid: pid } : { allTerminals: true },
|
|
1904
|
+
};
|
|
1905
|
+
},
|
|
1906
|
+
recoverWorkers: (context = {}) => {
|
|
1907
|
+
const scopedContext = agentScope({ recover: true }, context);
|
|
1908
|
+
refreshTagsFromSessions({ scanSessions: true, context: scopedContext });
|
|
1909
|
+
return list({ scanSessions: false, context: scopedContext });
|
|
1910
|
+
},
|
|
1911
|
+
closeAll,
|
|
1912
|
+
};
|
|
1913
|
+
}
|