@pi-agents/orchid 0.1.0-beta.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/CHANGELOG.md +41 -0
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/agents/AGENTS-MANIFEST.md +42 -0
- package/agents/brain.md +42 -0
- package/agents/context-builder.md +46 -0
- package/agents/delegate.md +12 -0
- package/agents/dev-1.md +42 -0
- package/agents/oracle.md +73 -0
- package/agents/planner.md +55 -0
- package/agents/researcher.md +52 -0
- package/agents/reviewer.md +79 -0
- package/agents/scout.md +50 -0
- package/agents/tester.md +45 -0
- package/agents/worker.md +55 -0
- package/extensions/ralph.ts +1 -0
- package/extensions/reviewer-extension.ts +125 -0
- package/extensions/task-orchestrator.ts +28 -0
- package/package.json +63 -0
- package/prompts/gather-context-and-clarify.md +13 -0
- package/prompts/parallel-cleanup.md +59 -0
- package/prompts/parallel-context-build.md +53 -0
- package/prompts/parallel-handoff-plan.md +59 -0
- package/prompts/parallel-research.md +50 -0
- package/prompts/parallel-review.md +54 -0
- package/prompts/review-loop.md +41 -0
- package/skills/orchid/SKILL.md +214 -0
- package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
- package/skills/orchid/orchid-converge/SKILL.md +124 -0
- package/skills/orchid/orchid-decompose/SKILL.md +201 -0
- package/skills/orchid/orchid-doctor/SKILL.md +162 -0
- package/skills/orchid/orchid-investigate/SKILL.md +102 -0
- package/skills/orchid/orchid-launch/SKILL.md +147 -0
- package/skills/ralph/SKILL.md +73 -0
- package/skills/subagents/pi-subagents/SKILL.md +813 -0
- package/src/index.ts +7 -0
- package/src/orchestrator/abort.ts +534 -0
- package/src/orchestrator/agent-bridge-extension.ts +1020 -0
- package/src/orchestrator/agent-host.ts +954 -0
- package/src/orchestrator/cleanup.ts +776 -0
- package/src/orchestrator/config-loader.ts +1412 -0
- package/src/orchestrator/config-schema.ts +690 -0
- package/src/orchestrator/config.ts +81 -0
- package/src/orchestrator/context-window.ts +66 -0
- package/src/orchestrator/diagnostic-reports.ts +475 -0
- package/src/orchestrator/diagnostics.ts +394 -0
- package/src/orchestrator/discovery.ts +1833 -0
- package/src/orchestrator/engine-worker.ts +415 -0
- package/src/orchestrator/engine.ts +5940 -0
- package/src/orchestrator/execution.ts +3104 -0
- package/src/orchestrator/extension.ts +5934 -0
- package/src/orchestrator/formatting.ts +785 -0
- package/src/orchestrator/git.ts +88 -0
- package/src/orchestrator/index.ts +28 -0
- package/src/orchestrator/lane-runner.ts +1787 -0
- package/src/orchestrator/mailbox.ts +780 -0
- package/src/orchestrator/merge.ts +3414 -0
- package/src/orchestrator/messages.ts +1062 -0
- package/src/orchestrator/migrations.ts +278 -0
- package/src/orchestrator/naming.ts +117 -0
- package/src/orchestrator/path-resolver.ts +275 -0
- package/src/orchestrator/persistence.ts +2625 -0
- package/src/orchestrator/process-registry.ts +452 -0
- package/src/orchestrator/quality-gate.ts +1085 -0
- package/src/orchestrator/resume.ts +3488 -0
- package/src/orchestrator/sessions.ts +57 -0
- package/src/orchestrator/settings-loader.ts +136 -0
- package/src/orchestrator/settings-tui.ts +2208 -0
- package/src/orchestrator/sidecar-telemetry.ts +267 -0
- package/src/orchestrator/supervisor.ts +4548 -0
- package/src/orchestrator/task-executor-core.ts +675 -0
- package/src/orchestrator/tmux-compat.ts +37 -0
- package/src/orchestrator/tool-allowlist-constants.ts +37 -0
- package/src/orchestrator/types.ts +4465 -0
- package/src/orchestrator/verification.ts +547 -0
- package/src/orchestrator/waves.ts +1564 -0
- package/src/orchestrator/workspace.ts +707 -0
- package/src/orchestrator/worktree.ts +2725 -0
- package/src/ralph/index.ts +825 -0
- package/src/subagents/agents/agent-management.ts +648 -0
- package/src/subagents/agents/agent-scope.ts +6 -0
- package/src/subagents/agents/agent-selection.ts +23 -0
- package/src/subagents/agents/agent-serializer.ts +86 -0
- package/src/subagents/agents/agents.ts +832 -0
- package/src/subagents/agents/chain-serializer.ts +137 -0
- package/src/subagents/agents/frontmatter.ts +29 -0
- package/src/subagents/agents/identity.ts +30 -0
- package/src/subagents/agents/skills.ts +632 -0
- package/src/subagents/extension/config.ts +16 -0
- package/src/subagents/extension/control-notices.ts +92 -0
- package/src/subagents/extension/doctor.ts +199 -0
- package/src/subagents/extension/fanout-child.ts +170 -0
- package/src/subagents/extension/index.ts +573 -0
- package/src/subagents/extension/schemas.ts +168 -0
- package/src/subagents/intercom/intercom-bridge.ts +379 -0
- package/src/subagents/intercom/result-intercom.ts +377 -0
- package/src/subagents/runs/background/async-execution.ts +712 -0
- package/src/subagents/runs/background/async-job-tracker.ts +310 -0
- package/src/subagents/runs/background/async-resume.ts +345 -0
- package/src/subagents/runs/background/async-status.ts +325 -0
- package/src/subagents/runs/background/completion-dedupe.ts +63 -0
- package/src/subagents/runs/background/notify.ts +108 -0
- package/src/subagents/runs/background/parallel-groups.ts +45 -0
- package/src/subagents/runs/background/result-watcher.ts +307 -0
- package/src/subagents/runs/background/run-id-resolver.ts +83 -0
- package/src/subagents/runs/background/run-status.ts +269 -0
- package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
- package/src/subagents/runs/background/subagent-runner.ts +1808 -0
- package/src/subagents/runs/background/top-level-async.ts +13 -0
- package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
- package/src/subagents/runs/foreground/chain-execution.ts +938 -0
- package/src/subagents/runs/foreground/execution.ts +918 -0
- package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
- package/src/subagents/runs/shared/completion-guard.ts +147 -0
- package/src/subagents/runs/shared/long-running-guard.ts +175 -0
- package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
- package/src/subagents/runs/shared/model-fallback.ts +103 -0
- package/src/subagents/runs/shared/nested-events.ts +819 -0
- package/src/subagents/runs/shared/nested-path.ts +52 -0
- package/src/subagents/runs/shared/nested-render.ts +115 -0
- package/src/subagents/runs/shared/parallel-utils.ts +109 -0
- package/src/subagents/runs/shared/pi-args.ts +220 -0
- package/src/subagents/runs/shared/pi-spawn.ts +115 -0
- package/src/subagents/runs/shared/run-history.ts +60 -0
- package/src/subagents/runs/shared/single-output.ts +164 -0
- package/src/subagents/runs/shared/subagent-control.ts +226 -0
- package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
- package/src/subagents/runs/shared/worktree.ts +577 -0
- package/src/subagents/shared/artifacts.ts +98 -0
- package/src/subagents/shared/atomic-json.ts +16 -0
- package/src/subagents/shared/file-coalescer.ts +40 -0
- package/src/subagents/shared/fork-context.ts +76 -0
- package/src/subagents/shared/formatters.ts +133 -0
- package/src/subagents/shared/jsonl-writer.ts +81 -0
- package/src/subagents/shared/model-info.ts +78 -0
- package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
- package/src/subagents/shared/session-identity.ts +10 -0
- package/src/subagents/shared/session-tokens.ts +44 -0
- package/src/subagents/shared/settings.ts +397 -0
- package/src/subagents/shared/status-format.ts +49 -0
- package/src/subagents/shared/types.ts +822 -0
- package/src/subagents/shared/utils.ts +450 -0
- package/src/subagents/slash/prompt-template-bridge.ts +397 -0
- package/src/subagents/slash/slash-bridge.ts +174 -0
- package/src/subagents/slash/slash-commands.ts +528 -0
- package/src/subagents/slash/slash-live-state.ts +292 -0
- package/src/subagents/tui/render-helpers.ts +80 -0
- package/src/subagents/tui/render.ts +1358 -0
- package/templates/agents/local/supervisor.md +33 -0
- package/templates/agents/local/task-merger.md +27 -0
- package/templates/agents/local/task-reviewer.md +30 -0
- package/templates/agents/local/task-worker.md +34 -0
- package/templates/agents/supervisor-routing.md +92 -0
- package/templates/agents/supervisor.md +229 -0
- package/templates/agents/task-merger.md +214 -0
- package/templates/agents/task-reviewer.md +260 -0
- package/templates/agents/task-worker-segment.md +44 -0
- package/templates/agents/task-worker.md +557 -0
- package/templates/tasks/CONTEXT.md +30 -0
- package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
- package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
- package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
- package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -0
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Host — Direct-child Pi agent hosting for Runtime V2
|
|
3
|
+
*
|
|
4
|
+
* Spawns `pi --mode rpc` as a direct child process (no terminal multiplexer, no shell),
|
|
5
|
+
* parses RPC JSONL events, normalizes them into RuntimeAgentEvents,
|
|
6
|
+
* manages mailbox delivery, and produces exit summaries.
|
|
7
|
+
*
|
|
8
|
+
* This replaces the legacy terminal-session hosting path with
|
|
9
|
+
* a programmatic parent-child model where the caller has full process
|
|
10
|
+
* ownership.
|
|
11
|
+
*
|
|
12
|
+
* Key differences from the legacy path:
|
|
13
|
+
* 1. No terminal-session backend — `spawn()` with `shell: false`
|
|
14
|
+
* 2. No sidecar tailing — events flow directly to the caller via callbacks
|
|
15
|
+
* 3. No PID-file orphan guessing — caller owns the process handle
|
|
16
|
+
* 4. Registry integration — manifests updated on status transitions
|
|
17
|
+
* 5. Pi CLI resolved to JS entrypoint, not .CMD shim
|
|
18
|
+
*
|
|
19
|
+
* @module orchid/agent-host
|
|
20
|
+
* @since TP-104
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawn, type ChildProcess } from "child_process";
|
|
24
|
+
import {
|
|
25
|
+
readFileSync,
|
|
26
|
+
writeFileSync,
|
|
27
|
+
appendFileSync,
|
|
28
|
+
mkdirSync,
|
|
29
|
+
existsSync,
|
|
30
|
+
readdirSync,
|
|
31
|
+
renameSync,
|
|
32
|
+
} from "fs";
|
|
33
|
+
import { join, dirname, basename, resolve } from "path";
|
|
34
|
+
import { StringDecoder } from "string_decoder";
|
|
35
|
+
|
|
36
|
+
import type {
|
|
37
|
+
RuntimeAgentId,
|
|
38
|
+
RuntimeAgentRole,
|
|
39
|
+
RuntimeAgentEvent,
|
|
40
|
+
RuntimeAgentEventType,
|
|
41
|
+
RuntimeAgentManifest,
|
|
42
|
+
PacketPaths,
|
|
43
|
+
} from "./types.ts";
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
createManifest,
|
|
47
|
+
writeManifest,
|
|
48
|
+
updateManifestStatus,
|
|
49
|
+
buildRegistrySnapshot,
|
|
50
|
+
writeRegistrySnapshot,
|
|
51
|
+
} from "./process-registry.ts";
|
|
52
|
+
import { appendMailboxAuditEvent } from "./mailbox.ts";
|
|
53
|
+
import { resolvePiCliPath } from "./path-resolver.ts";
|
|
54
|
+
|
|
55
|
+
// ── Pi CLI Resolution ────────────────────────────────────────────────
|
|
56
|
+
// resolvePiCliPath() is imported from path-resolver.ts and re-exported below (TP-157)
|
|
57
|
+
|
|
58
|
+
export { resolvePiCliPath };
|
|
59
|
+
|
|
60
|
+
// ── Worker Tools Allowlist (TP-184) ─────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Engine-internal tools that the orchestrator's bridge extension
|
|
64
|
+
* (`agent-bridge-extension.ts`) registers for every spawned worker. These
|
|
65
|
+
* tools are coordination primitives owned by OrchID, NOT user-facing
|
|
66
|
+
* capabilities, so they must be present in the worker's `--tools` allowlist
|
|
67
|
+
* regardless of what `taskRunner.worker.tools` is configured to.
|
|
68
|
+
*
|
|
69
|
+
* If a worker is spawned without one of these tools in its allowlist, pi's
|
|
70
|
+
* tool gate filters the registered tool out and the matching feature
|
|
71
|
+
* silently no-ops:
|
|
72
|
+
* - `review_step`: plan/code/test reviews never fire at
|
|
73
|
+
* any Review Level >= 1
|
|
74
|
+
* - `notify_supervisor`: worker cannot reply to supervisor
|
|
75
|
+
* steering messages
|
|
76
|
+
* - `escalate_to_supervisor`: worker cannot escalate blockers or
|
|
77
|
+
* ambiguity to the supervisor/operator
|
|
78
|
+
* - `request_segment_expansion`: multi-repo segment expansion
|
|
79
|
+
* unreachable (the request file IPC is
|
|
80
|
+
* never written)
|
|
81
|
+
*
|
|
82
|
+
* Keep this list in sync with the registrations in
|
|
83
|
+
* `agent-bridge-extension.ts` (lines ~137, 180, 230, 599).
|
|
84
|
+
*
|
|
85
|
+
* @see https://github.com/claude-code-swe/OrchID/issues/530
|
|
86
|
+
* @since TP-184
|
|
87
|
+
*/
|
|
88
|
+
export const ENGINE_BRIDGE_TOOLS = [
|
|
89
|
+
"review_step",
|
|
90
|
+
"notify_supervisor",
|
|
91
|
+
"escalate_to_supervisor",
|
|
92
|
+
"request_segment_expansion",
|
|
93
|
+
] as const;
|
|
94
|
+
|
|
95
|
+
// TP-189 (Cluster B): `DEFAULT_WORKER_USER_TOOLS` now lives in the
|
|
96
|
+
// import-free `./tool-allowlist-constants.ts` module so that pure-data
|
|
97
|
+
// layers (`config-schema.ts`, `types.ts`) can import it without pulling
|
|
98
|
+
// agent-host's heavy `child_process`/`fs` imports into the schema/type
|
|
99
|
+
// graph. We re-export here so existing internal imports (e.g.,
|
|
100
|
+
// `execution.ts`, `worker-tools-allowlist.test.ts`) continue to work
|
|
101
|
+
// without churn.
|
|
102
|
+
//
|
|
103
|
+
// @since TP-184 (constant introduced) / TP-189 (moved to constants module)
|
|
104
|
+
export { DEFAULT_WORKER_USER_TOOLS } from "./tool-allowlist-constants.ts";
|
|
105
|
+
import { DEFAULT_WORKER_USER_TOOLS } from "./tool-allowlist-constants.ts";
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build the final worker `--tools` allowlist string by combining the
|
|
109
|
+
* user-tools portion (from config or {@link DEFAULT_WORKER_USER_TOOLS}) with
|
|
110
|
+
* {@link ENGINE_BRIDGE_TOOLS} (always appended, deduplicated).
|
|
111
|
+
*
|
|
112
|
+
* Semantics:
|
|
113
|
+
* - `null` / `undefined` / empty / whitespace-only input → falls back to
|
|
114
|
+
* {@link DEFAULT_WORKER_USER_TOOLS}
|
|
115
|
+
* - Non-empty input → split on `,`, trim each entry, drop empties
|
|
116
|
+
* - All three bridge tools are appended; duplicates are dropped via Set
|
|
117
|
+
* - Returned string has no leading/trailing commas, no whitespace
|
|
118
|
+
*
|
|
119
|
+
* Call this exactly **once** in the spawn pipeline (currently
|
|
120
|
+
* `lane-runner.ts:580`) — augmentation is intended to be a single,
|
|
121
|
+
* idempotent layer; double-application is harmless (deduplicated) but
|
|
122
|
+
* obscures the data flow.
|
|
123
|
+
*
|
|
124
|
+
* @see https://github.com/claude-code-swe/OrchID/issues/530
|
|
125
|
+
* @since TP-184
|
|
126
|
+
*/
|
|
127
|
+
export function buildWorkerToolsAllowlist(userTools: string | undefined | null): string {
|
|
128
|
+
const userPart = (userTools && userTools.trim()) || DEFAULT_WORKER_USER_TOOLS;
|
|
129
|
+
const rawUserList = userPart
|
|
130
|
+
.split(",")
|
|
131
|
+
.map((s) => s.trim())
|
|
132
|
+
.filter(Boolean);
|
|
133
|
+
// Guard against delimiter-only / whitespace-only inputs (e.g. ",", " , ")
|
|
134
|
+
// that would otherwise parse to an empty list and yield bridge-tools-only
|
|
135
|
+
// workers with no file/shell capabilities.
|
|
136
|
+
const userList =
|
|
137
|
+
rawUserList.length > 0
|
|
138
|
+
? rawUserList
|
|
139
|
+
: DEFAULT_WORKER_USER_TOOLS.split(",")
|
|
140
|
+
.map((s) => s.trim())
|
|
141
|
+
.filter(Boolean);
|
|
142
|
+
const merged = new Set<string>(userList);
|
|
143
|
+
for (const t of ENGINE_BRIDGE_TOOLS) merged.add(t);
|
|
144
|
+
return Array.from(merged).join(",");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Conversation Payload Helpers (TP-111) ───────────────────────────────
|
|
148
|
+
|
|
149
|
+
/** Maximum characters for conversation event text payloads. */
|
|
150
|
+
const MAX_CONV_PAYLOAD_CHARS = 2000;
|
|
151
|
+
|
|
152
|
+
/** Truncate a string to maxLen chars, appending ellipsis if truncated. */
|
|
153
|
+
function truncatePayload(text: string, maxLen: number): string {
|
|
154
|
+
if (text.length <= maxLen) return text;
|
|
155
|
+
return text.slice(0, maxLen) + "…";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Extract text content from a Pi RPC message_end event's message object.
|
|
160
|
+
* Pi may return content as a string or as an array of content blocks.
|
|
161
|
+
*/
|
|
162
|
+
function extractAssistantText(message: Record<string, unknown>): string {
|
|
163
|
+
// Direct string content
|
|
164
|
+
if (typeof message.content === "string") return message.content;
|
|
165
|
+
// Array of content blocks (Anthropic format)
|
|
166
|
+
// Guard: skip null/non-object entries to prevent TypeError on malformed streams
|
|
167
|
+
if (Array.isArray(message.content)) {
|
|
168
|
+
const textBlocks = message.content
|
|
169
|
+
.filter(
|
|
170
|
+
(b: unknown): b is { type: string; text: string } =>
|
|
171
|
+
typeof b === "object" &&
|
|
172
|
+
b !== null &&
|
|
173
|
+
(b as any).type === "text" &&
|
|
174
|
+
typeof (b as any).text === "string",
|
|
175
|
+
)
|
|
176
|
+
.map((b) => b.text);
|
|
177
|
+
if (textBlocks.length > 0) return textBlocks.join("\n");
|
|
178
|
+
}
|
|
179
|
+
// Fallback: try text field
|
|
180
|
+
if (typeof message.text === "string") return message.text;
|
|
181
|
+
return "";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Options for spawning an agent via the direct host.
|
|
188
|
+
*
|
|
189
|
+
* @since TP-104
|
|
190
|
+
*/
|
|
191
|
+
export interface AgentHostOptions {
|
|
192
|
+
/** Stable agent identity */
|
|
193
|
+
agentId: RuntimeAgentId;
|
|
194
|
+
/** Agent role */
|
|
195
|
+
role: RuntimeAgentRole;
|
|
196
|
+
/** Batch ID this agent belongs to */
|
|
197
|
+
batchId: string;
|
|
198
|
+
/** Lane number (null for merge agents) */
|
|
199
|
+
laneNumber: number | null;
|
|
200
|
+
/** Task ID being executed (null before first assignment) */
|
|
201
|
+
taskId: string | null;
|
|
202
|
+
/** Repo ID the agent is operating in */
|
|
203
|
+
repoId: string;
|
|
204
|
+
/** Working directory for the Pi process */
|
|
205
|
+
cwd: string;
|
|
206
|
+
/** User prompt content */
|
|
207
|
+
prompt: string;
|
|
208
|
+
/** Optional system prompt content */
|
|
209
|
+
systemPrompt?: string;
|
|
210
|
+
/** Model identifier (e.g., "anthropic/claude-sonnet-4-20250514") */
|
|
211
|
+
model?: string;
|
|
212
|
+
/** Comma-separated tool list */
|
|
213
|
+
tools?: string;
|
|
214
|
+
/** Thinking mode override */
|
|
215
|
+
thinking?: string;
|
|
216
|
+
/** Extension paths to load */
|
|
217
|
+
extensions?: string[];
|
|
218
|
+
/** Mailbox directory for steering (null = no mailbox) */
|
|
219
|
+
mailboxDir?: string | null;
|
|
220
|
+
/** Steering-pending JSONL path (TP-090, worker-only) */
|
|
221
|
+
steeringPendingPath?: string | null;
|
|
222
|
+
/** Path to persist normalized events JSONL */
|
|
223
|
+
eventsPath?: string | null;
|
|
224
|
+
/** Path to write exit summary JSON */
|
|
225
|
+
exitSummaryPath?: string | null;
|
|
226
|
+
/** Timeout in milliseconds (0 = no timeout) */
|
|
227
|
+
timeoutMs?: number;
|
|
228
|
+
/** Delay in ms before closing stdin after agent_end (default: 100) */
|
|
229
|
+
closeDelayMs?: number;
|
|
230
|
+
/** State root for process registry (null = no registry integration) */
|
|
231
|
+
stateRoot?: string | null;
|
|
232
|
+
/** Packet paths for registry manifest (null for merge agents) */
|
|
233
|
+
packet?: PacketPaths | null;
|
|
234
|
+
/** Extra environment variables for the child process */
|
|
235
|
+
env?: Record<string, string>;
|
|
236
|
+
/**
|
|
237
|
+
* Callback invoked when agent_end fires, before stdin is closed.
|
|
238
|
+
* Receives the last assistant message text.
|
|
239
|
+
* Return a string to send as a new prompt (re-prompt the agent),
|
|
240
|
+
* or null to close the session normally.
|
|
241
|
+
*
|
|
242
|
+
* @since TP-172
|
|
243
|
+
*/
|
|
244
|
+
onPrematureExit?: (assistantMessage: string) => Promise<string | null>;
|
|
245
|
+
/**
|
|
246
|
+
* Maximum number of exit interceptions before forcing session close.
|
|
247
|
+
* Prevents infinite loops where the callback always returns a new prompt.
|
|
248
|
+
* Default: 2
|
|
249
|
+
*
|
|
250
|
+
* @since TP-172
|
|
251
|
+
*/
|
|
252
|
+
maxExitInterceptions?: number;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Accumulated telemetry from a completed agent session.
|
|
257
|
+
*
|
|
258
|
+
* @since TP-104
|
|
259
|
+
*/
|
|
260
|
+
export interface AgentHostResult {
|
|
261
|
+
/** Process exit code (null if killed by signal) */
|
|
262
|
+
exitCode: number | null;
|
|
263
|
+
/** Signal that killed the process (null if exited normally) */
|
|
264
|
+
signal: string | null;
|
|
265
|
+
/** Wall-clock duration in milliseconds */
|
|
266
|
+
durationMs: number;
|
|
267
|
+
/** Whether the process was killed by the caller */
|
|
268
|
+
killed: boolean;
|
|
269
|
+
/** Total input tokens */
|
|
270
|
+
inputTokens: number;
|
|
271
|
+
/** Total output tokens */
|
|
272
|
+
outputTokens: number;
|
|
273
|
+
/** Cache read tokens */
|
|
274
|
+
cacheReadTokens: number;
|
|
275
|
+
/** Cache write tokens */
|
|
276
|
+
cacheWriteTokens: number;
|
|
277
|
+
/** Cumulative cost in USD */
|
|
278
|
+
costUsd: number;
|
|
279
|
+
/** Number of tool calls */
|
|
280
|
+
toolCalls: number;
|
|
281
|
+
/** Last tool call description */
|
|
282
|
+
lastTool: string;
|
|
283
|
+
/** Number of auto-retries */
|
|
284
|
+
retries: number;
|
|
285
|
+
/** Number of auto-compactions */
|
|
286
|
+
compactions: number;
|
|
287
|
+
/** Authoritative context usage from Pi */
|
|
288
|
+
contextUsage: { tokens: number; contextWindow: number; percent: number } | null;
|
|
289
|
+
/** Final error message (null if clean exit) */
|
|
290
|
+
error: string | null;
|
|
291
|
+
/** Whether agent_end was received */
|
|
292
|
+
agentEnded: boolean;
|
|
293
|
+
/** Captured stderr tail (last 2KB) */
|
|
294
|
+
stderrTail: string;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Callback for normalized agent events.
|
|
299
|
+
*
|
|
300
|
+
* @since TP-104
|
|
301
|
+
*/
|
|
302
|
+
export type AgentEventCallback = (event: RuntimeAgentEvent) => void;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Callback for telemetry updates (called on each message_end).
|
|
306
|
+
*
|
|
307
|
+
* @since TP-104
|
|
308
|
+
*/
|
|
309
|
+
export type AgentTelemetryCallback = (result: Partial<AgentHostResult>) => void;
|
|
310
|
+
|
|
311
|
+
// ── JSONL Helpers ────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
const MAILBOX_MESSAGE_TYPES = new Set(["steer", "query", "abort", "info", "reply", "escalate"]);
|
|
314
|
+
|
|
315
|
+
function isValidMailboxMessage(obj: any): boolean {
|
|
316
|
+
if (!obj || typeof obj !== "object") return false;
|
|
317
|
+
return (
|
|
318
|
+
typeof obj.id === "string" &&
|
|
319
|
+
typeof obj.batchId === "string" &&
|
|
320
|
+
typeof obj.from === "string" &&
|
|
321
|
+
typeof obj.to === "string" &&
|
|
322
|
+
typeof obj.timestamp === "number" &&
|
|
323
|
+
Number.isFinite(obj.timestamp) &&
|
|
324
|
+
typeof obj.type === "string" &&
|
|
325
|
+
MAILBOX_MESSAGE_TYPES.has(obj.type) &&
|
|
326
|
+
typeof obj.content === "string"
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── Core Host Function ───────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Spawn and manage a Pi agent as a direct child process.
|
|
334
|
+
*
|
|
335
|
+
* Returns a promise that resolves with the full session result when
|
|
336
|
+
* the agent exits, plus a kill function for early termination.
|
|
337
|
+
*
|
|
338
|
+
* @param opts - Agent host options
|
|
339
|
+
* @param onEvent - Optional callback for normalized events
|
|
340
|
+
* @param onTelemetry - Optional callback for telemetry updates
|
|
341
|
+
* @returns Object with promise (resolves on exit) and kill function
|
|
342
|
+
*
|
|
343
|
+
* @since TP-104
|
|
344
|
+
*/
|
|
345
|
+
export function spawnAgent(
|
|
346
|
+
opts: AgentHostOptions,
|
|
347
|
+
onEvent?: AgentEventCallback,
|
|
348
|
+
onTelemetry?: AgentTelemetryCallback,
|
|
349
|
+
): { promise: Promise<AgentHostResult>; kill: () => void } {
|
|
350
|
+
const cliPath = resolvePiCliPath();
|
|
351
|
+
const closeDelayMs = opts.closeDelayMs ?? 100;
|
|
352
|
+
const timeoutMs = opts.timeoutMs ?? 0;
|
|
353
|
+
const maxExitInterceptions = opts.maxExitInterceptions ?? 3;
|
|
354
|
+
|
|
355
|
+
// Build Pi CLI arguments
|
|
356
|
+
const piArgs: string[] = [cliPath, "--mode", "rpc", "--no-session"];
|
|
357
|
+
if (opts.model) piArgs.push("--model", opts.model);
|
|
358
|
+
if (opts.tools) piArgs.push("--tools", opts.tools);
|
|
359
|
+
if (opts.systemPrompt) piArgs.push("--system-prompt", opts.systemPrompt);
|
|
360
|
+
// Always pass --no-extensions to prevent auto-discovery from cwd.
|
|
361
|
+
// Explicit -e entries are still honored by pi even with --no-extensions.
|
|
362
|
+
// This matches the fix from TP-095 that eliminated duplicate extension loading.
|
|
363
|
+
piArgs.push("--no-extensions");
|
|
364
|
+
if (opts.extensions && opts.extensions.length > 0) {
|
|
365
|
+
for (const ext of opts.extensions) {
|
|
366
|
+
piArgs.push("-e", ext);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
piArgs.push("--no-skills");
|
|
370
|
+
if (opts.thinking) piArgs.push("--thinking", opts.thinking);
|
|
371
|
+
|
|
372
|
+
// Spawn directly — no shell, no terminal multiplexer
|
|
373
|
+
const proc = spawn(process.execPath, piArgs, {
|
|
374
|
+
shell: false,
|
|
375
|
+
cwd: opts.cwd,
|
|
376
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
377
|
+
env: { ...process.env, ...(opts.env ?? {}) },
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// State accumulator
|
|
381
|
+
const startedAt = Date.now();
|
|
382
|
+
let killed = false;
|
|
383
|
+
let timedOut = false;
|
|
384
|
+
let agentEnded = false;
|
|
385
|
+
let stdinClosed = false;
|
|
386
|
+
let assistantMessageEnds = 0;
|
|
387
|
+
const STATS_REFRESH_EVERY_ASSISTANT_MESSAGES = 5;
|
|
388
|
+
let inputTokens = 0,
|
|
389
|
+
outputTokens = 0,
|
|
390
|
+
cacheReadTokens = 0,
|
|
391
|
+
cacheWriteTokens = 0;
|
|
392
|
+
let costUsd = 0,
|
|
393
|
+
toolCalls = 0,
|
|
394
|
+
retries = 0,
|
|
395
|
+
compactions = 0;
|
|
396
|
+
let lastTool = "",
|
|
397
|
+
error: string | null = null;
|
|
398
|
+
let contextUsage: AgentHostResult["contextUsage"] = null;
|
|
399
|
+
let stderrBuffer = "";
|
|
400
|
+
const STDERR_MAX = 2048;
|
|
401
|
+
/** Last assistant message text captured from message_end events (TP-172) */
|
|
402
|
+
let lastAssistantMessage = "";
|
|
403
|
+
/** Number of times exit interception has occurred (TP-172) */
|
|
404
|
+
let exitInterceptionCount = 0;
|
|
405
|
+
/** Whether the current turn had any tool calls (TP-172: text-only gate) */
|
|
406
|
+
let currentTurnHadToolCalls = false;
|
|
407
|
+
|
|
408
|
+
// Timeout
|
|
409
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
410
|
+
if (timeoutMs > 0) {
|
|
411
|
+
timeoutHandle = setTimeout(() => {
|
|
412
|
+
timedOut = true;
|
|
413
|
+
killed = true;
|
|
414
|
+
try {
|
|
415
|
+
proc.kill("SIGTERM");
|
|
416
|
+
} catch {
|
|
417
|
+
/* ignore */
|
|
418
|
+
}
|
|
419
|
+
}, timeoutMs);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const REGISTRY_REFRESH_INTERVAL_MS = 1_000;
|
|
423
|
+
let lastRegistryRefreshAt = 0;
|
|
424
|
+
const refreshRegistrySnapshot = (force: boolean = false) => {
|
|
425
|
+
if (!opts.stateRoot) return;
|
|
426
|
+
const now = Date.now();
|
|
427
|
+
if (!force && now - lastRegistryRefreshAt < REGISTRY_REFRESH_INTERVAL_MS) return;
|
|
428
|
+
try {
|
|
429
|
+
const snapshot = buildRegistrySnapshot(opts.stateRoot, opts.batchId);
|
|
430
|
+
writeRegistrySnapshot(opts.stateRoot, snapshot);
|
|
431
|
+
lastRegistryRefreshAt = now;
|
|
432
|
+
} catch {
|
|
433
|
+
/* best effort */
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
// Registry integration: write manifest before process is considered visible
|
|
438
|
+
if (opts.stateRoot) {
|
|
439
|
+
const manifest = createManifest({
|
|
440
|
+
batchId: opts.batchId,
|
|
441
|
+
agentId: opts.agentId,
|
|
442
|
+
role: opts.role,
|
|
443
|
+
laneNumber: opts.laneNumber,
|
|
444
|
+
taskId: opts.taskId,
|
|
445
|
+
repoId: opts.repoId,
|
|
446
|
+
pid: proc.pid ?? 0,
|
|
447
|
+
parentPid: process.pid,
|
|
448
|
+
cwd: opts.cwd,
|
|
449
|
+
packet: opts.packet ?? null,
|
|
450
|
+
});
|
|
451
|
+
manifest.status = "running";
|
|
452
|
+
writeManifest(opts.stateRoot, manifest);
|
|
453
|
+
refreshRegistrySnapshot(true);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Helper: close stdin safely with delay
|
|
457
|
+
function closeStdin() {
|
|
458
|
+
if (stdinClosed) return;
|
|
459
|
+
stdinClosed = true;
|
|
460
|
+
if (closeDelayMs > 0) {
|
|
461
|
+
setTimeout(() => {
|
|
462
|
+
try {
|
|
463
|
+
proc.stdin?.end();
|
|
464
|
+
} catch {
|
|
465
|
+
/* ignore */
|
|
466
|
+
}
|
|
467
|
+
}, closeDelayMs);
|
|
468
|
+
} else {
|
|
469
|
+
try {
|
|
470
|
+
proc.stdin?.end();
|
|
471
|
+
} catch {
|
|
472
|
+
/* ignore */
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Helper: emit normalized event
|
|
478
|
+
function emitEvent(type: RuntimeAgentEventType, payload: Record<string, unknown> = {}) {
|
|
479
|
+
const event: RuntimeAgentEvent = {
|
|
480
|
+
batchId: opts.batchId,
|
|
481
|
+
agentId: opts.agentId,
|
|
482
|
+
role: opts.role,
|
|
483
|
+
laneNumber: opts.laneNumber,
|
|
484
|
+
taskId: opts.taskId,
|
|
485
|
+
repoId: opts.repoId,
|
|
486
|
+
ts: Date.now(),
|
|
487
|
+
type,
|
|
488
|
+
payload,
|
|
489
|
+
};
|
|
490
|
+
if (onEvent) onEvent(event);
|
|
491
|
+
// Persist to events JSONL if path is provided
|
|
492
|
+
if (opts.eventsPath) {
|
|
493
|
+
try {
|
|
494
|
+
mkdirSync(dirname(opts.eventsPath), { recursive: true });
|
|
495
|
+
appendFileSync(opts.eventsPath, JSON.stringify(event) + "\n", "utf-8");
|
|
496
|
+
} catch {
|
|
497
|
+
/* best effort */
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Helper: check mailbox and inject (own inbox + _broadcast)
|
|
503
|
+
function checkMailbox() {
|
|
504
|
+
if (!opts.mailboxDir || !proc.stdin || proc.stdin.destroyed) return;
|
|
505
|
+
|
|
506
|
+
const expectedSessionName = basename(opts.mailboxDir);
|
|
507
|
+
const expectedBatchId = basename(dirname(opts.mailboxDir));
|
|
508
|
+
|
|
509
|
+
// Collect messages from own inbox AND broadcast inbox
|
|
510
|
+
const inboxDirs: Array<{ dir: string; isBroadcast: boolean }> = [
|
|
511
|
+
{ dir: join(opts.mailboxDir, "inbox"), isBroadcast: false },
|
|
512
|
+
];
|
|
513
|
+
// TP-106: Also check _broadcast/inbox for broadcast messages
|
|
514
|
+
const broadcastInbox = join(dirname(opts.mailboxDir), "_broadcast", "inbox");
|
|
515
|
+
if (existsSync(broadcastInbox)) {
|
|
516
|
+
inboxDirs.push({ dir: broadcastInbox, isBroadcast: true });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
for (const { dir: inboxDir, isBroadcast } of inboxDirs) {
|
|
520
|
+
if (!existsSync(inboxDir)) continue;
|
|
521
|
+
|
|
522
|
+
let entries: string[];
|
|
523
|
+
try {
|
|
524
|
+
entries = readdirSync(inboxDir);
|
|
525
|
+
} catch {
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const msgFiles = entries
|
|
530
|
+
.filter((f) => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"))
|
|
531
|
+
.sort();
|
|
532
|
+
if (msgFiles.length === 0) continue;
|
|
533
|
+
|
|
534
|
+
const ackDir = join(opts.mailboxDir, "ack");
|
|
535
|
+
|
|
536
|
+
for (const filename of msgFiles) {
|
|
537
|
+
try {
|
|
538
|
+
const raw = readFileSync(join(inboxDir, filename), "utf-8");
|
|
539
|
+
const msg = JSON.parse(raw);
|
|
540
|
+
if (!isValidMailboxMessage(msg)) continue;
|
|
541
|
+
if (msg.batchId !== expectedBatchId) continue;
|
|
542
|
+
// Validate 'to' field: own inbox requires exact match, broadcast accepts "_broadcast"
|
|
543
|
+
if (!isBroadcast && msg.to !== expectedSessionName) continue;
|
|
544
|
+
if (isBroadcast && msg.to !== "_broadcast") continue;
|
|
545
|
+
|
|
546
|
+
mkdirSync(ackDir, { recursive: true });
|
|
547
|
+
const ackPath = join(ackDir, filename);
|
|
548
|
+
// Broadcast fan-out: if this agent already acked this broadcast message,
|
|
549
|
+
// skip to avoid duplicate delivery while preserving message for peers.
|
|
550
|
+
if (isBroadcast && existsSync(ackPath)) continue;
|
|
551
|
+
|
|
552
|
+
proc.stdin.write(JSON.stringify({ type: "steer", message: msg.content }) + "\n");
|
|
553
|
+
|
|
554
|
+
if (isBroadcast) {
|
|
555
|
+
// Do NOT remove the shared broadcast inbox file. Persist a per-agent
|
|
556
|
+
// ack marker so all agents can consume the same broadcast exactly once.
|
|
557
|
+
try {
|
|
558
|
+
writeFileSync(ackPath, raw, "utf-8");
|
|
559
|
+
} catch {
|
|
560
|
+
/* best effort */
|
|
561
|
+
}
|
|
562
|
+
} else {
|
|
563
|
+
try {
|
|
564
|
+
renameSync(join(inboxDir, filename), ackPath);
|
|
565
|
+
} catch {
|
|
566
|
+
/* race ok */
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
emitEvent("message_delivered", {
|
|
571
|
+
messageId: msg.id,
|
|
572
|
+
content: msg.content,
|
|
573
|
+
broadcast: isBroadcast,
|
|
574
|
+
});
|
|
575
|
+
if (opts.stateRoot) {
|
|
576
|
+
appendMailboxAuditEvent(opts.stateRoot, expectedBatchId, {
|
|
577
|
+
type: "message_delivered",
|
|
578
|
+
from: msg.from,
|
|
579
|
+
to: isBroadcast ? expectedSessionName : msg.to,
|
|
580
|
+
messageId: msg.id,
|
|
581
|
+
messageType: msg.type,
|
|
582
|
+
contentPreview: msg.content.slice(0, 200),
|
|
583
|
+
broadcast: isBroadcast,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// TP-090: steering-pending flag
|
|
588
|
+
if (opts.steeringPendingPath) {
|
|
589
|
+
try {
|
|
590
|
+
appendFileSync(
|
|
591
|
+
opts.steeringPendingPath,
|
|
592
|
+
JSON.stringify({ ts: msg.timestamp, content: msg.content, id: msg.id }) + "\n",
|
|
593
|
+
"utf-8",
|
|
594
|
+
);
|
|
595
|
+
} catch {
|
|
596
|
+
/* best effort */
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
} catch {
|
|
600
|
+
/* skip malformed */
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const promise = new Promise<AgentHostResult>((resolvePromise) => {
|
|
607
|
+
let stdoutBuf = "";
|
|
608
|
+
const decoder = new StringDecoder("utf8");
|
|
609
|
+
let finished = false;
|
|
610
|
+
|
|
611
|
+
function finish(exitCode: number | null, signal: string | null) {
|
|
612
|
+
if (finished) return;
|
|
613
|
+
finished = true;
|
|
614
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
615
|
+
|
|
616
|
+
const result: AgentHostResult = {
|
|
617
|
+
exitCode,
|
|
618
|
+
signal,
|
|
619
|
+
durationMs: Date.now() - startedAt,
|
|
620
|
+
killed,
|
|
621
|
+
inputTokens,
|
|
622
|
+
outputTokens,
|
|
623
|
+
cacheReadTokens,
|
|
624
|
+
cacheWriteTokens,
|
|
625
|
+
costUsd,
|
|
626
|
+
toolCalls,
|
|
627
|
+
lastTool,
|
|
628
|
+
retries,
|
|
629
|
+
compactions,
|
|
630
|
+
contextUsage,
|
|
631
|
+
error,
|
|
632
|
+
agentEnded,
|
|
633
|
+
stderrTail: stderrBuffer.trim().slice(-STDERR_MAX),
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
// Write exit summary if path provided
|
|
637
|
+
if (opts.exitSummaryPath) {
|
|
638
|
+
try {
|
|
639
|
+
mkdirSync(dirname(opts.exitSummaryPath), { recursive: true });
|
|
640
|
+
const summary = {
|
|
641
|
+
exitCode: result.exitCode,
|
|
642
|
+
exitSignal: result.signal,
|
|
643
|
+
tokens:
|
|
644
|
+
inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens > 0
|
|
645
|
+
? {
|
|
646
|
+
input: inputTokens,
|
|
647
|
+
output: outputTokens,
|
|
648
|
+
cacheRead: cacheReadTokens,
|
|
649
|
+
cacheWrite: cacheWriteTokens,
|
|
650
|
+
}
|
|
651
|
+
: null,
|
|
652
|
+
cost: costUsd > 0 ? costUsd : null,
|
|
653
|
+
toolCalls,
|
|
654
|
+
retries,
|
|
655
|
+
compactions,
|
|
656
|
+
durationSec: Math.round(result.durationMs / 1000),
|
|
657
|
+
lastToolCall: lastTool || null,
|
|
658
|
+
error: error || null,
|
|
659
|
+
contextUsage: contextUsage || null,
|
|
660
|
+
};
|
|
661
|
+
writeFileSync(opts.exitSummaryPath, JSON.stringify(summary, null, 2) + "\n", "utf-8");
|
|
662
|
+
} catch {
|
|
663
|
+
/* best effort */
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const exitEventType: RuntimeAgentEventType = timedOut
|
|
668
|
+
? "agent_timeout"
|
|
669
|
+
: killed
|
|
670
|
+
? "agent_killed"
|
|
671
|
+
: exitCode === 0 && agentEnded
|
|
672
|
+
? "agent_exited"
|
|
673
|
+
: "agent_crashed";
|
|
674
|
+
emitEvent(exitEventType, { exitCode, signal, durationMs: result.durationMs, timedOut });
|
|
675
|
+
|
|
676
|
+
// Registry integration: update manifest to terminal status
|
|
677
|
+
if (opts.stateRoot) {
|
|
678
|
+
const terminalStatus = timedOut
|
|
679
|
+
? ("timed_out" as const)
|
|
680
|
+
: killed
|
|
681
|
+
? ("killed" as const)
|
|
682
|
+
: exitCode === 0 && agentEnded
|
|
683
|
+
? ("exited" as const)
|
|
684
|
+
: ("crashed" as const);
|
|
685
|
+
updateManifestStatus(opts.stateRoot, opts.batchId, opts.agentId, terminalStatus);
|
|
686
|
+
refreshRegistrySnapshot(true);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
resolvePromise(result);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
proc.stdout.on("data", (chunk: Buffer | string) => {
|
|
693
|
+
stdoutBuf += typeof chunk === "string" ? chunk : decoder.write(chunk);
|
|
694
|
+
let idx: number;
|
|
695
|
+
while ((idx = stdoutBuf.indexOf("\n")) >= 0) {
|
|
696
|
+
let line = stdoutBuf.slice(0, idx);
|
|
697
|
+
stdoutBuf = stdoutBuf.slice(idx + 1);
|
|
698
|
+
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
699
|
+
if (!line.trim()) continue;
|
|
700
|
+
|
|
701
|
+
let event: any;
|
|
702
|
+
try {
|
|
703
|
+
event = JSON.parse(line);
|
|
704
|
+
} catch {
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
if (!event || !event.type) continue;
|
|
708
|
+
|
|
709
|
+
// Accumulate telemetry
|
|
710
|
+
switch (event.type) {
|
|
711
|
+
case "message_end": {
|
|
712
|
+
const usage = event.message?.usage;
|
|
713
|
+
if (usage) {
|
|
714
|
+
inputTokens += usage.input || 0;
|
|
715
|
+
outputTokens += usage.output || 0;
|
|
716
|
+
cacheReadTokens += usage.cacheRead || 0;
|
|
717
|
+
cacheWriteTokens += usage.cacheWrite || 0;
|
|
718
|
+
if (usage.cost) {
|
|
719
|
+
costUsd +=
|
|
720
|
+
typeof usage.cost === "object"
|
|
721
|
+
? usage.cost.total || 0
|
|
722
|
+
: typeof usage.cost === "number"
|
|
723
|
+
? usage.cost
|
|
724
|
+
: 0;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
// TP-111: Emit assistant_message with bounded content
|
|
728
|
+
if (event.message?.role === "assistant") {
|
|
729
|
+
const content = extractAssistantText(event.message);
|
|
730
|
+
if (content) {
|
|
731
|
+
emitEvent("assistant_message", { text: truncatePayload(content, MAX_CONV_PAYLOAD_CHARS) });
|
|
732
|
+
// TP-172: Track last assistant message for exit interception
|
|
733
|
+
lastAssistantMessage = content;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
// Request session stats immediately on first assistant message,
|
|
737
|
+
// then periodically at a bounded cadence to refresh context usage.
|
|
738
|
+
if (event.message?.role === "assistant") {
|
|
739
|
+
assistantMessageEnds += 1;
|
|
740
|
+
if (
|
|
741
|
+
assistantMessageEnds === 1 ||
|
|
742
|
+
assistantMessageEnds % STATS_REFRESH_EVERY_ASSISTANT_MESSAGES === 0
|
|
743
|
+
) {
|
|
744
|
+
try {
|
|
745
|
+
proc.stdin?.write(JSON.stringify({ type: "get_session_stats" }) + "\n");
|
|
746
|
+
} catch {
|
|
747
|
+
/* ignore */
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
// Check mailbox
|
|
752
|
+
checkMailbox();
|
|
753
|
+
// Keep registry snapshot freshness while agent is active.
|
|
754
|
+
refreshRegistrySnapshot(false);
|
|
755
|
+
// Emit telemetry update
|
|
756
|
+
if (onTelemetry) {
|
|
757
|
+
onTelemetry({
|
|
758
|
+
inputTokens,
|
|
759
|
+
outputTokens,
|
|
760
|
+
cacheReadTokens,
|
|
761
|
+
cacheWriteTokens,
|
|
762
|
+
costUsd,
|
|
763
|
+
toolCalls,
|
|
764
|
+
lastTool,
|
|
765
|
+
contextUsage,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
case "tool_execution_start": {
|
|
771
|
+
toolCalls++;
|
|
772
|
+
currentTurnHadToolCalls = true;
|
|
773
|
+
const toolName = event.toolName || "tool";
|
|
774
|
+
const argPreview =
|
|
775
|
+
typeof event.args === "string"
|
|
776
|
+
? event.args.slice(0, 300)
|
|
777
|
+
: event.args && typeof Object.values(event.args)[0] === "string"
|
|
778
|
+
? String(Object.values(event.args)[0]).slice(0, 300)
|
|
779
|
+
: "";
|
|
780
|
+
lastTool = argPreview ? `${toolName}: ${argPreview}` : toolName;
|
|
781
|
+
// TP-111: Bounded payload only — no raw args in durable event log
|
|
782
|
+
const toolPath = event.args?.path ? String(event.args.path).slice(0, 200) : "";
|
|
783
|
+
emitEvent("tool_call", { tool: toolName, path: toolPath, argsPreview: argPreview });
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
case "tool_execution_end": {
|
|
787
|
+
// TP-111: Include bounded result summary for dashboard display
|
|
788
|
+
const toolResultSummary =
|
|
789
|
+
typeof event.result === "string"
|
|
790
|
+
? event.result.slice(0, 200)
|
|
791
|
+
: event.output
|
|
792
|
+
? String(event.output).slice(0, 200)
|
|
793
|
+
: "";
|
|
794
|
+
emitEvent("tool_result", { tool: event.toolName, summary: toolResultSummary });
|
|
795
|
+
break;
|
|
796
|
+
}
|
|
797
|
+
case "auto_retry_start": {
|
|
798
|
+
retries++;
|
|
799
|
+
emitEvent("retry_started", {
|
|
800
|
+
attempt: event.attempt,
|
|
801
|
+
error: event.errorMessage || event.error,
|
|
802
|
+
});
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
case "auto_compaction_start": {
|
|
806
|
+
compactions++;
|
|
807
|
+
emitEvent("compaction_started", {});
|
|
808
|
+
break;
|
|
809
|
+
}
|
|
810
|
+
case "response": {
|
|
811
|
+
if (event.success === false && event.error) {
|
|
812
|
+
error = event.error;
|
|
813
|
+
}
|
|
814
|
+
if (event.success === true && event.data?.contextUsage) {
|
|
815
|
+
contextUsage = event.data.contextUsage;
|
|
816
|
+
emitEvent("context_usage", { ...event.data.contextUsage });
|
|
817
|
+
// Emit telemetry immediately so context % is live in dashboard
|
|
818
|
+
if (onTelemetry) {
|
|
819
|
+
onTelemetry({
|
|
820
|
+
inputTokens,
|
|
821
|
+
outputTokens,
|
|
822
|
+
cacheReadTokens,
|
|
823
|
+
cacheWriteTokens,
|
|
824
|
+
costUsd,
|
|
825
|
+
toolCalls,
|
|
826
|
+
lastTool,
|
|
827
|
+
contextUsage,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
break;
|
|
832
|
+
}
|
|
833
|
+
case "agent_end": {
|
|
834
|
+
agentEnded = true;
|
|
835
|
+
// TP-172: Exit interception — intercept any exit when callback
|
|
836
|
+
// is provided and under limit. The callback (lane-runner) decides
|
|
837
|
+
// whether the worker made progress. We don't gate on tool calls
|
|
838
|
+
// because workers commonly use tools (reads/greps) then exit
|
|
839
|
+
// with a text declaration ("Now let me fix this:") without
|
|
840
|
+
// actually making the edit.
|
|
841
|
+
const shouldIntercept = opts.onPrematureExit && exitInterceptionCount < maxExitInterceptions;
|
|
842
|
+
if (shouldIntercept) {
|
|
843
|
+
exitInterceptionCount++;
|
|
844
|
+
const INTERCEPTION_TIMEOUT_MS = 120_000; // 2 minute safety timeout
|
|
845
|
+
// Wrap in Promise.resolve().then() to catch synchronous throws
|
|
846
|
+
const interceptPromise = Promise.resolve().then(() =>
|
|
847
|
+
opts.onPrematureExit!(lastAssistantMessage),
|
|
848
|
+
);
|
|
849
|
+
const timeoutPromise = new Promise<null>((res) =>
|
|
850
|
+
setTimeout(() => res(null), INTERCEPTION_TIMEOUT_MS),
|
|
851
|
+
);
|
|
852
|
+
Promise.race([interceptPromise, timeoutPromise]).then(
|
|
853
|
+
(newPrompt: string | null) => {
|
|
854
|
+
if (newPrompt && !stdinClosed && proc.stdin && !proc.stdin.destroyed) {
|
|
855
|
+
// Re-prompt the agent with supervisor guidance
|
|
856
|
+
agentEnded = false; // Reset for the new turn
|
|
857
|
+
currentTurnHadToolCalls = false; // Reset for new turn
|
|
858
|
+
proc.stdin.write(JSON.stringify({ type: "prompt", message: newPrompt }) + "\n");
|
|
859
|
+
emitEvent("exit_intercepted", {
|
|
860
|
+
interceptionCount: exitInterceptionCount,
|
|
861
|
+
assistantMessage: truncatePayload(lastAssistantMessage, 500),
|
|
862
|
+
supervisorConsulted: true,
|
|
863
|
+
action: "reprompt",
|
|
864
|
+
newPromptPreview: truncatePayload(newPrompt, MAX_CONV_PAYLOAD_CHARS),
|
|
865
|
+
});
|
|
866
|
+
} else {
|
|
867
|
+
// Callback returned null or stdin already closed — close session
|
|
868
|
+
const reason = stdinClosed
|
|
869
|
+
? "stdin_closed"
|
|
870
|
+
: newPrompt === null
|
|
871
|
+
? "callback_returned_null"
|
|
872
|
+
: "unknown";
|
|
873
|
+
emitEvent("exit_intercepted", {
|
|
874
|
+
interceptionCount: exitInterceptionCount,
|
|
875
|
+
assistantMessage: truncatePayload(lastAssistantMessage, 500),
|
|
876
|
+
supervisorConsulted: true,
|
|
877
|
+
action: "close",
|
|
878
|
+
reason,
|
|
879
|
+
});
|
|
880
|
+
closeStdin();
|
|
881
|
+
}
|
|
882
|
+
},
|
|
883
|
+
(err: unknown) => {
|
|
884
|
+
// Callback rejected — emit single diagnostic event and close
|
|
885
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
886
|
+
emitEvent("exit_intercepted", {
|
|
887
|
+
interceptionCount: exitInterceptionCount,
|
|
888
|
+
assistantMessage: truncatePayload(lastAssistantMessage, 500),
|
|
889
|
+
supervisorConsulted: false,
|
|
890
|
+
action: "close",
|
|
891
|
+
reason: "callback_error",
|
|
892
|
+
error: msg,
|
|
893
|
+
});
|
|
894
|
+
closeStdin();
|
|
895
|
+
},
|
|
896
|
+
);
|
|
897
|
+
} else {
|
|
898
|
+
// No callback, had tool calls, or interception limit reached — close normally
|
|
899
|
+
if (opts.onPrematureExit && exitInterceptionCount >= maxExitInterceptions) {
|
|
900
|
+
emitEvent("exit_intercepted", {
|
|
901
|
+
interceptionCount: exitInterceptionCount,
|
|
902
|
+
assistantMessage: truncatePayload(lastAssistantMessage, 500),
|
|
903
|
+
supervisorConsulted: false,
|
|
904
|
+
action: "close",
|
|
905
|
+
reason: "max_interceptions_reached",
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
closeStdin();
|
|
909
|
+
}
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
proc.stderr?.setEncoding("utf-8");
|
|
917
|
+
proc.stderr?.on("data", (chunk: string) => {
|
|
918
|
+
stderrBuffer += chunk;
|
|
919
|
+
if (stderrBuffer.length > STDERR_MAX * 2) {
|
|
920
|
+
stderrBuffer = stderrBuffer.slice(-STDERR_MAX);
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
proc.on("error", (err: Error) => {
|
|
925
|
+
error = `spawn error: ${err.message}`;
|
|
926
|
+
finish(null, null);
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
proc.on("close", (code: number | null, signal: string | null) => {
|
|
930
|
+
finish(code, signal);
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
// Send steering mode and prompt
|
|
934
|
+
if (opts.mailboxDir) {
|
|
935
|
+
proc.stdin.write(JSON.stringify({ type: "set_steering_mode", mode: "all" }) + "\n");
|
|
936
|
+
}
|
|
937
|
+
proc.stdin.write(JSON.stringify({ type: "prompt", message: opts.prompt }) + "\n");
|
|
938
|
+
|
|
939
|
+
emitEvent("agent_started", { model: opts.model, cwd: opts.cwd });
|
|
940
|
+
// TP-111: Emit prompt_sent with bounded preview
|
|
941
|
+
emitEvent("prompt_sent", { text: truncatePayload(opts.prompt, MAX_CONV_PAYLOAD_CHARS) });
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
const kill = () => {
|
|
945
|
+
killed = true;
|
|
946
|
+
try {
|
|
947
|
+
proc.kill("SIGTERM");
|
|
948
|
+
} catch {
|
|
949
|
+
/* ignore */
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
return { promise, kill };
|
|
954
|
+
}
|