@tea-agent/loop-agent 0.22.0 → 0.24.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.
Files changed (103) hide show
  1. package/AGENTS.md +42 -108
  2. package/CHANGELOG.md +85 -0
  3. package/README.md +8 -5
  4. package/bin/agent-worker.js +0 -0
  5. package/dist/application/context-usage/skill-resolution-stats.js +263 -0
  6. package/dist/application/dag/generate-task-dag.js +17 -3
  7. package/dist/cli/command-definitions.js +8 -7
  8. package/dist/cli/program.js +17 -15
  9. package/dist/commands/doctor.js +269 -18
  10. package/dist/commands/init.js +101 -86
  11. package/dist/commands/stats.js +40 -11
  12. package/dist/executors/shell-executor.js +20 -7
  13. package/dist/shared/operator/capabilities.js +486 -3
  14. package/dist/worker/console/app-data.js +6 -0
  15. package/dist/worker/console/chat/artifact-card.js +23 -0
  16. package/dist/worker/console/chat/chat-event-store.js +495 -0
  17. package/dist/worker/console/chat/chat-ui-policy.js +25 -0
  18. package/dist/worker/console/chat/composer-draft-store.js +45 -0
  19. package/dist/worker/console/chat/context-panel.js +54 -0
  20. package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
  21. package/dist/worker/console/chat/explore-tools.js +299 -0
  22. package/dist/worker/console/chat/human-gate-card.js +37 -0
  23. package/dist/worker/console/chat/instruction-skills.js +217 -0
  24. package/dist/worker/console/chat/interview-adapter.js +136 -0
  25. package/dist/worker/console/chat/model-resolver.js +106 -0
  26. package/dist/worker/console/chat/operation-card.js +23 -0
  27. package/dist/worker/console/chat/pi-console-config.js +158 -0
  28. package/dist/worker/console/chat/pi-runtime.js +1143 -0
  29. package/dist/worker/console/chat/repo-browser.js +140 -0
  30. package/dist/worker/console/chat/repo-walk.js +116 -0
  31. package/dist/worker/console/chat/resource-loader.js +67 -0
  32. package/dist/worker/console/chat/routes.js +1646 -0
  33. package/dist/worker/console/chat/runtime-context.js +24 -0
  34. package/dist/worker/console/chat/runtime-selection.js +37 -0
  35. package/dist/worker/console/chat/session-store.js +437 -0
  36. package/dist/worker/console/chat/shortcuts.js +15 -0
  37. package/dist/worker/console/chat/tool-adapter.js +125 -0
  38. package/dist/worker/console/chat/tools.js +195 -0
  39. package/dist/worker/console/chat/usage.js +37 -0
  40. package/dist/worker/console/chat/workspace-landing.js +56 -0
  41. package/dist/worker/console/dag-confirmation.js +42 -8
  42. package/dist/worker/console/human-gate-token.js +130 -0
  43. package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
  44. package/dist/worker/console/operation-runner.js +6 -2
  45. package/dist/worker/console/operation-sse.js +26 -0
  46. package/dist/worker/console/operator-actions.js +420 -7
  47. package/dist/worker/console/server.js +68 -1
  48. package/dist/worker/console/static/assets/index-BTbrEHnO.css +1 -0
  49. package/dist/worker/console/static/assets/index-D9qLevoP.js +27 -0
  50. package/dist/worker/console/static/index.html +2 -2
  51. package/dist/worker/feature/profile-schema.js +1 -1
  52. package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -5
  53. package/dist/workflows/dag/backend-test-result-contract.js +229 -0
  54. package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
  55. package/dist/workflows/dag/init-hybrid.js +71 -22
  56. package/dist/workflows/dag/node-execution.js +38 -1
  57. package/dist/workflows/dag/output-protocol.js +89 -0
  58. package/dist/workflows/dag/prompt.js +35 -1
  59. package/dist/workflows/dag/recovery-recommendation.js +45 -0
  60. package/dist/workflows/dag/report.js +28 -1
  61. package/dist/workflows/dag/rerun-task.js +1 -1
  62. package/dist/workflows/dag/scheduler.js +9 -0
  63. package/dist/workflows/dag/types.js +12 -0
  64. package/dist/workflows/dag/validate.js +55 -0
  65. package/docs/README.md +73 -156
  66. package/docs/architecture/README.md +7 -6
  67. package/docs/architecture/dag-execution.md +2 -2
  68. package/docs/architecture/evolution.md +16 -14
  69. package/docs/architecture/system-overview.md +1 -1
  70. package/docs/architecture/worker-and-feature.md +3 -3
  71. package/docs/governance/README.md +15 -0
  72. package/docs/{harness-methodology-debugging.md → governance/harness-methodology-debugging.md} +27 -3
  73. package/docs/init-surface.manifest.json +22 -4
  74. package/docs/operations/README.md +12 -0
  75. package/docs/{local-development-environment.md → operations/local-development-environment.md} +1 -1
  76. package/docs/skills/vetted-skill-registry.md +23 -3
  77. package/docs/templates/README.md +55 -0
  78. package/docs/templates/backend-test-dag.json +2 -2
  79. package/docs/templates/evaluation/agents-map-slim-v1.candidate.json +9 -0
  80. package/docs/templates/evaluation/agents-map-slim-v1.md +87 -0
  81. package/docs/templates/evaluation/agents-map-verbose-v0.candidate.json +9 -0
  82. package/docs/templates/evaluation/agents-map-verbose-v0.md +153 -0
  83. package/docs/templates/hybrid-dag.json +1 -1
  84. package/docs/templates/progress-log.md +9 -2
  85. package/harness.json +4 -4
  86. package/package.json +5 -5
  87. package/scripts/kb-bootstrap-init-skeleton.sh +2 -2
  88. package/skills/agent-worker/SKILL.md +1 -1
  89. package/skills/grill-with-docs/SKILL.md +44 -52
  90. package/skills/grill-with-docs/adr-format.md +37 -26
  91. package/skills/grill-with-docs/context-format.md +18 -26
  92. package/skills/loop-agent/SKILL.md +28 -112
  93. package/skills/loop-agent/references/command-reference.md +9 -3
  94. package/skills/loop-agent/references/harness-policy.md +3 -3
  95. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  96. package/skills/loop-agent/references/orchestrator-and-interventions.md +1 -1
  97. package/skills/loop-agent/references/task-workflow.md +2 -0
  98. package/skills/systematic-debugging/SKILL.md +20 -4
  99. package/skills/test-driven-development/SKILL.md +10 -3
  100. package/dist/worker/console/static/assets/index-CUDke82y.js +0 -18
  101. package/dist/worker/console/static/assets/index-wSEksVSO.css +0 -1
  102. /package/docs/{harness-methodology-tdd.md → governance/harness-methodology-tdd.md} +0 -0
  103. /package/docs/{harness-methodology-verification.md → governance/harness-methodology-verification.md} +0 -0
@@ -0,0 +1,1143 @@
1
+ /**
2
+ * Operator Chat — Pi SDK runtime wrapper (plan W1 / M1).
3
+ *
4
+ * Wraps the Pi SDK's services-mode session API
5
+ * (createAgentSessionServices + createAgentSessionFromServices + SessionManager)
6
+ * into a small, testable surface that the Console HTTP layer can drive.
7
+ *
8
+ * Isolation guarantees (plan D4 / V12 / V14):
9
+ * - Chat sessions are persisted to a DEDICATED sessionDir (not the user's
10
+ * default ~/.pi/agent/sessions). Naming prefix `operator-chat-` makes them
11
+ * auditable and cleanable (plan Q4 / Q8).
12
+ * - The ResourceLoader is configured to NOT auto-load user extensions/skills
13
+ * (noContextFiles / closed surface); credential/model plane is shared via
14
+ * the same agentDir auth.json/models.json.
15
+ * - Active tools are pinned to the operator-chat surface at session create
16
+ * AND re-pinned before each prompt (three-gate, design §7.5): the FULL
17
+ * operator action set (dynamic, from the registry) PLUS the built-in
18
+ * read/explore tools (bash/read/grep/find/ls). File-WRITING coding tools
19
+ * (edit/write/apply_patch/full-tools/shell/coding-chat) are excluded from
20
+ * the SDK registry and can never be activated (2026-07-25 widening).
21
+ *
22
+ * The actual SDK calls are injected via `PiSdkBindings` so this module is
23
+ * unit-testable without a live Pi install. Production bindings come from
24
+ * `createDefaultPiSdkBindings()`.
25
+ */
26
+ import { randomBytes } from "node:crypto";
27
+ import { projectCompactSnapshot } from "./chat-event-store.js";
28
+ import path from "node:path";
29
+ import { extractUsageSample } from "./usage.js";
30
+ import { OPERATOR_CHAT_ALLOWED_TOOLS, authorizeOperatorChatTool, assertNoWriteToolInList, } from "./tools.js";
31
+ import { createOperatorChatResourceLoader, } from "./resource-loader.js";
32
+ import { loadOperatorChatInstructionSkills, composeInstructionSkillsPrompt, OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS, } from "./instruction-skills.js";
33
+ import { buildModelCallableToolSchemas } from "./tool-adapter.js";
34
+ import { resolveDefaultChatModel, } from "./model-resolver.js";
35
+ import { filterActiveInterviewTools } from "../interview/tools.js";
36
+ /**
37
+ * Base system prompt fragment every General Operator Chat session receives.
38
+ *
39
+ * Establishes the operator-vs-implementer role boundary (ADR 0005 D2) and the
40
+ * current tool surface contract (2026-07-25 widening): the Chat exposes the
41
+ * FULL operator action surface (all registry actions, including high-risk
42
+ * mutations like contractApply / runDag / dagRerun) PLUS the built-in
43
+ * read/explore tools (bash / read / grep / find / ls). The ONLY thing still
44
+ * forbidden is direct file-WRITING via coding tools (edit / write /
45
+ * apply_patch / full-tools / shell / coding-chat) — those are excluded from
46
+ * the SDK registry entirely and can never be activated.
47
+ *
48
+ * Instruction skills are appended AFTER this as read-only context (plan D1).
49
+ */
50
+ const OPERATOR_CHAT_SYSTEM_PROMPT_BASE = [
51
+ "You are the General Operator Chat for loop-agent / agent-worker.",
52
+ "You are an OPERATOR: you orchestrate and inspect via the registered operator_* tools. You are NOT an implementer.",
53
+ "Tool surface: you have safe read-only explore tools (safe-read, safe-grep, git-status, git-diff, find, ls) to probe the repo, AND the model-callable operator_* tools. bash is NOT available (it was removed to close a write-via-redirect escape); use safe-read / safe-grep / git-status / git-diff instead.",
54
+ "safe-read / safe-grep enforce a repo-relative path boundary, a sensitive-file denylist (.env* / *.key / *.pem / .git/** / auth.json / sessions/**) and secret scrubbing. Do not attempt to read those files via any other path.",
55
+ "You do NOT have edit / write / apply_patch / full-tools / shell / coding-chat — file-WRITING is gated behind the DAG path. All canonical writes still go through DAG implement-pi / repair-pi, never via this chat.",
56
+ "High-risk mutations are not model-executable tools. Prepare contract via interview then apply via contractApply, prepare DAG runs via prepareDagConfirmation, prepare other mutations via prepareMutationGate, then confirm in the browser Human Gate. When a high-risk action (contractApply / runDag / dagRerun / etc.) fails on missing prepared state, use read-only tools (status, doctor, dagReport, inspect, contractShow, safe-read, safe-grep) to diagnose.",
57
+ "You cannot self-confirm a DAG run: confirmDagConfirmation is a human-only action (executed by the browser with a server-signed confirmation token). You may only prepare it via prepareDagConfirmation; the user must confirm in the UI.",
58
+ "Prefer read-only tools (status, doctor, dagReport, inspect, contractShow, safe-read, safe-grep, git-status) to diagnose before acting.",
59
+ ].join("\n");
60
+ /**
61
+ * Built-in SDK read/explore tool names registered for every Chat session.
62
+ *
63
+ * M0-A (roadmap §14 工作块 A): bash is REMOVED — it could write files via
64
+ * `echo >` / `tee` / `sed -i` / `node -e writeFile`, bypassing the DAG write
65
+ * boundary. The residual read-only repo probing is served by the custom
66
+ * safe-read / safe-grep / find / ls tools (see buildExploreCustomTools) which
67
+ * enforce a repo-relative path boundary, a sensitive-file denylist, and
68
+ * secret scrubbing on the returned content. edit/write/... stay excluded.
69
+ */
70
+ export const OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS = Object.freeze([
71
+ "find",
72
+ "ls",
73
+ ]);
74
+ /**
75
+ * Canonical active tool-set handed to setActiveToolsByName at every gate:
76
+ * every model-callable operator action (dynamic, from the registry) PLUS the
77
+ * built-in explore tools (find/ls) PLUS the safe explore custom tools
78
+ * (safe-read / safe-grep / git-status / git-diff). bash is NOT here (M0-A).
79
+ * File-WRITING coding tools (edit/write/...) are never here — they are
80
+ * excluded at the registry level and thus cannot be activated.
81
+ */
82
+ export const OPERATOR_CHAT_SAFE_EXPLORE_TOOL_IDS = Object.freeze([
83
+ "safe-read",
84
+ "safe-grep",
85
+ "git-status",
86
+ "git-diff",
87
+ ]);
88
+ export function OPERATOR_CHAT_ACTIVE_TOOL_NAMES() {
89
+ return [
90
+ ...OPERATOR_CHAT_ALLOWED_TOOLS,
91
+ ...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS,
92
+ ...OPERATOR_CHAT_SAFE_EXPLORE_TOOL_IDS,
93
+ ];
94
+ }
95
+ /**
96
+ * Build Pi custom ToolDefinition objects for every whitelisted operator action.
97
+ * Each tool's execute() routes into the operator action dispatcher (Gate 3).
98
+ * Requires the `typebox` + `defineTool` packages (both bundled with the SDK).
99
+ */
100
+ async function buildCustomOperatorTools(options) {
101
+ // typebox is a transitive dep of @earendil-works/pi-coding-agent; Node's
102
+ // resolver hoists to the SDK's nested copy. defineTool is re-exported by SDK.
103
+ const [{ Type }, { defineTool }] = await Promise.all([
104
+ import("typebox"),
105
+ import("@earendil-works/pi-coding-agent"),
106
+ ]);
107
+ const schemas = buildModelCallableToolSchemas();
108
+ const ctx = options.actionContext;
109
+ return schemas.map((schema) => {
110
+ // TypeBox schema is built dynamically; cast through unknown to the builder
111
+ // since Static<TParams> is structural and cannot be statically inferred here.
112
+ const parameters = Type.Object(schema.inputParams.reduce((acc, param) => {
113
+ let t;
114
+ switch (param.type) {
115
+ case "string":
116
+ t = Type.String();
117
+ break;
118
+ case "boolean":
119
+ t = Type.Boolean();
120
+ break;
121
+ case "number":
122
+ t = Type.Number();
123
+ break;
124
+ case "array":
125
+ t = Type.Array(Type.String());
126
+ break;
127
+ default:
128
+ t = Type.Object({}, { additionalProperties: true });
129
+ }
130
+ acc[param.name] = param.required
131
+ ? t
132
+ : Type.Optional(t);
133
+ return acc;
134
+ }, {}), { additionalProperties: false });
135
+ return defineTool({
136
+ name: schema.toolId,
137
+ label: schema.toolId,
138
+ description: `${schema.description} (kind=${schema.kind}; cli: ${schema.cli})`,
139
+ promptSnippet: `${schema.toolId}: ${schema.description}`,
140
+ parameters: parameters,
141
+ async execute(toolCallId, params) {
142
+ const decision = authorizeOperatorChatTool(schema.toolId);
143
+ if (!decision.ok) {
144
+ return {
145
+ content: [
146
+ { type: "text", text: JSON.stringify({ error: decision.message, code: decision.code }) },
147
+ ],
148
+ details: { error: decision.message, code: decision.code },
149
+ };
150
+ }
151
+ // Defer require to avoid circular import at module load.
152
+ const { dispatchChatToolCall } = await import("./session-store.js");
153
+ const result = await dispatchChatToolCall(ctx, decision.toolId, params, toolCallId);
154
+ const payload = result.ok
155
+ ? result.result
156
+ : { error: result.message, code: result.errorCode };
157
+ return {
158
+ content: [{ type: "text", text: JSON.stringify(payload) }],
159
+ details: payload,
160
+ };
161
+ },
162
+ });
163
+ });
164
+ }
165
+ /**
166
+ * Build the safe explore custom tools (roadmap M0-A / G16): safe-read,
167
+ * safe-grep, gitStatus, gitDiff. These replace the bare SDK bash/read/grep so
168
+ * the model can probe the repo WITHOUT write escapes (echo redirect, tee,
169
+ * sed -i, node -e) and WITHOUT reading sensitive files (env files, key/pem,
170
+ * auth.json, sessions dir) — and every returned byte is secret-scrubbed
171
+ * before reaching the model context.
172
+ *
173
+ * gitStatus/gitDiff use a FIXED argv (typed, no shell) so they cannot be
174
+ * turned into an arbitrary-shell vector.
175
+ */
176
+ async function buildExploreCustomTools(options) {
177
+ const [{ Type }, { defineTool }] = await Promise.all([
178
+ import("typebox"),
179
+ import("@earendil-works/pi-coding-agent"),
180
+ ]);
181
+ const { execFileSync } = await import("node:child_process");
182
+ const repoRoot = options.repoRoot;
183
+ const { safeReadFile, safeGrep, scrubSecrets } = await import("./explore-tools.js");
184
+ function safeGit(args) {
185
+ try {
186
+ const stdout = execFileSync("git", args, {
187
+ cwd: repoRoot,
188
+ encoding: "utf8",
189
+ // Fixed argv, no shell, bounded output.
190
+ maxBuffer: 512 * 1024,
191
+ timeout: 10_000,
192
+ windowsHide: true,
193
+ });
194
+ return { ok: true, stdout };
195
+ }
196
+ catch (e) {
197
+ return {
198
+ ok: false,
199
+ code: "GIT_FAILED",
200
+ message: e instanceof Error ? e.message : String(e),
201
+ };
202
+ }
203
+ }
204
+ const safeReadTool = defineTool({
205
+ name: "safe-read",
206
+ label: "safe-read",
207
+ description: "Read a repo-relative file with sensitive-file denylist (.env*/*.key/*.pem/.git/**/auth.json/sessions/**) and secret scrubbing. Absolute paths and .. are rejected.",
208
+ promptSnippet: "safe-read: read a repo file (safe; secrets scrubbed)",
209
+ parameters: Type.Object({
210
+ path: Type.String({ description: "repo-relative file path" }),
211
+ }, { additionalProperties: false }),
212
+ async execute(_toolCallId, params) {
213
+ const raw = String(params.path ?? "");
214
+ try {
215
+ const result = await safeReadFile(repoRoot, raw);
216
+ return {
217
+ content: [{ type: "text", text: JSON.stringify(result) }],
218
+ details: result,
219
+ };
220
+ }
221
+ catch (e) {
222
+ const err = e;
223
+ return {
224
+ content: [
225
+ {
226
+ type: "text",
227
+ text: JSON.stringify({
228
+ error: err.message ?? String(e),
229
+ code: err.code ?? "READ_FAILED",
230
+ }),
231
+ },
232
+ ],
233
+ details: { error: err.message ?? String(e), code: err.code ?? "READ_FAILED" },
234
+ };
235
+ }
236
+ },
237
+ });
238
+ const safeGrepTool = defineTool({
239
+ name: "safe-grep",
240
+ label: "safe-grep",
241
+ description: "Line search across the repo working tree with sensitive-file denylist and secret scrubbing. pattern is a JS regex source; optional glob filters paths.",
242
+ promptSnippet: "safe-grep: search repo files (safe; secrets scrubbed)",
243
+ parameters: Type.Object({
244
+ pattern: Type.String({ description: "regex source (no shell)" }),
245
+ caseInsensitive: Type.Optional(Type.Boolean()),
246
+ glob: Type.Optional(Type.String({ description: "optional glob filter e.g. *.ts" })),
247
+ }, { additionalProperties: false }),
248
+ async execute(_toolCallId, params) {
249
+ try {
250
+ const result = await safeGrep(repoRoot, String(params.pattern ?? ""), {
251
+ caseInsensitive: Boolean(params.caseInsensitive),
252
+ glob: params.glob ? String(params.glob) : undefined,
253
+ });
254
+ return {
255
+ content: [{ type: "text", text: JSON.stringify(result) }],
256
+ details: result,
257
+ };
258
+ }
259
+ catch (e) {
260
+ const err = e;
261
+ return {
262
+ content: [
263
+ {
264
+ type: "text",
265
+ text: JSON.stringify({
266
+ error: err.message ?? String(e),
267
+ code: err.code ?? "GREP_FAILED",
268
+ }),
269
+ },
270
+ ],
271
+ details: { error: err.message ?? String(e), code: err.code ?? "GREP_FAILED" },
272
+ };
273
+ }
274
+ },
275
+ });
276
+ const gitStatusTool = defineTool({
277
+ name: "git-status",
278
+ label: "git-status",
279
+ description: "Typed `git status --porcelain` (fixed argv, no shell). Returns scrubbed working-tree status.",
280
+ promptSnippet: "git-status: working tree status (read-only)",
281
+ parameters: Type.Object({}, { additionalProperties: false }),
282
+ async execute() {
283
+ const r = safeGit(["status", "--porcelain"]);
284
+ if (!r.ok) {
285
+ return {
286
+ content: [{ type: "text", text: JSON.stringify({ error: r.message, code: r.code }) }],
287
+ details: { error: r.message, code: r.code },
288
+ };
289
+ }
290
+ const { scrubbed, redactions } = scrubSecrets(r.stdout);
291
+ const payload = { status: scrubbed, redactions };
292
+ return {
293
+ content: [{ type: "text", text: JSON.stringify(payload) }],
294
+ details: payload,
295
+ };
296
+ },
297
+ });
298
+ const gitDiffTool = defineTool({
299
+ name: "git-diff",
300
+ label: "git-diff",
301
+ description: "Typed `git diff` (fixed argv: --stat or a single -- path; no shell). Returns scrubbed diff. path must be repo-relative and not sensitive.",
302
+ promptSnippet: "git-diff: diff (read-only, scrubbed)",
303
+ parameters: Type.Object({
304
+ path: Type.Optional(Type.String({ description: "optional repo-relative path to diff (default: whole tree --stat)" })),
305
+ }, { additionalProperties: false }),
306
+ async execute(_toolCallId, params) {
307
+ let rawPath = params.path ? String(params.path) : undefined;
308
+ // If a path is supplied, validate it via the read-path guard so git diff
309
+ // cannot be aimed at sensitive files (.env* / auth.json / sessions).
310
+ if (rawPath) {
311
+ const { guardReadPath } = await import("./explore-tools.js");
312
+ const g = await guardReadPath(repoRoot, rawPath);
313
+ if (!g.ok) {
314
+ return {
315
+ content: [
316
+ { type: "text", text: JSON.stringify({ error: g.message, code: g.code }) },
317
+ ],
318
+ details: { error: g.message, code: g.code },
319
+ };
320
+ }
321
+ rawPath = g.repoRelative;
322
+ }
323
+ const args = rawPath
324
+ ? ["diff", "--", rawPath]
325
+ : ["diff", "--stat"];
326
+ const r = safeGit(args);
327
+ if (!r.ok) {
328
+ return {
329
+ content: [{ type: "text", text: JSON.stringify({ error: r.message, code: r.code }) }],
330
+ details: { error: r.message, code: r.code },
331
+ };
332
+ }
333
+ const { scrubbed, redactions } = scrubSecrets(r.stdout);
334
+ const payload = { diff: scrubbed, redactions };
335
+ return {
336
+ content: [{ type: "text", text: JSON.stringify(payload) }],
337
+ details: payload,
338
+ };
339
+ },
340
+ });
341
+ return [safeReadTool, safeGrepTool, gitStatusTool, gitDiffTool];
342
+ }
343
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
344
+ /**
345
+ * Extract the latest assistant text fragment from an SDK message object.
346
+ * Tolerant of multiple message shapes the SDK emits.
347
+ */
348
+ export function extractAssistantText(message) {
349
+ if (!message || typeof message !== "object")
350
+ return "";
351
+ const m = message;
352
+ if (typeof m.text === "string")
353
+ return m.text;
354
+ const contents = Array.isArray(m.content) ? m.content : [];
355
+ const parts = Array.isArray(m.parts) ? m.parts : [];
356
+ const segments = [];
357
+ for (const c of [...contents, ...parts]) {
358
+ if (!c || typeof c !== "object")
359
+ continue;
360
+ const seg = c;
361
+ if (typeof seg.text === "string")
362
+ segments.push(seg.text);
363
+ }
364
+ return segments.join("");
365
+ }
366
+ /**
367
+ * Console Pi runtime — holds Chat sessions and drives prompt() with three-gate
368
+ * tool enforcement. Session-per-sessionId; cross-request reuse via open().
369
+ */
370
+ export class ConsolePiRuntime {
371
+ options;
372
+ loader;
373
+ sessions = new Map();
374
+ modelRuntimes = new Map();
375
+ sessionManagers = new Map();
376
+ mainlineLeaves = new Map();
377
+ bindings;
378
+ disabledInstructionSkills = new Set();
379
+ constructor(options) {
380
+ this.options = options;
381
+ this.loader = createOperatorChatResourceLoader();
382
+ this.bindings =
383
+ options.bindings ?? createDefaultPiSdkBindings();
384
+ }
385
+ get sessionDir() {
386
+ return this.options.sessionDir;
387
+ }
388
+ /** Resolve the Pi agent directory for bounded Console configuration APIs. */
389
+ async getAgentDir() {
390
+ return safeGetAgentDir(this.bindings);
391
+ }
392
+ async testConfiguredModel(model) {
393
+ const { services } = await this.bindings.createServices({ cwd: this.options.cwd, agentDir: await this.getAgentDir() });
394
+ return { available: Boolean(this.bindings.resolveModel({ modelRuntime: services.modelRuntime, provider: model.provider, modelId: model.modelId })) };
395
+ }
396
+ setDisabledInstructionSkills(names) {
397
+ this.disabledInstructionSkills = new Set(names);
398
+ }
399
+ /** Operator action context used by custom-tool dispatchers (Gate 3). */
400
+ get actionContext() {
401
+ return this.options.actionContext;
402
+ }
403
+ /**
404
+ * Resolve the Chat default model descriptor from harness.json
405
+ * executors.pi.MED + the SDK available-model list. Returns undefined when
406
+ * harness is unset or no provider surfaces the model. No hardcoded fallback.
407
+ */
408
+ async resolveDefaultModel() {
409
+ let available;
410
+ try {
411
+ // Build a throwaway services just to read modelRuntime.snapshot. This is
412
+ // cheap (SDK caches); tests inject bindings that stub listAvailableModels.
413
+ const { services } = await this.bindings.createServices({
414
+ cwd: this.options.cwd,
415
+ agentDir: await safeGetAgentDir(this.bindings),
416
+ });
417
+ available = this.bindings.listAvailableModels({
418
+ modelRuntime: services.modelRuntime,
419
+ });
420
+ }
421
+ catch {
422
+ return undefined;
423
+ }
424
+ const descriptor = await resolveDefaultChatModel(this.options.cwd, available);
425
+ if (!descriptor)
426
+ return undefined;
427
+ return {
428
+ provider: descriptor.provider,
429
+ modelId: descriptor.modelId,
430
+ };
431
+ }
432
+ /**
433
+ * Load the operator-context instruction skills (plan D1) and compose the
434
+ * read-only methodology fragment for the Chat system prompt. Returns an
435
+ * empty string when no skills load (e.g. skills dir missing) so the
436
+ * session still boots — skills are context, not a hard dependency.
437
+ */
438
+ async composeSystemPromptSkills() {
439
+ try {
440
+ const result = await loadOperatorChatInstructionSkills(this.options.skillsDir, OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS.filter((name) => !this.disabledInstructionSkills.has(name)));
441
+ return composeInstructionSkillsPrompt(result.loaded);
442
+ }
443
+ catch (error) {
444
+ process.stderr.write(`[console] chat instruction-skills load failed: ${error instanceof Error ? error.message : String(error)}\n`);
445
+ return "";
446
+ }
447
+ }
448
+ /** Create a new isolated Chat session pinned to the operator-chat tool set. */
449
+ async createSession(init) {
450
+ const sessionId = `operator-chat-${randomBytes(12).toString("hex")}`;
451
+ const agentDir = await safeGetAgentDir(this.bindings);
452
+ // Resolve default model from harness.json executors.pi.MED (no hardcode).
453
+ // Caller may pass an explicit model; otherwise we read the repo harness and
454
+ // search the SDK available-model list for a credentialled provider.
455
+ let model = init?.model;
456
+ if (!model) {
457
+ const resolved = await this.resolveDefaultModel();
458
+ if (resolved)
459
+ model = resolved;
460
+ }
461
+ // Final fallback only when nothing resolved: let the SDK pick its default
462
+ // (we do NOT hardcode a provider here).
463
+ // Load operator-context instruction skills (plan D1) and compose the
464
+ // read-only methodology fragment injected into the Chat system prompt.
465
+ // Skills are NEVER registered as executable tools (D1 hard rule).
466
+ const skillFragment = await this.composeSystemPromptSkills();
467
+ const appendSystemPrompt = [
468
+ OPERATOR_CHAT_SYSTEM_PROMPT_BASE,
469
+ ...(skillFragment ? [skillFragment] : []),
470
+ ...(init?.systemPromptSuffix ? [init.systemPromptSuffix] : []),
471
+ ];
472
+ const { services } = await this.bindings.createServices({
473
+ cwd: this.options.cwd,
474
+ agentDir,
475
+ resourceLoaderOptions: {
476
+ // Closed surface: do not load user/project extensions, skills,
477
+ // AGENTS.md, context files. The Chat tool surface is fixed by
478
+ // the whitelist, not by filesystem discovery.
479
+ noContextFiles: true,
480
+ noSkills: true,
481
+ noExtensions: true,
482
+ // Inject the audited instruction skills as READ-ONLY context
483
+ // (plan D1). appendSystemPrompt is the SDK's documented entry
484
+ // point for adding system-prompt text without enabling tools.
485
+ appendSystemPrompt,
486
+ },
487
+ });
488
+ const modelRuntime = services.modelRuntime;
489
+ // Resolve the concrete SDK model only when we have a descriptor. When no
490
+ // model is resolved (harness unset + no snapshot match), we omit `model`
491
+ // and let the SDK session use its own default (no hardcoded fallback).
492
+ const resolvedModel = model
493
+ ? (this.bindings.resolveModel({
494
+ modelRuntime,
495
+ provider: model.provider,
496
+ modelId: model.modelId,
497
+ }) ?? undefined)
498
+ : undefined;
499
+ const sessionManager = await this.bindings.createSessionManager({
500
+ cwd: this.options.cwd,
501
+ sessionDir: this.options.sessionDir,
502
+ sessionId,
503
+ });
504
+ // Gate 1: pin the tool set at session create. assertNoWriteTool guards
505
+ // that no file-WRITING coding tool slipped into the allowed list (bash
506
+ // is intentionally allowed since the 2026-07-25 widening).
507
+ assertNoWriteToolInList(OPERATOR_CHAT_ALLOWED_TOOLS);
508
+ // Build custom Pi tools that route whitelisted operator actions into
509
+ // the dispatcher (Gate 3 lives inside each tool's execute()).
510
+ let operatorCustomTools = [];
511
+ if (this.options.actionContext) {
512
+ try {
513
+ operatorCustomTools = await buildCustomOperatorTools({
514
+ actionContext: this.options.actionContext,
515
+ });
516
+ }
517
+ catch (error) {
518
+ // If custom-tool build fails (e.g. typebox unavailable), fall back to
519
+ // a tool-less session — Chat still works as text-only, and the
520
+ // failure is surfaced via doctor / readiness diagnostics.
521
+ process.stderr.write(`[console] chat custom-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
522
+ }
523
+ }
524
+ // M0-A: also register the safe explore tools (safe-read / safe-grep /
525
+ // git-status / git-diff) so the model can probe the repo without bash.
526
+ let exploreCustomTools = [];
527
+ try {
528
+ exploreCustomTools = await buildExploreCustomTools({
529
+ repoRoot: this.options.cwd,
530
+ });
531
+ }
532
+ catch (error) {
533
+ process.stderr.write(`[console] chat explore-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
534
+ }
535
+ const customTools = [...operatorCustomTools, ...exploreCustomTools];
536
+ // Tool surface (roadmap M0):
537
+ // - ALL model-callable operator actions are registered as customTools.
538
+ // - Safe explore tools (safe-read / safe-grep / git-status / git-diff)
539
+ // are also registered as customTools (bash is GONE — M0-A removes
540
+ // the write-via-redirect escape).
541
+ // - Built-in find/ls are registered by passing `tools:` (Gate 1). The
542
+ // default builtin read/bash/edit/write are EXCLUDED: read/bash/grep
543
+ // are replaced by the safe custom versions above; edit/write stay
544
+ // excluded (file-writing never activated).
545
+ // by passing `tools: OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS` (Gate 1).
546
+ // This is REQUIRED: the SDK's default builtin set is only
547
+ // `read, bash, edit, write` (grep/find/ls are NOT default builtins —
548
+ // see SDK CreateAgentSessionOptions.tools docs). Without `tools`, the
549
+ const { session } = await this.bindings.createSessionFromServices({
550
+ services,
551
+ sessionManager,
552
+ // Explicitly register the explore-tool builtin set (Gate 1): find/ls.
553
+ // The default builtin read/bash/edit/write are EXCLUDED below; read/
554
+ // grep/bash are replaced by the safe customTools (M0-A).
555
+ tools: [...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS],
556
+ excludeTools: [
557
+ "edit",
558
+ "write",
559
+ "apply_patch",
560
+ "apply-patch",
561
+ "full-tools",
562
+ "full_tools",
563
+ "coding-chat",
564
+ "coding_chat",
565
+ "shell",
566
+ // M0-A: bash is removed entirely (write-via-redirect escape). The
567
+ // model gets safe-read / safe-grep custom tools instead.
568
+ "bash",
569
+ // M0-A: built-in read/grep are replaced by custom safe-read/safe-grep
570
+ // that enforce a repo-relative boundary, a sensitive-file denylist,
571
+ // and secret scrubbing (roadmap G16).
572
+ "read",
573
+ "grep",
574
+ ],
575
+ ...(customTools && customTools.length > 0
576
+ ? { customTools }
577
+ : {}),
578
+ ...(resolvedModel ? { model: resolvedModel } : {}),
579
+ });
580
+ // Gate 2: re-pin active tools (defensive — some SDK paths may seed
581
+ // defaults). Active set = all operator actions + built-in explore tools.
582
+ // File-writing tools are excluded above and thus cannot appear here.
583
+ session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
584
+ this.sessions.set(sessionId, session);
585
+ this.sessionManagers.set(sessionId, sessionManager);
586
+ this.modelRuntimes.set(sessionId, modelRuntime);
587
+ return {
588
+ sessionId,
589
+ sessionFile: session.sessionFile,
590
+ createdAt: new Date().toISOString(),
591
+ model,
592
+ activeTools: session.getActiveToolNames(),
593
+ };
594
+ }
595
+ async forkSession(init) {
596
+ if (!this.bindings.forkSessionManager)
597
+ throw new Error("SESSION_FORK_UNSUPPORTED");
598
+ const sessionId = `operator-chat-${randomBytes(12).toString("hex")}`;
599
+ const sessionManager = await this.bindings.forkSessionManager({
600
+ sourcePath: init.sourceSessionFile,
601
+ targetCwd: this.options.cwd,
602
+ targetSessionDir: this.options.sessionDir,
603
+ targetSessionId: sessionId,
604
+ });
605
+ const built = await this.buildSessionWithServices({ sessionManager, model: init.model });
606
+ built.session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
607
+ this.sessions.set(sessionId, built.session);
608
+ this.sessionManagers.set(sessionId, sessionManager);
609
+ this.modelRuntimes.set(sessionId, built.modelRuntime);
610
+ return { sessionId, sessionFile: built.session.sessionFile, createdAt: new Date().toISOString(), model: init.model, activeTools: built.session.getActiveToolNames() };
611
+ }
612
+ async branchSession(input) {
613
+ const session = this.sessions.get(input.sessionId);
614
+ const manager = this.sessionManagers.get(input.sessionId);
615
+ if (!session || !manager)
616
+ throw new Error(`chat session not active: ${input.sessionId}`);
617
+ if (session.isStreaming === true || session.isIdle === false)
618
+ throw new Error("CHAT_TURN_ACTIVE");
619
+ if (!manager.getEntry?.(input.branchFromEntryId))
620
+ throw new Error("BRANCH_ENTRY_NOT_FOUND");
621
+ if (!this.mainlineLeaves.has(input.sessionId))
622
+ this.mainlineLeaves.set(input.sessionId, manager.getLeafId?.());
623
+ const entry = input.summary
624
+ ? manager.branchWithSummary?.(input.branchFromEntryId, input.summary)
625
+ : (manager.branch(input.branchFromEntryId), undefined);
626
+ return { branchedFromEntryId: input.branchFromEntryId, ...(entry?.id ? { branchSummaryEntryId: entry.id } : {}) };
627
+ }
628
+ switchToMainline(sessionId) {
629
+ const manager = this.sessionManagers.get(sessionId);
630
+ if (!manager)
631
+ throw new Error(`chat session not active: ${sessionId}`);
632
+ manager.branch(this.mainlineLeaves.get(sessionId));
633
+ this.mainlineLeaves.delete(sessionId);
634
+ }
635
+ getBranchContext(sessionId) {
636
+ const manager = this.sessionManagers.get(sessionId);
637
+ if (!manager)
638
+ throw new Error(`chat session not active: ${sessionId}`);
639
+ return { leafId: manager.getLeafId?.() ?? null, entries: manager.getBranch?.() ?? [] };
640
+ }
641
+ async listModels(sessionId) {
642
+ const modelRuntime = this.modelRuntimes.get(sessionId);
643
+ if (!this.sessions.has(sessionId) || !modelRuntime)
644
+ throw new Error(`chat session not active: ${sessionId}`);
645
+ return this.bindings.listAvailableModels({ modelRuntime });
646
+ }
647
+ async applyModel(sessionId, model) {
648
+ const session = this.sessions.get(sessionId);
649
+ const modelRuntime = this.modelRuntimes.get(sessionId);
650
+ if (!session || !modelRuntime)
651
+ throw new Error(`chat session not active: ${sessionId}`);
652
+ if (!session.setModel)
653
+ throw new Error("MODEL_SWITCH_UNSUPPORTED");
654
+ const resolved = this.bindings.resolveModel({ modelRuntime, provider: model.provider, modelId: model.modelId });
655
+ if (!resolved)
656
+ throw new Error("MODEL_NOT_AVAILABLE");
657
+ await session.setModel(resolved);
658
+ return { provider: session.model?.provider ?? model.provider, modelId: session.model?.id ?? session.model?.modelId ?? model.modelId };
659
+ }
660
+ applyThinkingLevel(sessionId, level) {
661
+ const session = this.sessions.get(sessionId);
662
+ if (!session)
663
+ throw new Error(`chat session not active: ${sessionId}`);
664
+ if (!THINKING_LEVELS.includes(level) || !session.setThinkingLevel)
665
+ throw new Error("THINKING_LEVEL_UNSUPPORTED");
666
+ session.setThinkingLevel(level);
667
+ return session.thinkingLevel ?? level;
668
+ }
669
+ getRuntimeSelection(sessionId) {
670
+ const session = this.sessions.get(sessionId);
671
+ if (!session)
672
+ return { activeTools: [] };
673
+ const provider = session.model?.provider;
674
+ const modelId = session.model?.id ?? session.model?.modelId;
675
+ return { ...(provider && modelId ? { model: { provider, modelId } } : {}), ...(session.thinkingLevel ? { thinkingLevel: session.thinkingLevel } : {}), activeTools: session.getActiveToolNames() };
676
+ }
677
+ /**
678
+ * Re-open a previously persisted Chat session after Console restart or
679
+ * browser refresh (roadmap M1-S04 / S05).
680
+ *
681
+ * Unlike `createSession`, this reuses the EXISTING Pi JSONL session file so
682
+ * the conversation history is preserved. The SDK's `SessionManager.open(path)`
683
+ * loads the file; we then rebuild the same services + pinned tool surface so
684
+ * the operator-chat three-gate policy still applies after a restart.
685
+ *
686
+ * Fail-closed contract: if `sessionFile` is missing, or the SDK cannot open
687
+ * it (file deleted/corrupt), returns an `{ ok: false, code }` result rather
688
+ * than silently creating a fresh empty session and losing prior context.
689
+ */
690
+ async reopenSession(init) {
691
+ const sessionId = init.sessionId;
692
+ // Already active in-memory: idempotent reopen is a no-op success.
693
+ if (this.sessions.has(sessionId)) {
694
+ return { ok: true, handle: await this.snapshotActive(sessionId) };
695
+ }
696
+ if (!init.sessionFile) {
697
+ return {
698
+ ok: false,
699
+ code: "NO_SESSION_FILE",
700
+ message: `cannot reopen chat session ${sessionId}: no persisted Pi session file`,
701
+ };
702
+ }
703
+ let sessionManager;
704
+ try {
705
+ sessionManager = await this.bindings.openSessionManager({
706
+ sessionFile: init.sessionFile,
707
+ });
708
+ }
709
+ catch (error) {
710
+ return {
711
+ ok: false,
712
+ code: "REOPEN_FAILED",
713
+ message: error instanceof Error ? error.message : String(error),
714
+ };
715
+ }
716
+ if (!sessionManager) {
717
+ return {
718
+ ok: false,
719
+ code: "REOPEN_FAILED",
720
+ message: `Pi session file not openable: ${init.sessionFile}`,
721
+ };
722
+ }
723
+ try {
724
+ const built = await this.buildSessionWithServices({
725
+ sessionManager,
726
+ model: init.model,
727
+ systemPromptSuffix: init.systemPromptSuffix,
728
+ });
729
+ const session = built.session;
730
+ // The reopened session MUST report the SAME sessionId as the durable
731
+ // record, otherwise we'd be serving a different conversation under a
732
+ // stale id. Mismatch is a fail-closed reopen failure.
733
+ if (session.sessionId !== sessionId) {
734
+ try {
735
+ session.dispose();
736
+ }
737
+ catch {
738
+ // ignore
739
+ }
740
+ return {
741
+ ok: false,
742
+ code: "REOPEN_FAILED",
743
+ message: `reopened Pi sessionId mismatch: expected ${sessionId}, got ${session.sessionId}`,
744
+ };
745
+ }
746
+ session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
747
+ this.sessions.set(sessionId, session);
748
+ this.sessionManagers.set(sessionId, sessionManager);
749
+ this.modelRuntimes.set(sessionId, built.modelRuntime);
750
+ return {
751
+ ok: true,
752
+ handle: {
753
+ sessionId,
754
+ sessionFile: session.sessionFile ?? init.sessionFile,
755
+ createdAt: new Date().toISOString(),
756
+ model: init.model,
757
+ activeTools: session.getActiveToolNames(),
758
+ },
759
+ };
760
+ }
761
+ catch (error) {
762
+ return {
763
+ ok: false,
764
+ code: "REOPEN_FAILED",
765
+ message: error instanceof Error ? error.message : String(error),
766
+ };
767
+ }
768
+ }
769
+ /** Build a handle for an already-active session (idempotent reopen path). */
770
+ async snapshotActive(sessionId) {
771
+ const session = this.sessions.get(sessionId);
772
+ return {
773
+ sessionId,
774
+ sessionFile: session.sessionFile,
775
+ createdAt: new Date().toISOString(),
776
+ model: undefined,
777
+ activeTools: session.getActiveToolNames(),
778
+ };
779
+ }
780
+ /**
781
+ * Shared services + pinned-tool materialization for both createSession and
782
+ * reopenSession. Loads closed-surface services, resolves the model, builds
783
+ * the operator + safe explore customTools, and calls the SDK with the same
784
+ * excludeTools / tools policy (M0-A three-gate).
785
+ */
786
+ async buildSessionWithServices(init) {
787
+ const agentDir = await safeGetAgentDir(this.bindings);
788
+ const skillFragment = await this.composeSystemPromptSkills();
789
+ const appendSystemPrompt = [
790
+ OPERATOR_CHAT_SYSTEM_PROMPT_BASE,
791
+ ...(skillFragment ? [skillFragment] : []),
792
+ ...(init.systemPromptSuffix ? [init.systemPromptSuffix] : []),
793
+ ];
794
+ const { services } = await this.bindings.createServices({
795
+ cwd: this.options.cwd,
796
+ agentDir,
797
+ resourceLoaderOptions: {
798
+ noContextFiles: true,
799
+ noSkills: true,
800
+ noExtensions: true,
801
+ appendSystemPrompt,
802
+ },
803
+ });
804
+ const modelRuntime = services.modelRuntime;
805
+ const resolvedModel = init.model
806
+ ? (this.bindings.resolveModel({
807
+ modelRuntime,
808
+ provider: init.model.provider,
809
+ modelId: init.model.modelId,
810
+ }) ?? undefined)
811
+ : undefined;
812
+ assertNoWriteToolInList(OPERATOR_CHAT_ALLOWED_TOOLS);
813
+ let operatorCustomTools = [];
814
+ if (this.options.actionContext) {
815
+ try {
816
+ operatorCustomTools = await buildCustomOperatorTools({
817
+ actionContext: this.options.actionContext,
818
+ });
819
+ }
820
+ catch (error) {
821
+ process.stderr.write(`[console] chat custom-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
822
+ }
823
+ }
824
+ let exploreCustomTools = [];
825
+ try {
826
+ exploreCustomTools = await buildExploreCustomTools({
827
+ repoRoot: this.options.cwd,
828
+ });
829
+ }
830
+ catch (error) {
831
+ process.stderr.write(`[console] chat explore-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
832
+ }
833
+ const customTools = [...operatorCustomTools, ...exploreCustomTools];
834
+ const { session } = await this.bindings.createSessionFromServices({
835
+ services,
836
+ sessionManager: init.sessionManager,
837
+ tools: [...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS],
838
+ excludeTools: [
839
+ "edit",
840
+ "write",
841
+ "apply_patch",
842
+ "apply-patch",
843
+ "full-tools",
844
+ "full_tools",
845
+ "coding-chat",
846
+ "coding_chat",
847
+ "shell",
848
+ "bash",
849
+ "read",
850
+ "grep",
851
+ ],
852
+ ...(customTools && customTools.length > 0 ? { customTools } : {}),
853
+ ...(resolvedModel ? { model: resolvedModel } : {}),
854
+ });
855
+ return { session, modelRuntime };
856
+ }
857
+ /** Re-open a previously created session (cross-request). */
858
+ open(sessionId) {
859
+ return this.sessions.get(sessionId);
860
+ }
861
+ hasSession(sessionId) {
862
+ return this.sessions.has(sessionId);
863
+ }
864
+ /**
865
+ * Send a prompt and stream events. Gate 3: each tool invocation is
866
+ * re-authorized by authorizeOperatorChatTool before the dispatcher runs it
867
+ * (the dispatcher is wired by the HTTP layer, see chat-session.ts).
868
+ */
869
+ async prompt(sessionId, text, onEvent, options) {
870
+ const session = this.sessions.get(sessionId);
871
+ if (!session) {
872
+ throw new Error(`chat session not found: ${sessionId}`);
873
+ }
874
+ // Gate 2 (re-pin before each turn): interview turns pass through the
875
+ // existing closed interview allow/deny registry. The next ordinary turn
876
+ // explicitly restores the normal Operator Chat set.
877
+ const requestedTools = OPERATOR_CHAT_ACTIVE_TOOL_NAMES();
878
+ const activeTools = options?.mode === "requirement-interview"
879
+ ? filterActiveInterviewTools(requestedTools).allowed
880
+ : requestedTools;
881
+ session.setActiveToolsByName(activeTools);
882
+ const unsub = session.subscribe((event) => {
883
+ const mapped = mapSdkEvent(sessionId, event);
884
+ if (mapped)
885
+ onEvent(mapped);
886
+ });
887
+ const onAbort = () => {
888
+ // M1 T08: Stop must abort only the current turn, NOT dispose the
889
+ // session. SDK AgentSession.abort() aborts the current operation and
890
+ // waits for the agent to become idle, keeping the session reusable for
891
+ // the next turn. Only fall back to dispose if the session has no abort()
892
+ // (e.g. a minimal stub), since a stuck turn should not leak forever.
893
+ try {
894
+ if (typeof session.abort === "function") {
895
+ void session.abort();
896
+ }
897
+ else {
898
+ session.dispose();
899
+ }
900
+ }
901
+ catch {
902
+ // ignore
903
+ }
904
+ };
905
+ options?.signal?.addEventListener("abort", onAbort);
906
+ try {
907
+ await session.prompt(text, options?.images?.length ? { images: options.images } : undefined);
908
+ // Wait until the session reports idle / not streaming. The SDK prompt()
909
+ // resolves when the agent turn completes, but post-turn continuation
910
+ // (auto-compaction / follow-up) may still be in flight. We poll isIdle.
911
+ // While polling we emit periodic heartbeat events so the SSE stream
912
+ // keeps proxies/browsers from timing out (no other data is flowing).
913
+ await waitForIdle(session, {
914
+ signal: options?.signal,
915
+ onHeartbeat: () => onEvent({ type: "heartbeat", sessionId }),
916
+ });
917
+ onEvent({ type: "agent_settled", sessionId });
918
+ }
919
+ catch (error) {
920
+ const message = error instanceof Error ? error.message : String(error);
921
+ onEvent({ type: "error", sessionId, message });
922
+ throw error;
923
+ }
924
+ finally {
925
+ options?.signal?.removeEventListener("abort", onAbort);
926
+ unsub();
927
+ }
928
+ }
929
+ async compact(sessionId, customInstructions) {
930
+ const session = this.sessions.get(sessionId);
931
+ if (!session)
932
+ throw new Error(`chat session not found: ${sessionId}`);
933
+ if (session.isStreaming === true || session.isIdle === false)
934
+ throw new Error("CHAT_TURN_ACTIVE");
935
+ if (typeof session.compact !== "function")
936
+ throw new Error("COMPACTION_UNSUPPORTED");
937
+ const result = await session.compact(customInstructions);
938
+ return projectCompactSnapshot(result ?? {});
939
+ }
940
+ dispose(sessionId) {
941
+ const session = this.sessions.get(sessionId);
942
+ if (session) {
943
+ try {
944
+ session.dispose();
945
+ }
946
+ catch {
947
+ // ignore
948
+ }
949
+ this.sessions.delete(sessionId);
950
+ this.sessionManagers.delete(sessionId);
951
+ this.mainlineLeaves.delete(sessionId);
952
+ this.modelRuntimes.delete(sessionId);
953
+ }
954
+ }
955
+ disposeAll() {
956
+ for (const id of [...this.sessions.keys()])
957
+ this.dispose(id);
958
+ }
959
+ }
960
+ function mapSdkEvent(sessionId, event) {
961
+ switch (event.type) {
962
+ case "agent_start":
963
+ return { type: "agent_start", sessionId };
964
+ case "agent_end":
965
+ return {
966
+ type: "agent_end",
967
+ sessionId,
968
+ willRetry: Boolean(event.willRetry),
969
+ };
970
+ case "message_update":
971
+ return {
972
+ type: "message_update",
973
+ sessionId,
974
+ text: extractAssistantText(event.message),
975
+ };
976
+ case "message_end":
977
+ return { type: "message_end", sessionId, text: extractAssistantText(event.message) };
978
+ case "turn_end": {
979
+ const usage = extractUsageSample(event);
980
+ return usage ? { type: "usage", sessionId, usage } : undefined;
981
+ }
982
+ case "tool_execution_start":
983
+ return {
984
+ type: "tool_call",
985
+ sessionId,
986
+ toolCallId: event.toolCallId,
987
+ toolName: event.toolName,
988
+ args: event.args,
989
+ };
990
+ case "tool_execution_end":
991
+ return {
992
+ type: "tool_result",
993
+ sessionId,
994
+ toolCallId: event.toolCallId,
995
+ toolName: event.toolName,
996
+ result: event.result,
997
+ isError: event.isError,
998
+ };
999
+ case "agent_settled":
1000
+ return { type: "agent_settled", sessionId };
1001
+ default:
1002
+ return undefined;
1003
+ }
1004
+ }
1005
+ async function waitForIdle(session, options) {
1006
+ // Default 45s — shorter than the previous 120s so a stuck SDK session does
1007
+ // not pin an HTTP connection for two full minutes. The SDK agent turn
1008
+ // normally settles in seconds; 45s covers auto-compaction/follow-up tails
1009
+ // while staying under typical proxy idle timeouts (60s).
1010
+ const timeoutMs = options?.timeoutMs ?? 45_000;
1011
+ const heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 10_000;
1012
+ const start = Date.now();
1013
+ let lastHeartbeat = start;
1014
+ // eslint-disable-next-line no-constant-condition
1015
+ while (true) {
1016
+ if (options?.signal?.aborted)
1017
+ return;
1018
+ if (session.isIdle === true)
1019
+ return;
1020
+ if (session.isStreaming === false)
1021
+ return;
1022
+ const now = Date.now();
1023
+ if (now - start > timeoutMs)
1024
+ return;
1025
+ if (options?.onHeartbeat && now - lastHeartbeat >= heartbeatIntervalMs) {
1026
+ lastHeartbeat = now;
1027
+ try {
1028
+ options.onHeartbeat();
1029
+ }
1030
+ catch {
1031
+ // Heartbeat is best-effort; never let it break the wait loop.
1032
+ }
1033
+ }
1034
+ await new Promise((r) => setTimeout(r, 50));
1035
+ }
1036
+ }
1037
+ async function safeGetAgentDir(bindings) {
1038
+ try {
1039
+ return await bindings.getAgentDir();
1040
+ }
1041
+ catch {
1042
+ // Fall back to a Console-app-data scoped agent dir so we never accidentally
1043
+ // touch the user's real ~/.pi/agent config if getAgentDir throws.
1044
+ return path.join(process.cwd(), ".harness", "console", "pi-agent-dir");
1045
+ }
1046
+ }
1047
+ /**
1048
+ * Cached SDK module loader. The pi-coding-agent package is ESM-only, so we
1049
+ * must use dynamic import() (not require). Cached to avoid repeated imports.
1050
+ */
1051
+ let cachedSdk;
1052
+ async function loadSdk() {
1053
+ if (!cachedSdk) {
1054
+ cachedSdk = import("@earendil-works/pi-coding-agent");
1055
+ }
1056
+ return cachedSdk;
1057
+ }
1058
+ /**
1059
+ * Default production bindings backed by the real Pi SDK (services mode).
1060
+ * Dynamically imported so the worker bundle does not hard-fail if the SDK is
1061
+ * absent at module load (discovered lazily by pi-readiness instead).
1062
+ */
1063
+ export function createDefaultPiSdkBindings() {
1064
+ return {
1065
+ getAgentDir: async () => {
1066
+ const sdk = (await loadSdk());
1067
+ return sdk.getAgentDir();
1068
+ },
1069
+ createServices: async (opts) => {
1070
+ const sdk = (await loadSdk());
1071
+ // createAgentSessionServices returns the full AgentSessionServices object
1072
+ // directly (cwd/agentDir/modelRuntime/settingsManager/resourceLoader).
1073
+ // Wrap it so our type contract is satisfied.
1074
+ const result = await sdk.createAgentSessionServices({
1075
+ cwd: opts.cwd,
1076
+ agentDir: opts.agentDir,
1077
+ resourceLoaderOptions: opts.resourceLoaderOptions,
1078
+ });
1079
+ return { services: result };
1080
+ },
1081
+ createSessionFromServices: async (opts) => {
1082
+ const sdk = (await loadSdk());
1083
+ return sdk.createAgentSessionFromServices({
1084
+ services: opts.services,
1085
+ sessionManager: opts.sessionManager,
1086
+ ...(opts.tools ? { tools: opts.tools } : {}),
1087
+ ...(opts.noTools ? { noTools: opts.noTools } : {}),
1088
+ ...(opts.excludeTools ? { excludeTools: opts.excludeTools } : {}),
1089
+ ...(opts.customTools ? { customTools: opts.customTools } : {}),
1090
+ ...(opts.model ? { model: opts.model } : {}),
1091
+ });
1092
+ },
1093
+ createSessionManager: async (opts) => {
1094
+ const sdk = (await loadSdk());
1095
+ return sdk.SessionManager.create(opts.cwd, opts.sessionDir, {
1096
+ id: opts.sessionId,
1097
+ });
1098
+ },
1099
+ forkSessionManager: async (opts) => {
1100
+ const sdk = (await loadSdk());
1101
+ return sdk.SessionManager.forkFrom(opts.sourcePath, opts.targetCwd, opts.targetSessionDir, { id: opts.targetSessionId });
1102
+ },
1103
+ openSessionManager: async (opts) => {
1104
+ const sdk = (await loadSdk());
1105
+ try {
1106
+ return sdk.SessionManager.open(opts.sessionFile);
1107
+ }
1108
+ catch {
1109
+ // Missing/corrupt session file — caller fails closed rather than
1110
+ // silently creating a fresh session and losing prior context.
1111
+ return undefined;
1112
+ }
1113
+ },
1114
+ resolveModel: (opts) => {
1115
+ const rt = opts.modelRuntime;
1116
+ if (typeof rt?.getModel !== "function")
1117
+ return undefined;
1118
+ try {
1119
+ return rt.getModel(opts.provider, opts.modelId);
1120
+ }
1121
+ catch {
1122
+ return undefined;
1123
+ }
1124
+ },
1125
+ listAvailableModels: (opts) => {
1126
+ const rt = opts.modelRuntime;
1127
+ const available = rt?.snapshot?.available ?? [];
1128
+ const configured = rt?.snapshot?.configuredProviders;
1129
+ const isConfigured = (provider) => configured instanceof Set ? configured.has(provider) : false;
1130
+ return available.map((m) => ({
1131
+ id: m.id,
1132
+ name: m.name,
1133
+ provider: m.provider,
1134
+ hasCredentials: isConfigured(m.provider),
1135
+ }));
1136
+ },
1137
+ };
1138
+ }
1139
+ /**
1140
+ * Re-export the tool gate for the dispatcher layer so the per-tool-call gate
1141
+ * (Gate 3) lives in exactly one place.
1142
+ */
1143
+ export { authorizeOperatorChatTool, assertNoWriteToolInList, OPERATOR_CHAT_ALLOWED_TOOLS, };