@tea-agent/loop-agent 0.28.13 → 0.29.1

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 (48) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +73 -15
  3. package/dist/commands/client-recovery.js +56 -1
  4. package/dist/commands/init-upgrade.js +186 -21
  5. package/dist/commands/init.js +1 -1
  6. package/dist/executors/dag-pi-executor.js +52 -3
  7. package/dist/executors/pi-playwright-cli-tool.js +14 -8
  8. package/dist/executors/shell-executor.js +288 -0
  9. package/dist/task/config-types.js +21 -0
  10. package/dist/worker/console/app-data.js +132 -11
  11. package/dist/worker/console/chat/pi-runtime.js +24 -42
  12. package/dist/worker/console/chat/resource-loader.js +11 -20
  13. package/dist/worker/console/chat/routes.js +7 -8
  14. package/dist/worker/console/chat/runtime-context.js +1 -1
  15. package/dist/worker/console/chat/tools.js +67 -54
  16. package/dist/worker/console/operation-runner.js +15 -1
  17. package/dist/worker/console/operation-store.js +70 -49
  18. package/dist/worker/console/operator-actions.js +57 -1
  19. package/dist/worker/console/static/assets/index-Cwx-ZVEQ.js +29 -0
  20. package/dist/worker/console/static/favicon.svg +37 -0
  21. package/dist/worker/console/static/index.html +2 -1
  22. package/dist/workflows/dag/backend-test-scenario-param.js +846 -0
  23. package/dist/workflows/dag/backend-test-writer-completeness.js +418 -0
  24. package/dist/workflows/dag/frontend-test-case-checklist.js +94 -15
  25. package/dist/workflows/dag/frontend-test-case-manifest.js +104 -0
  26. package/dist/workflows/dag/frontend-test-html-report.js +106 -24
  27. package/dist/workflows/dag/frontend-test-result-contract.js +3 -0
  28. package/dist/workflows/dag/init-hybrid.js +187 -108
  29. package/dist/workflows/dag/node-execution.js +31 -2
  30. package/dist/workflows/dag/retry-policy.js +55 -18
  31. package/dist/workflows/dag/types.js +41 -0
  32. package/dist/workflows/dag/validate.js +42 -4
  33. package/docs/operations/README.md +1 -1
  34. package/docs/templates/README.md +2 -1
  35. package/docs/templates/agent-dag.schema.json +9 -4
  36. package/docs/templates/backend-test-dag.json +36 -11
  37. package/docs/templates/frontend-test-case-checklist.md +1 -1
  38. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +9 -1
  39. package/docs/templates/frontend-test-dag.json +125 -267
  40. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +7 -1
  41. package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
  42. package/docs/templates/frontend-test-standard-scenarios.v1.json +114 -0
  43. package/docs/templates/init-managed-agents.md +1 -1
  44. package/harness.json +2 -2
  45. package/package.json +2 -1
  46. package/skills/playwright-cli/SKILL.md +1 -1
  47. package/skills/playwright-cli-case-generator/SKILL.md +1 -1
  48. package/dist/worker/console/static/assets/index-BfRgtLF4.js +0 -29
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { chmodSync, existsSync, lstatSync, mkdirSync, renameSync, writeFileSync, } from "node:fs";
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
3
  import { readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
@@ -100,6 +100,98 @@ export function assertSafeAppDataPath(baseDir, candidate) {
100
100
  }
101
101
  return resolved;
102
102
  }
103
+ const WINDOWS_REPLACE_RETRY_DELAYS_MS = [10, 25, 50, 100, 200, 400];
104
+ const WINDOWS_REPLACE_RETRY_CODES = new Set([
105
+ "EACCES",
106
+ "EBUSY",
107
+ "EEXIST",
108
+ "EPERM",
109
+ ]);
110
+ /**
111
+ * Atomically replace `resolved` with `tmp`.
112
+ * On Windows, destination locks (AV / indexer / concurrent writers) often yield
113
+ * EPERM/EEXIST on rename — retry, then fall back to unlink+rename.
114
+ */
115
+ export async function replaceFileWithRetry(tmp, resolved, deps = {}) {
116
+ const doRename = deps.rename ?? rename;
117
+ const doRm = deps.rm ?? rm;
118
+ const platform = deps.platform ?? process.platform;
119
+ const sleep = deps.sleep ??
120
+ ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
121
+ let lastError;
122
+ for (let attempt = 0;; attempt += 1) {
123
+ try {
124
+ await doRename(tmp, resolved);
125
+ return;
126
+ }
127
+ catch (error) {
128
+ lastError = error;
129
+ const code = error.code ?? "";
130
+ const delay = WINDOWS_REPLACE_RETRY_DELAYS_MS[attempt];
131
+ if (platform !== "win32" ||
132
+ !WINDOWS_REPLACE_RETRY_CODES.has(code) ||
133
+ delay === undefined) {
134
+ break;
135
+ }
136
+ await sleep(delay);
137
+ }
138
+ }
139
+ if (platform === "win32") {
140
+ const code = lastError?.code ?? "";
141
+ if (WINDOWS_REPLACE_RETRY_CODES.has(code)) {
142
+ try {
143
+ await doRm(resolved, { force: true });
144
+ await doRename(tmp, resolved);
145
+ return;
146
+ }
147
+ catch (fallbackError) {
148
+ throw lastError ?? fallbackError;
149
+ }
150
+ }
151
+ }
152
+ throw lastError instanceof Error
153
+ ? lastError
154
+ : new Error(String(lastError ?? "replaceFileWithRetry failed"));
155
+ }
156
+ function replaceFileWithRetrySync(tmp, resolved) {
157
+ let lastError;
158
+ for (let attempt = 0;; attempt += 1) {
159
+ try {
160
+ renameSync(tmp, resolved);
161
+ return;
162
+ }
163
+ catch (error) {
164
+ lastError = error;
165
+ const code = error.code ?? "";
166
+ const delay = WINDOWS_REPLACE_RETRY_DELAYS_MS[attempt];
167
+ if (process.platform !== "win32" ||
168
+ !WINDOWS_REPLACE_RETRY_CODES.has(code) ||
169
+ delay === undefined) {
170
+ break;
171
+ }
172
+ const end = Date.now() + delay;
173
+ while (Date.now() < end) {
174
+ /* busy-wait: sync path used only for small boot/SSE writes */
175
+ }
176
+ }
177
+ }
178
+ if (process.platform === "win32") {
179
+ const code = lastError?.code ?? "";
180
+ if (WINDOWS_REPLACE_RETRY_CODES.has(code)) {
181
+ try {
182
+ rmSync(resolved, { force: true });
183
+ renameSync(tmp, resolved);
184
+ return;
185
+ }
186
+ catch (fallbackError) {
187
+ throw lastError ?? fallbackError;
188
+ }
189
+ }
190
+ }
191
+ throw lastError instanceof Error
192
+ ? lastError
193
+ : new Error(String(lastError ?? "replaceFileWithRetrySync failed"));
194
+ }
103
195
  export async function writeSecureJson(filePath, value) {
104
196
  const dir = path.dirname(filePath);
105
197
  ensureSecureDir(dir);
@@ -108,12 +200,18 @@ export async function writeSecureJson(filePath, value) {
108
200
  const body = `${JSON.stringify(value, null, 2)}\n`;
109
201
  await writeFile(tmp, body, { encoding: "utf8", mode: 0o600 });
110
202
  try {
111
- chmodSync(tmp, 0o600);
203
+ try {
204
+ chmodSync(tmp, 0o600);
205
+ }
206
+ catch {
207
+ // best-effort
208
+ }
209
+ await replaceFileWithRetry(tmp, resolved);
112
210
  }
113
- catch {
114
- // best-effort
211
+ catch (error) {
212
+ await rm(tmp, { force: true }).catch(() => undefined);
213
+ throw error;
115
214
  }
116
- await rename(tmp, resolved);
117
215
  try {
118
216
  chmodSync(resolved, 0o600);
119
217
  }
@@ -182,10 +280,33 @@ export function writeSecureJsonSync(filePath, value) {
182
280
  const dir = path.dirname(filePath);
183
281
  ensureSecureDir(dir);
184
282
  const resolved = assertSafeAppDataPath(dir, filePath);
185
- const tmp = `${resolved}.${process.pid}.tmp`;
186
- writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, {
187
- encoding: "utf8",
188
- mode: 0o600,
189
- });
190
- renameSync(tmp, resolved);
283
+ const tmp = `${resolved}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
284
+ try {
285
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, {
286
+ encoding: "utf8",
287
+ mode: 0o600,
288
+ });
289
+ try {
290
+ chmodSync(tmp, 0o600);
291
+ }
292
+ catch {
293
+ // best-effort
294
+ }
295
+ replaceFileWithRetrySync(tmp, resolved);
296
+ }
297
+ catch (error) {
298
+ try {
299
+ rmSync(tmp, { force: true });
300
+ }
301
+ catch {
302
+ // best-effort cleanup
303
+ }
304
+ throw error;
305
+ }
306
+ try {
307
+ chmodSync(resolved, 0o600);
308
+ }
309
+ catch {
310
+ // best-effort
311
+ }
191
312
  }
@@ -13,11 +13,10 @@
13
13
  * (noContextFiles / closed surface); credential/model plane is shared via
14
14
  * the same agentDir auth.json/models.json.
15
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).
16
+ * AND re-pinned before each prompt (three-gate, design §7.5 / ADR 0011):
17
+ * the FULL operator action set PLUS full Pi builtins
18
+ * (read/write/edit/bash/grep/find/ls) and optional safe-*. Non-Pi write
19
+ * channels (apply_patch/full-tools/shell/coding-chat) stay excluded.
21
20
  *
22
21
  * The actual SDK calls are injected via `PiSdkBindings` so this module is
23
22
  * unit-testable without a live Pi install. Production bindings come from
@@ -49,35 +48,34 @@ import { filterActiveInterviewTools } from "../interview/tools.js";
49
48
  */
50
49
  const OPERATOR_CHAT_SYSTEM_PROMPT_BASE = [
51
50
  "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.",
51
+ "You are an OPERATOR first: orchestrate and inspect via operator_* tools, and you also have full Pi repository tools (read, write, edit, bash, grep, find, ls) plus optional safe-read/safe-grep/git-status/git-diff.",
52
+ "Capability honesty (ADR 0011): read/write/edit/bash ARE available. Prefer governed loop-agent / Agent DAG paths (implement-pi / repair-pi) and Human Gate for large refactors, public contracts, credentials, or production-risk changes treat direct write/edit/bash as soft-disciplined, not as a second DAG kernel.",
53
+ "safe-read / safe-grep enforce a repo-relative path boundary, a sensitive-file denylist (.env* / *.key / *.pem / .git/** / auth.json / sessions/**) and secret scrubbing. Prefer them for sensitive probes; avoid dumping secrets via raw read/bash.",
54
+ "You do NOT have apply_patch / full-tools / shell / coding-chat as alternate coding runtimes those non-Pi channels stay denied.",
55
+ "High-risk mutations still use prepare + browser Human Gate. 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, diagnose with status/doctor/dagReport/inspect/contractShow/read/safe-read/safe-grep.",
57
56
  "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.",
57
+ "Prefer read-only diagnosis (status, doctor, dagReport, inspect, contractShow, read, safe-read, safe-grep, git-status) before mutating.",
59
58
  ].join("\n");
60
59
  /**
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.
60
+ * Built-in SDK tool names registered for every Chat session (ADR 0011).
61
+ * Full Pi builtins: read/write/edit/bash/grep/find/ls. safe-* custom tools
62
+ * remain available in parallel for bounded sensitive probes.
69
63
  */
70
64
  export const OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS = Object.freeze([
65
+ "read",
66
+ "write",
67
+ "edit",
68
+ "bash",
69
+ "grep",
71
70
  "find",
72
71
  "ls",
73
72
  ]);
74
73
  /**
75
74
  * 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.
75
+ * every model-callable operator action (dynamic, from the registry) PLUS full
76
+ * Pi builtins (ADR 0011) PLUS safe explore custom tools (safe-read /
77
+ * safe-grep / git-status / git-diff). Non-Pi write channels (apply_patch /
78
+ * full-tools / shell / coding-chat) stay excluded.
81
79
  */
82
80
  export const OPERATOR_CHAT_SAFE_EXPLORE_TOOL_IDS = Object.freeze([
83
81
  "safe-read",
@@ -549,13 +547,10 @@ export class ConsolePiRuntime {
549
547
  const { session } = await this.bindings.createSessionFromServices({
550
548
  services,
551
549
  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).
550
+ // Gate 1 (ADR 0011): full Pi builtins registered; only non-Pi write
551
+ // channels excluded (apply_patch / full-tools / shell / coding-chat).
555
552
  tools: [...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS],
556
553
  excludeTools: [
557
- "edit",
558
- "write",
559
554
  "apply_patch",
560
555
  "apply-patch",
561
556
  "full-tools",
@@ -563,14 +558,6 @@ export class ConsolePiRuntime {
563
558
  "coding-chat",
564
559
  "coding_chat",
565
560
  "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
561
  ],
575
562
  ...(customTools && customTools.length > 0
576
563
  ? { customTools }
@@ -836,8 +823,6 @@ export class ConsolePiRuntime {
836
823
  sessionManager: init.sessionManager,
837
824
  tools: [...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS],
838
825
  excludeTools: [
839
- "edit",
840
- "write",
841
826
  "apply_patch",
842
827
  "apply-patch",
843
828
  "full-tools",
@@ -845,9 +830,6 @@ export class ConsolePiRuntime {
845
830
  "coding-chat",
846
831
  "coding_chat",
847
832
  "shell",
848
- "bash",
849
- "read",
850
- "grep",
851
833
  ],
852
834
  ...(customTools && customTools.length > 0 ? { customTools } : {}),
853
835
  ...(resolvedModel ? { model: resolvedModel } : {}),
@@ -1,14 +1,11 @@
1
1
  /**
2
- * Operator Chat — closed ResourceLoader contract (design §7.5 / V12 / plan W3).
2
+ * Operator Chat — ResourceLoader contract (ADR 0011 full Pi tools).
3
3
  *
4
- * M0-A (roadmap §14 工作块 A): the bare bash/read/grep built-ins are GONE.
5
- * The Chat session now exposes safe explore custom tools (safe-read /
6
- * safe-grep / git-status / git-diff) which enforce a repo-relative boundary,
7
- * a sensitive-file denylist, and secret scrubbing plus find/ls. The full
8
- * model-callable operator action set is still allowed; the only denied tools
9
- * are file-WRITING coding tools (edit/write/apply_patch/full-tools/shell/
10
- * coding-chat) AND bash/read/grep (replaced by the safe versions). hasBash is
11
- * therefore false: there is no shell escape.
4
+ * Declares the operator-chat surface for capabilities reporting and red-team
5
+ * checks: full Pi builtins (read/write/edit/bash/grep/find/ls), model-callable
6
+ * operator actions, and optional safe-* explore tools. hasBash is true.
7
+ * Non-Pi write channels (apply_patch / full-tools / shell / coding-chat) remain
8
+ * denied. Interview/Official loaders are unchanged.
12
9
  *
13
10
  * Note: this adapter is NOT the SDK's own ResourceLoader (that is
14
11
  * DefaultResourceLoader, created inside createAgentSessionServices with
@@ -17,13 +14,12 @@
17
14
  * The three-gate authorization (authorizeOperatorChatTool) is the actual
18
15
  * enforcement; this adapter describes what that gate admits.
19
16
  */
20
- import { OPERATOR_CHAT_ALLOWED_TOOLS, OPERATOR_CHAT_DENIED_TOOLS, OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS, authorizeOperatorChatTool, filterOperatorChatTools, assertNoWriteToolInList, } from "./tools.js";
21
- /** Built-in explore tool names that are ALLOWED in every Chat session (M0-A). */
17
+ import { OPERATOR_CHAT_ALLOWED_TOOLS, OPERATOR_CHAT_DENIED_TOOLS, OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS, OPERATOR_CHAT_PI_BUILTIN_TOOLS, authorizeOperatorChatTool, filterOperatorChatTools, assertNoWriteToolInList, } from "./tools.js";
18
+ /** Built-in Pi tool names allowed in every Chat session (ADR 0011). */
22
19
  export const OPERATOR_CHAT_BUILTIN_EXPLORE_TOOL_IDS = Object.freeze([
23
- "find",
24
- "ls",
20
+ ...OPERATOR_CHAT_PI_BUILTIN_TOOLS,
25
21
  ]);
26
- /** Safe explore custom tool names (M0-A). */
22
+ /** Safe explore custom tool names (optional parallel surface). */
27
23
  export const OPERATOR_CHAT_SAFE_EXPLORE_TOOL_IDS = Object.freeze([
28
24
  "safe-read",
29
25
  "safe-grep",
@@ -41,11 +37,6 @@ export function createOperatorChatResourceLoader() {
41
37
  safeExploreToolIds: OPERATOR_CHAT_SAFE_EXPLORE_TOOL_IDS,
42
38
  listTools: () => OPERATOR_CHAT_ALLOWED_TOOLS,
43
39
  tryActivateTool: (toolId) => {
44
- // Explore tools (bash/read/grep/find/ls) are run by the SDK's built-in
45
- // tool registry, not by the operator dispatcher. tryActivateTool only
46
- // classifies operator actions here, but it must NOT deny an explore
47
- // tool — that would contradict the real session. Deny only write tools
48
- // and report unknowns.
49
40
  const explore = OPERATOR_CHAT_BUILTIN_EXPLORE_TOOL_IDS.includes(toolId);
50
41
  if (explore) {
51
42
  return { ok: true, toolId };
@@ -62,6 +53,6 @@ export function createOperatorChatResourceLoader() {
62
53
  return { active: [...active], denied };
63
54
  },
64
55
  allowsEnvToolRestore: false,
65
- hasBash: false,
56
+ hasBash: true,
66
57
  };
67
58
  }
@@ -176,9 +176,8 @@ export async function handleChatCapabilities(_req, res, deps) {
176
176
  skipped: skills.skipped,
177
177
  promptFragmentCharCount: composeInstructionSkillsPrompt(skills.loaded).length,
178
178
  },
179
- // M0-A: bare bash is removed; safe-read/safe-grep enforce the repo and
180
- // secret boundaries instead.
181
- hasBash: false,
179
+ // ADR 0011: full Pi tools including bash are default-active.
180
+ hasBash: true,
182
181
  });
183
182
  }
184
183
  export async function handleCreateChatSession(req, res, deps) {
@@ -236,7 +235,7 @@ export async function handleCreateChatSession(req, res, deps) {
236
235
  sessionFile: handle.sessionFile,
237
236
  model: handle.model,
238
237
  activeTools: handle.activeTools,
239
- hasBash: false,
238
+ hasBash: true,
240
239
  });
241
240
  }
242
241
  catch (error) {
@@ -342,7 +341,7 @@ export async function handleGetChatSession(_req, res, deps, sessionId) {
342
341
  composerDraft,
343
342
  activeTurnId: deps.events.getActiveTurn(sessionId)?.turnId,
344
343
  },
345
- hasBash: false,
344
+ hasBash: true,
346
345
  lastEventId: lastEvent?.eventId ?? null,
347
346
  lastEventSeq: lastEvent?.seq ?? 0,
348
347
  });
@@ -356,7 +355,7 @@ export async function handleListChatSessions(_req, res, deps) {
356
355
  ...session,
357
356
  activeTurnId: deps.events.getActiveTurn(session.sessionId)?.turnId,
358
357
  })),
359
- hasBash: false,
358
+ hasBash: true,
360
359
  });
361
360
  }
362
361
  export async function handlePatchChatSession(req, res, deps, sessionId) {
@@ -395,7 +394,7 @@ export async function handleReopenChatSession(req, res, deps, sessionId) {
395
394
  return;
396
395
  }
397
396
  if (deps.runtime.hasSession(sessionId)) {
398
- sendJson(res, 200, { ok: true, sessionId, reopened: false, alreadyActive: true, hasBash: false });
397
+ sendJson(res, 200, { ok: true, sessionId, reopened: false, alreadyActive: true, hasBash: true });
399
398
  return;
400
399
  }
401
400
  const result = await deps.runtime.reopenSession({
@@ -416,7 +415,7 @@ export async function handleReopenChatSession(req, res, deps, sessionId) {
416
415
  sessionFile: result.handle.sessionFile,
417
416
  model: result.handle.model,
418
417
  activeTools: result.handle.activeTools,
419
- hasBash: false,
418
+ hasBash: true,
420
419
  });
421
420
  }
422
421
  export async function handleChatPrompt(req, res, deps, sessionId) {
@@ -16,7 +16,7 @@ export function projectRuntimeContext(input) {
16
16
  summary: summary(input.systemPrompt),
17
17
  },
18
18
  skills: input.skills.map((skill) => ({ ...skill, description: summary(skill.description, 200) ?? "" })),
19
- resources: { mode: "closed", noContextFiles: true, noSkills: true, noExtensions: true, hasBash: false, activeToolCount: input.activeTools.length },
19
+ resources: { mode: "closed", noContextFiles: true, noSkills: true, noExtensions: true, hasBash: true, activeToolCount: input.activeTools.length },
20
20
  model: input.model,
21
21
  thinkingLevel: input.thinkingLevel,
22
22
  suffix: input.systemPromptSuffix ? { present: true, summary: summary(input.systemPromptSuffix) } : { present: false },
@@ -1,15 +1,15 @@
1
1
  /**
2
- * Operator Chat — closed tool whitelist (design §7.5 / roadmap M0).
2
+ * Operator Chat — tool policy (ADR 0011 UX-first full Pi tools).
3
3
  *
4
- * The Chat exposes the model-callable structured operator surface. Repository
5
- * exploration is deliberately limited to safe-read/safe-grep/git-status/
6
- * git-diff plus built-in find/ls; the bare SDK bash/read/grep tools are denied
7
- * because they bypass the M0-A write and sensitive-data boundaries.
4
+ * Operator Chat registers and activates full Pi builtins
5
+ * (read/write/edit/bash/grep/find/ls) plus model-callable operator actions and
6
+ * optional safe-* explore custom tools. Discipline is primarily soft
7
+ * (system prompt: prefer DAG / Human Gate). Hard denies remain for non-Pi
8
+ * write channels (apply_patch / full-tools / shell / coding-chat) and for
9
+ * modelCallable="never" actions (e.g. confirmDagConfirmation).
8
10
  *
9
- * Dynamic discovery derives model-callable operator actions directly from the
10
- * capabilities registry. Explicit denial remains the defense-in-depth layer:
11
- * a future resource-loader or SDK drift cannot reactivate removed coding or
12
- * explore tools merely by changing the active-tool list.
11
+ * Interview / Official surfaces keep their own OFFICIAL_DENIED path; this
12
+ * module only filters Chat-side denial so Interview is not widened.
13
13
  */
14
14
  import { buildOperatorCapabilitiesDocument } from "../../../shared/operator/capabilities.js";
15
15
  import { OFFICIAL_DENIED_TOOL_IDS } from "../resource-loader.js";
@@ -41,31 +41,37 @@ export const OPERATOR_CHAT_MODEL_FORBIDDEN_ACTIONS = Object.freeze(buildOperator
41
41
  * stay defensive against future additions.
42
42
  */
43
43
  export const OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS = Object.freeze([]);
44
- // Coding / generic tool ids that must never appear in a Chat session. M0-A
45
- // removes bare bash (write-via-redirect) and SDK read/grep (no sensitive-file
46
- // boundary); find/ls remain the only SDK explore builtins. Safe content probes
47
- // are custom tools, not generic SDK tools.
48
- const _ALLOWED_SDK_EXPLORE_TOOLS = new Set(["find", "ls"]);
49
- const _REMOVED_SDK_EXPLORE_TOOLS = ["bash", "read", "grep"];
44
+ /** Full Pi builtin explore / coding tools admitted by ADR 0011. */
45
+ export const OPERATOR_CHAT_PI_BUILTIN_TOOLS = Object.freeze([
46
+ "read",
47
+ "write",
48
+ "edit",
49
+ "bash",
50
+ "grep",
51
+ "find",
52
+ "ls",
53
+ ]);
54
+ const _PI_BUILTIN_ALLOW = new Set(OPERATOR_CHAT_PI_BUILTIN_TOOLS.map((t) => t.toLowerCase()));
50
55
  // Operator actions that OFFICIAL_DENIED_TOOL_IDS carries for Interview safety
51
- // but that the Chat now deliberately allows. We compare on a COMPACTED form
52
- // (strip - and _) because OFFICIAL lists both camelCase (`contractApply`) and
53
- // snake_case (`contract_apply`) variants, and authorize() also normalizes —
54
- // leaving any variant in would re-deny the action via normalization.
56
+ // but that the Chat now deliberately allows. Compare on a COMPACTED form
57
+ // (strip - and _) because OFFICIAL lists both camelCase and snake_case.
55
58
  const _ALLOWED_OPERATOR_ACTIONS_COMPACT = new Set(OPERATOR_CHAT_ALLOWED_TOOLS.map((t) => t.toLowerCase().replace(/[-_]/g, "")));
59
+ /**
60
+ * Tools denied for Operator Chat. Built from OFFICIAL_DENIED minus:
61
+ * - Pi builtins allowed by ADR 0011
62
+ * - model-callable operator actions
63
+ * Does NOT re-add bash/read/grep/edit/write.
64
+ */
56
65
  export const OPERATOR_CHAT_DENIED_TOOLS = Object.freeze([
57
- ...new Set([
58
- ...OFFICIAL_DENIED_TOOL_IDS.filter((id) => {
59
- const lower = id.toLowerCase();
60
- if (_ALLOWED_SDK_EXPLORE_TOOLS.has(lower))
61
- return false;
62
- if (_ALLOWED_OPERATOR_ACTIONS_COMPACT.has(lower.replace(/[-_]/g, ""))) {
63
- return false;
64
- }
65
- return true;
66
- }),
67
- ..._REMOVED_SDK_EXPLORE_TOOLS,
68
- ]),
66
+ ...new Set(OFFICIAL_DENIED_TOOL_IDS.filter((id) => {
67
+ const lower = id.toLowerCase();
68
+ if (_PI_BUILTIN_ALLOW.has(lower))
69
+ return false;
70
+ if (_ALLOWED_OPERATOR_ACTIONS_COMPACT.has(lower.replace(/[-_]/g, ""))) {
71
+ return false;
72
+ }
73
+ return true;
74
+ })),
69
75
  ]);
70
76
  /**
71
77
  * Operator actions denied by model policy (modelCallable="never"). These are
@@ -78,11 +84,19 @@ const DENIED_MODEL_POLICY = new Set(OPERATOR_CHAT_MODEL_FORBIDDEN_ACTIONS.map((a
78
84
  const ALLOWED = new Set(OPERATOR_CHAT_ALLOWED_TOOLS.map((t) => t.toLowerCase()));
79
85
  const DENIED_ACTIONS = new Set(OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS.map((a) => a.toLowerCase()));
80
86
  const DENIED_TOOLS = new Set(OPERATOR_CHAT_DENIED_TOOLS.map((t) => t.toLowerCase()));
87
+ /** Pi builtins are authorized without being operator registry actions. */
88
+ const PI_BUILTIN_AUTHORIZED = new Set(OPERATOR_CHAT_PI_BUILTIN_TOOLS.map((t) => t.toLowerCase()));
81
89
  export function isOperatorChatToolAllowed(toolId) {
82
- return ALLOWED.has(toolId.trim().toLowerCase());
90
+ const id = toolId.trim().toLowerCase();
91
+ if (PI_BUILTIN_AUTHORIZED.has(id))
92
+ return true;
93
+ return ALLOWED.has(id);
83
94
  }
84
95
  export function isOperatorChatToolDenied(toolId) {
85
96
  const id = toolId.trim().toLowerCase();
97
+ // Pi builtins are never denied under ADR 0011.
98
+ if (PI_BUILTIN_AUTHORIZED.has(id))
99
+ return false;
86
100
  if (DENIED_TOOLS.has(id))
87
101
  return true;
88
102
  if (DENIED_ACTIONS.has(id))
@@ -125,7 +139,7 @@ export function authorizeOperatorChatTool(toolId) {
125
139
  ok: false,
126
140
  code: "tool-denied",
127
141
  toolId: id,
128
- message: `tool "${id}" is denied in General Operator Chat (no bash/read/grep/edit/write/apply_patch/full-tools/shell/coding-chat)`,
142
+ message: `tool "${id}" is denied in General Operator Chat (no apply_patch/full-tools/shell/coding-chat; modelCallable=never still blocked)`,
129
143
  };
130
144
  }
131
145
  if (!isOperatorChatToolAllowed(id)) {
@@ -155,35 +169,34 @@ export function filterOperatorChatTools(requested) {
155
169
  return { allowed, denied };
156
170
  }
157
171
  /**
158
- * Assert that a requested tool set contains no prohibited coding or removed
159
- * SDK explore tool. Used by red-team / boot checks (V12 / M0-A). bash is
160
- * explicitly forbidden: accepting it would recreate the redirect write escape.
172
+ * Non-Pi write / coding channels that must never appear in Operator Chat.
173
+ * ADR 0011 allows Pi builtins read/write/edit/bash/grep/find/ls; this guard
174
+ * only blocks alternate coding surfaces (apply_patch, full-tools, shell,
175
+ * coding-chat).
176
+ */
177
+ const NON_PI_WRITE_CHANNELS = [
178
+ "apply_patch",
179
+ "apply-patch",
180
+ "full-tools",
181
+ "full_tools",
182
+ "coding-chat",
183
+ "coding_chat",
184
+ "shell",
185
+ ];
186
+ /**
187
+ * Assert that a requested tool set contains no prohibited non-Pi write
188
+ * channel. Pi builtins (including write/edit/bash) are allowed (ADR 0011).
161
189
  */
162
190
  export function assertNoWriteToolInList(requested) {
163
- const writeVariants = [
164
- "edit",
165
- "write",
166
- "apply_patch",
167
- "apply-patch",
168
- "full-tools",
169
- "full_tools",
170
- "coding-chat",
171
- "coding_chat",
172
- "shell",
173
- "bash",
174
- "read",
175
- "grep",
176
- ];
177
191
  const lower = new Set(requested.map((t) => t.trim().toLowerCase()));
178
- for (const v of writeVariants) {
192
+ for (const v of NON_PI_WRITE_CHANNELS) {
179
193
  if (lower.has(v)) {
180
- throw new Error(`file-writing tool "${v}" must not appear in Operator Chat tool list (V12 closed coding surface)`);
194
+ throw new Error(`non-Pi write channel "${v}" must not appear in Operator Chat tool list (ADR 0011)`);
181
195
  }
182
196
  }
183
197
  }
184
198
  /**
185
- * Back-compat alias kept for callers that still reference the old name. It
186
- * also rejects removed SDK explore tools, including bash.
199
+ * Back-compat alias kept for callers that still reference the old name.
187
200
  * @deprecated use {@link assertNoWriteToolInList}.
188
201
  */
189
202
  export function assertNoBashInToolList(requested) {
@@ -34,6 +34,7 @@ export async function runOperation(operationId, deps) {
34
34
  ? deps.client.runExternal(op.externalCommand, cliArgs, options)
35
35
  : deps.client.run(cliArgs, options));
36
36
  let finished;
37
+ let spawnUpdate = Promise.resolve();
37
38
  try {
38
39
  const result = await run(args, {
39
40
  cwd: deps.repoRoot,
@@ -41,10 +42,21 @@ export async function runOperation(operationId, deps) {
41
42
  expectJson: true,
42
43
  timeoutMs: deps.defaultTimeoutMs,
43
44
  onSpawn: (info) => {
44
- void deps.store.update(operationId, {
45
+ // Chain onto the per-operation write lock; never fire-and-forget a
46
+ // rejected update (unhandled rejection can crash Console on Windows).
47
+ spawnUpdate = deps.store
48
+ .update(operationId, {
45
49
  state: "running",
46
50
  pid: info.pid,
47
51
  processStartIdentity: info.startedAt,
52
+ })
53
+ .catch((error) => {
54
+ const message = error instanceof Error ? error.message : String(error);
55
+ deps.events.append(operationId, {
56
+ at: new Date().toISOString(),
57
+ kind: "error",
58
+ message: `failed to persist running state: ${message}`,
59
+ });
48
60
  });
49
61
  stateEvent(deps.events, op, "running", `pid=${info.pid ?? "?"}`);
50
62
  },
@@ -63,9 +75,11 @@ export async function runOperation(operationId, deps) {
63
75
  });
64
76
  },
65
77
  });
78
+ await spawnUpdate;
66
79
  finished = await finalizeFromWorkerResult(operationId, result, deps);
67
80
  }
68
81
  catch (error) {
82
+ await spawnUpdate.catch(() => undefined);
69
83
  // Cannot prove CLI exit classification → needs-reconcile (not Cancel)
70
84
  finished = await deps.store.update(operationId, {
71
85
  state: "needs-reconcile",