@tea-agent/loop-agent 0.23.1 → 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 (53) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +1 -1
  3. package/bin/agent-worker.js +0 -0
  4. package/dist/executors/shell-executor.js +20 -7
  5. package/dist/shared/operator/capabilities.js +475 -2
  6. package/dist/worker/console/app-data.js +2 -0
  7. package/dist/worker/console/chat/artifact-card.js +23 -0
  8. package/dist/worker/console/chat/chat-event-store.js +495 -0
  9. package/dist/worker/console/chat/chat-ui-policy.js +25 -0
  10. package/dist/worker/console/chat/composer-draft-store.js +45 -0
  11. package/dist/worker/console/chat/context-panel.js +54 -0
  12. package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
  13. package/dist/worker/console/chat/explore-tools.js +299 -0
  14. package/dist/worker/console/chat/human-gate-card.js +37 -0
  15. package/dist/worker/console/chat/interview-adapter.js +136 -0
  16. package/dist/worker/console/chat/operation-card.js +23 -0
  17. package/dist/worker/console/chat/pi-console-config.js +158 -0
  18. package/dist/worker/console/chat/pi-runtime.js +581 -43
  19. package/dist/worker/console/chat/repo-browser.js +140 -0
  20. package/dist/worker/console/chat/repo-walk.js +116 -0
  21. package/dist/worker/console/chat/resource-loader.js +18 -17
  22. package/dist/worker/console/chat/routes.js +1354 -65
  23. package/dist/worker/console/chat/runtime-context.js +24 -0
  24. package/dist/worker/console/chat/runtime-selection.js +37 -0
  25. package/dist/worker/console/chat/session-store.js +210 -11
  26. package/dist/worker/console/chat/shortcuts.js +15 -0
  27. package/dist/worker/console/chat/tool-adapter.js +81 -194
  28. package/dist/worker/console/chat/tools.js +72 -48
  29. package/dist/worker/console/chat/usage.js +37 -0
  30. package/dist/worker/console/chat/workspace-landing.js +56 -0
  31. package/dist/worker/console/dag-confirmation.js +42 -8
  32. package/dist/worker/console/human-gate-token.js +130 -0
  33. package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
  34. package/dist/worker/console/operation-runner.js +6 -2
  35. package/dist/worker/console/operation-sse.js +26 -0
  36. package/dist/worker/console/operator-actions.js +420 -7
  37. package/dist/worker/console/server.js +14 -2
  38. package/dist/worker/console/static/assets/index-BTbrEHnO.css +1 -0
  39. package/dist/worker/console/static/assets/index-D9qLevoP.js +27 -0
  40. package/dist/worker/console/static/index.html +2 -2
  41. package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -5
  42. package/dist/workflows/dag/backend-test-result-contract.js +229 -0
  43. package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
  44. package/dist/workflows/dag/init-hybrid.js +2 -1
  45. package/docs/README.md +1 -1
  46. package/docs/architecture/README.md +5 -5
  47. package/docs/architecture/evolution.md +4 -4
  48. package/docs/architecture/worker-and-feature.md +1 -1
  49. package/docs/templates/backend-test-dag.json +2 -2
  50. package/harness.json +1 -1
  51. package/package.json +1 -1
  52. package/dist/worker/console/static/assets/index-DVl7Jxt5.js +0 -25
  53. package/dist/worker/console/static/assets/index-lVcIr9Ju.css +0 -1
@@ -24,12 +24,15 @@
24
24
  * `createDefaultPiSdkBindings()`.
25
25
  */
26
26
  import { randomBytes } from "node:crypto";
27
+ import { projectCompactSnapshot } from "./chat-event-store.js";
27
28
  import path from "node:path";
29
+ import { extractUsageSample } from "./usage.js";
28
30
  import { OPERATOR_CHAT_ALLOWED_TOOLS, authorizeOperatorChatTool, assertNoWriteToolInList, } from "./tools.js";
29
31
  import { createOperatorChatResourceLoader, } from "./resource-loader.js";
30
- import { loadOperatorChatInstructionSkills, composeInstructionSkillsPrompt, } from "./instruction-skills.js";
31
- import { buildOperatorChatToolSchemas } from "./tool-adapter.js";
32
+ import { loadOperatorChatInstructionSkills, composeInstructionSkillsPrompt, OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS, } from "./instruction-skills.js";
33
+ import { buildModelCallableToolSchemas } from "./tool-adapter.js";
32
34
  import { resolveDefaultChatModel, } from "./model-resolver.js";
35
+ import { filterActiveInterviewTools } from "../interview/tools.js";
33
36
  /**
34
37
  * Base system prompt fragment every General Operator Chat session receives.
35
38
  *
@@ -47,34 +50,47 @@ import { resolveDefaultChatModel, } from "./model-resolver.js";
47
50
  const OPERATOR_CHAT_SYSTEM_PROMPT_BASE = [
48
51
  "You are the General Operator Chat for loop-agent / agent-worker.",
49
52
  "You are an OPERATOR: you orchestrate and inspect via the registered operator_* tools. You are NOT an implementer.",
50
- "Tool surface: you have the built-in read/explore tools (bash, read, grep, find, ls) to probe the repo, AND the full set of operator_* tools (all registered operator actions, including high-risk mutations such as contractApply / runDag / dagRerun / standaloneTaskRerun / workerTaskRetry / importPrd / confirmDagConfirmation / dagRunTask).",
51
- "You do NOT have edit / write / apply_patch / full-tools / shell / coding-chat — file-WRITING is still gated behind the DAG path. All canonical writes still go through DAG implement-pi / repair-pi, never via this chat. The residual write-via-bash-redirect risk (`echo >` / `tee`) is an accepted documented residual.",
52
- "High-risk mutations are now callable as tools, but each still requires its prepared receipt (e.g. contractApply needs a fresh complete assessment; runDag needs a confirmationId from prepareDagConfirmation; dagRerun needs a planHash). When a high-risk action fails on missing prepared state, use read-only tools (status, doctor, dagReport, inspect, contractShow) to diagnose, then tell the user to prepare the receipt via the human-confirmation page if needed.",
53
- "Prefer read-only tools (status, doctor, dagReport, inspect, contractShow) to diagnose before acting.",
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.",
54
59
  ].join("\n");
55
60
  /**
56
61
  * Built-in SDK read/explore tool names registered for every Chat session.
57
62
  *
58
- * These MUST be passed as `tools:` to createSessionFromServices (Gate 1) so the
59
- * SDK actually registers grep/find/ls its default builtin set is only
60
- * read/bash/edit/write. Excluding edit/write via excludeTools then leaves
61
- * exactly bash/read/grep/find/ls as the builtin surface.
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.
62
69
  */
63
70
  export const OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS = Object.freeze([
64
- "bash",
65
- "read",
66
- "grep",
67
71
  "find",
68
72
  "ls",
69
73
  ]);
70
74
  /**
71
75
  * Canonical active tool-set handed to setActiveToolsByName at every gate:
72
- * every operator action (dynamic, from the registry) PLUS the built-in
73
- * explore tools. File-WRITING coding tools (edit/write/...) are never here
74
- * they are excluded at the registry level and thus cannot be activated.
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.
75
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
+ ]);
76
88
  export function OPERATOR_CHAT_ACTIVE_TOOL_NAMES() {
77
- return [...OPERATOR_CHAT_ALLOWED_TOOLS, ...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS];
89
+ return [
90
+ ...OPERATOR_CHAT_ALLOWED_TOOLS,
91
+ ...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS,
92
+ ...OPERATOR_CHAT_SAFE_EXPLORE_TOOL_IDS,
93
+ ];
78
94
  }
79
95
  /**
80
96
  * Build Pi custom ToolDefinition objects for every whitelisted operator action.
@@ -88,7 +104,7 @@ async function buildCustomOperatorTools(options) {
88
104
  import("typebox"),
89
105
  import("@earendil-works/pi-coding-agent"),
90
106
  ]);
91
- const schemas = buildOperatorChatToolSchemas();
107
+ const schemas = buildModelCallableToolSchemas();
92
108
  const ctx = options.actionContext;
93
109
  return schemas.map((schema) => {
94
110
  // TypeBox schema is built dynamically; cast through unknown to the builder
@@ -146,6 +162,185 @@ async function buildCustomOperatorTools(options) {
146
162
  });
147
163
  });
148
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"];
149
344
  /**
150
345
  * Extract the latest assistant text fragment from an SDK message object.
151
346
  * Tolerant of multiple message shapes the SDK emits.
@@ -176,7 +371,11 @@ export class ConsolePiRuntime {
176
371
  options;
177
372
  loader;
178
373
  sessions = new Map();
374
+ modelRuntimes = new Map();
375
+ sessionManagers = new Map();
376
+ mainlineLeaves = new Map();
179
377
  bindings;
378
+ disabledInstructionSkills = new Set();
180
379
  constructor(options) {
181
380
  this.options = options;
182
381
  this.loader = createOperatorChatResourceLoader();
@@ -186,6 +385,17 @@ export class ConsolePiRuntime {
186
385
  get sessionDir() {
187
386
  return this.options.sessionDir;
188
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
+ }
189
399
  /** Operator action context used by custom-tool dispatchers (Gate 3). */
190
400
  get actionContext() {
191
401
  return this.options.actionContext;
@@ -227,7 +437,7 @@ export class ConsolePiRuntime {
227
437
  */
228
438
  async composeSystemPromptSkills() {
229
439
  try {
230
- const result = await loadOperatorChatInstructionSkills(this.options.skillsDir);
440
+ const result = await loadOperatorChatInstructionSkills(this.options.skillsDir, OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS.filter((name) => !this.disabledInstructionSkills.has(name)));
231
441
  return composeInstructionSkillsPrompt(result.loaded);
232
442
  }
233
443
  catch (error) {
@@ -297,10 +507,10 @@ export class ConsolePiRuntime {
297
507
  assertNoWriteToolInList(OPERATOR_CHAT_ALLOWED_TOOLS);
298
508
  // Build custom Pi tools that route whitelisted operator actions into
299
509
  // the dispatcher (Gate 3 lives inside each tool's execute()).
300
- let customTools;
510
+ let operatorCustomTools = [];
301
511
  if (this.options.actionContext) {
302
512
  try {
303
- customTools = await buildCustomOperatorTools({
513
+ operatorCustomTools = await buildCustomOperatorTools({
304
514
  actionContext: this.options.actionContext,
305
515
  });
306
516
  }
@@ -311,26 +521,37 @@ export class ConsolePiRuntime {
311
521
  process.stderr.write(`[console] chat custom-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
312
522
  }
313
523
  }
314
- // Tool surface (2026-07-25 widening):
315
- // - ALL operator actions are registered as customTools (full runtime
316
- // capability surface derived dynamically from the registry).
317
- // - Built-in read/explore tools (bash/read/grep/find/ls) are registered
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).
318
545
  // by passing `tools: OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS` (Gate 1).
319
546
  // This is REQUIRED: the SDK's default builtin set is only
320
547
  // `read, bash, edit, write` (grep/find/ls are NOT default builtins —
321
548
  // see SDK CreateAgentSessionOptions.tools docs). Without `tools`, the
322
- // later setActiveToolsByName(["grep","find","ls"]) would silently
323
- // no-op because those tools would not exist in the registry.
324
- // - We then exclude edit/write so the only file-WRITING path is gone.
325
- // - bash is bare (no command filter); the residual write-via-redirect
326
- // risk is accepted (exec-plan Q9).
327
549
  const { session } = await this.bindings.createSessionFromServices({
328
550
  services,
329
551
  sessionManager,
330
- // Explicitly register the explore-tool builtin set (Gate 1). This is
331
- // what makes grep/find/ls actually available the SDK default builtin
332
- // set is only read/bash/edit/write. Combined with excludeTools below,
333
- // the effective registry = explore tools + customTools (operator actions).
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).
334
555
  tools: [...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS],
335
556
  excludeTools: [
336
557
  "edit",
@@ -342,6 +563,14 @@ export class ConsolePiRuntime {
342
563
  "coding-chat",
343
564
  "coding_chat",
344
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",
345
574
  ],
346
575
  ...(customTools && customTools.length > 0
347
576
  ? { customTools }
@@ -353,6 +582,8 @@ export class ConsolePiRuntime {
353
582
  // File-writing tools are excluded above and thus cannot appear here.
354
583
  session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
355
584
  this.sessions.set(sessionId, session);
585
+ this.sessionManagers.set(sessionId, sessionManager);
586
+ this.modelRuntimes.set(sessionId, modelRuntime);
356
587
  return {
357
588
  sessionId,
358
589
  sessionFile: session.sessionFile,
@@ -361,6 +592,268 @@ export class ConsolePiRuntime {
361
592
  activeTools: session.getActiveToolNames(),
362
593
  };
363
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
+ }
364
857
  /** Re-open a previously created session (cross-request). */
365
858
  open(sessionId) {
366
859
  return this.sessions.get(sessionId);
@@ -378,16 +871,32 @@ export class ConsolePiRuntime {
378
871
  if (!session) {
379
872
  throw new Error(`chat session not found: ${sessionId}`);
380
873
  }
381
- // Gate 2 (re-pin before each turn): defend against any mid-session drift.
382
- session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
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);
383
882
  const unsub = session.subscribe((event) => {
384
883
  const mapped = mapSdkEvent(sessionId, event);
385
884
  if (mapped)
386
885
  onEvent(mapped);
387
886
  });
388
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.
389
893
  try {
390
- session.dispose();
894
+ if (typeof session.abort === "function") {
895
+ void session.abort();
896
+ }
897
+ else {
898
+ session.dispose();
899
+ }
391
900
  }
392
901
  catch {
393
902
  // ignore
@@ -395,7 +904,7 @@ export class ConsolePiRuntime {
395
904
  };
396
905
  options?.signal?.addEventListener("abort", onAbort);
397
906
  try {
398
- await session.prompt(text);
907
+ await session.prompt(text, options?.images?.length ? { images: options.images } : undefined);
399
908
  // Wait until the session reports idle / not streaming. The SDK prompt()
400
909
  // resolves when the agent turn completes, but post-turn continuation
401
910
  // (auto-compaction / follow-up) may still be in flight. We poll isIdle.
@@ -417,6 +926,17 @@ export class ConsolePiRuntime {
417
926
  unsub();
418
927
  }
419
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
+ }
420
940
  dispose(sessionId) {
421
941
  const session = this.sessions.get(sessionId);
422
942
  if (session) {
@@ -427,6 +947,9 @@ export class ConsolePiRuntime {
427
947
  // ignore
428
948
  }
429
949
  this.sessions.delete(sessionId);
950
+ this.sessionManagers.delete(sessionId);
951
+ this.mainlineLeaves.delete(sessionId);
952
+ this.modelRuntimes.delete(sessionId);
430
953
  }
431
954
  }
432
955
  disposeAll() {
@@ -451,11 +974,11 @@ function mapSdkEvent(sessionId, event) {
451
974
  text: extractAssistantText(event.message),
452
975
  };
453
976
  case "message_end":
454
- return {
455
- type: "message_end",
456
- sessionId,
457
- text: extractAssistantText(event.message),
458
- };
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
+ }
459
982
  case "tool_execution_start":
460
983
  return {
461
984
  type: "tool_call",
@@ -573,6 +1096,21 @@ export function createDefaultPiSdkBindings() {
573
1096
  id: opts.sessionId,
574
1097
  });
575
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
+ },
576
1114
  resolveModel: (opts) => {
577
1115
  const rt = opts.modelRuntime;
578
1116
  if (typeof rt?.getModel !== "function")