@tea-agent/loop-agent 0.28.13 → 0.29.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 (33) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +44 -15
  3. package/dist/commands/client-recovery.js +56 -1
  4. package/dist/commands/init-upgrade.js +186 -21
  5. package/dist/executors/dag-pi-executor.js +49 -2
  6. package/dist/executors/shell-executor.js +135 -0
  7. package/dist/worker/console/app-data.js +132 -11
  8. package/dist/worker/console/chat/pi-runtime.js +24 -42
  9. package/dist/worker/console/chat/resource-loader.js +11 -20
  10. package/dist/worker/console/chat/routes.js +7 -8
  11. package/dist/worker/console/chat/runtime-context.js +1 -1
  12. package/dist/worker/console/chat/tools.js +67 -54
  13. package/dist/worker/console/operation-runner.js +15 -1
  14. package/dist/worker/console/operation-store.js +70 -49
  15. package/dist/worker/console/operator-actions.js +57 -1
  16. package/dist/worker/console/static/assets/index-Cwx-ZVEQ.js +29 -0
  17. package/dist/worker/console/static/favicon.svg +37 -0
  18. package/dist/worker/console/static/index.html +2 -1
  19. package/dist/workflows/dag/backend-test-scenario-param.js +846 -0
  20. package/dist/workflows/dag/backend-test-writer-completeness.js +418 -0
  21. package/dist/workflows/dag/init-hybrid.js +20 -6
  22. package/dist/workflows/dag/node-execution.js +31 -2
  23. package/dist/workflows/dag/retry-policy.js +55 -18
  24. package/dist/workflows/dag/types.js +1 -0
  25. package/dist/workflows/dag/validate.js +38 -3
  26. package/docs/operations/README.md +1 -1
  27. package/docs/templates/README.md +1 -1
  28. package/docs/templates/agent-dag.schema.json +3 -3
  29. package/docs/templates/backend-test-dag.json +36 -11
  30. package/docs/templates/init-managed-agents.md +1 -1
  31. package/harness.json +2 -2
  32. package/package.json +2 -1
  33. package/dist/worker/console/static/assets/index-BfRgtLF4.js +0 -29
@@ -30,6 +30,7 @@ import { materializeBackendTestExecutionContract } from "../workflows/dag/backen
30
30
  import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
31
31
  import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport, } from "../workflows/dag/backend-test-result-contract.js";
32
32
  import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, resolveBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
33
+ import { applyDeterministicScenarioParamRepairs, assessBackendScenarioParamConsistency, classifyBackendTestFailureWithScenarioParam, readBackendScenarioParamFacts, renderBackendTestFailureAnalysis, writeBackendScenarioParamArtifacts, writeScenarioParamRepairAudit, } from "../workflows/dag/backend-test-scenario-param.js";
33
34
  import { assessBackendPytestCollection, assessMissingBackendPytestScripts, assertBackendPytestCollectionFresh, buildBackendPytestAssetInventory, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
34
35
  import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
35
36
  import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
@@ -768,6 +769,77 @@ async function executeBackendTestPipeline(input, meta) {
768
769
  durationMs: Date.now() - started,
769
770
  };
770
771
  }
772
+ // Scenario-param consistency (P1): assess -> deterministic repair <=1 -> reassess final.
773
+ try {
774
+ const initialAssessment = await assessBackendScenarioParamConsistency({
775
+ workspaceRoot: input.cwd,
776
+ phase: "initial",
777
+ repairAttempt: 0,
778
+ strictScenarioParamGate: Boolean((meta.spec.globalConstraints ?? []).some((item) => /strictScenarioParamGate\s*=\s*true/i.test(item))),
779
+ });
780
+ const initialArtifacts = await writeBackendScenarioParamArtifacts({
781
+ runDir: meta.runDir,
782
+ facts: initialAssessment.facts,
783
+ markdown: initialAssessment.markdown,
784
+ });
785
+ outputs.push(`scenarioParamInitial=${initialArtifacts.reportPath}`, `scenarioParamInitialFacts=${initialArtifacts.factsPath}`);
786
+ let finalFacts = initialAssessment.facts;
787
+ let finalMarkdown = initialAssessment.markdown;
788
+ if (initialAssessment.facts.repairEligible) {
789
+ const repair = await applyDeterministicScenarioParamRepairs({
790
+ workspaceRoot: input.cwd,
791
+ facts: initialAssessment.facts,
792
+ });
793
+ const auditPath = await writeScenarioParamRepairAudit({
794
+ runDir: meta.runDir,
795
+ audit: repair.audit,
796
+ changedFiles: repair.changedFiles,
797
+ });
798
+ outputs.push(`scenarioParamRepairAudit=${auditPath}`, `scenarioParamRepaired=${repair.repaired.join(",") || "(none)"}`);
799
+ const reassessment = await assessBackendScenarioParamConsistency({
800
+ workspaceRoot: input.cwd,
801
+ phase: "final",
802
+ repairAttempt: 1,
803
+ strictScenarioParamGate: initialAssessment.facts.strictScenarioParamGate,
804
+ });
805
+ finalFacts = reassessment.facts;
806
+ finalMarkdown = reassessment.markdown;
807
+ }
808
+ else {
809
+ finalFacts = {
810
+ ...initialAssessment.facts,
811
+ phase: "final",
812
+ repairAttempt: 0,
813
+ repairEligible: false,
814
+ };
815
+ finalMarkdown = initialAssessment.markdown.replace("Phase: initial", "Phase: final");
816
+ }
817
+ const finalArtifacts = await writeBackendScenarioParamArtifacts({
818
+ runDir: meta.runDir,
819
+ facts: finalFacts,
820
+ markdown: finalMarkdown,
821
+ });
822
+ outputs.push(`scenarioParamFinal=${finalArtifacts.reportPath}`, `scenarioParamFinalFacts=${finalArtifacts.factsPath}`, `scenarioParamMismatch=${finalFacts.summary.mismatchCount}`, finalMarkdown);
823
+ if (finalFacts.strictScenarioParamGate &&
824
+ finalFacts.summary.mismatchCount > 0) {
825
+ return {
826
+ ok: false,
827
+ stdout: outputs.join("\n\n"),
828
+ stderr: "backend-test strict scenario-param gate blocked residual MISMATCH before execute",
829
+ failureCategory: "invalid-output",
830
+ durationMs: Date.now() - started,
831
+ };
832
+ }
833
+ }
834
+ catch (error) {
835
+ const message = error instanceof Error ? error.message : String(error);
836
+ const report = "# Backend Test Scenario-Param Consistency\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Scenario-param analysis crashed: " +
837
+ message +
838
+ "\n";
839
+ const reportPath = await writeRunReport(meta.runDir, "backend-test-scenario-param-consistency.md", report);
840
+ outputs.push(`scenarioParam=${reportPath}`, report);
841
+ // Advisory by default: do not fail the node on scenario-param infrastructure errors.
842
+ }
771
843
  }
772
844
  else if (pipeline === "markdown-manifest") {
773
845
  const sourceBinding = meta.spec.sourceBinding;
@@ -989,6 +1061,69 @@ async function executeBackendTestPipeline(input, meta) {
989
1061
  });
990
1062
  const markdownPath = await writeRunReport(meta.runDir, "backend-test.md", facts);
991
1063
  const factsPath = await writeRunReport(meta.runDir, "backend-test-facts.md", facts);
1064
+ // Structured failure analysis report (P2); emitted even when there are zero failures.
1065
+ let scenarioParamFinalStatus = "UNAVAILABLE";
1066
+ let scenarioParamRepairAttempt = 0;
1067
+ const scenarioParamStatusByToken = new Map();
1068
+ try {
1069
+ const scenarioFacts = await readBackendScenarioParamFacts(path.join(meta.runDir, "contracts", "backend-test-scenario-param-consistency-facts.json"));
1070
+ scenarioParamFinalStatus =
1071
+ scenarioFacts.summary.mismatchCount > 0 ? "FAIL" : "PASS";
1072
+ scenarioParamRepairAttempt = scenarioFacts.repairAttempt;
1073
+ for (const entry of scenarioFacts.entries) {
1074
+ scenarioParamStatusByToken.set(entry.caseId, entry.status);
1075
+ scenarioParamStatusByToken.set(entry.tpId, entry.status);
1076
+ }
1077
+ }
1078
+ catch {
1079
+ // optional facts
1080
+ }
1081
+ const failureCases = parsed.cases.filter((item) => item.status === "failure" || item.status === "error");
1082
+ const failureAnalysisMarkdown = renderBackendTestFailureAnalysis({
1083
+ generatedAt: new Date().toISOString(),
1084
+ htmlReportPath: "reports/backend-test.html",
1085
+ total: parsed.tests,
1086
+ passed: parsed.passed,
1087
+ failed: parsed.failed + parsed.errors,
1088
+ durationLabel: `${parsed.durationMs ?? 0}ms`,
1089
+ environmentSummary: await readFile(path.join(reportsDir, "backend-test-environment.md"), "utf8").catch(() => "unavailable"),
1090
+ scenarioParamFinalStatus,
1091
+ repairAttempt: scenarioParamRepairAttempt,
1092
+ failures: failureCases.map((result) => {
1093
+ const caseMatch = result.name.match(/\bBE-[A-Z0-9_-]+-\d{2,3}\b/);
1094
+ const tpMatch = result.name.match(/\bTP-[A-Z0-9-]+\b/);
1095
+ const caseId = caseMatch?.[0] ?? result.name;
1096
+ const scenarioParamStatus = (tpMatch && scenarioParamStatusByToken.get(tpMatch[0])) ||
1097
+ scenarioParamStatusByToken.get(caseId);
1098
+ const evidence = [result.message, result.details]
1099
+ .filter(Boolean)
1100
+ .join("\n");
1101
+ const statusMatch = /assert\s+(\d{3})\s*==\s*(\d{3})/i.exec(evidence) ||
1102
+ /expected[^\d]*(\d{3})[\s\S]{0,40}actual[^\d]*(\d{3})/i.exec(evidence);
1103
+ return {
1104
+ name: result.name,
1105
+ caseId,
1106
+ scenario: cases.find((item) => item.id === caseId)?.scenario ??
1107
+ cases.find((item) => item.id === caseId)?.title ??
1108
+ result.name,
1109
+ expectedCode: statusMatch?.[2] ?? statusMatch?.[1],
1110
+ actualCode: statusMatch?.[1] ?? statusMatch?.[2],
1111
+ message: result.message || result.status,
1112
+ scriptPath: result.classname,
1113
+ durationLabel: result.durationMs !== undefined
1114
+ ? `${result.durationMs}ms`
1115
+ : undefined,
1116
+ scenarioParamStatus,
1117
+ classification: classifyBackendTestFailureWithScenarioParam({
1118
+ message: result.message || "",
1119
+ details: result.details,
1120
+ scenarioParamStatus,
1121
+ }),
1122
+ };
1123
+ }),
1124
+ });
1125
+ const failureAnalysisPath = await writeRunReport(meta.runDir, "backend-test-failure-analysis.md", failureAnalysisMarkdown);
1126
+ outputs.push(`failureAnalysis=${failureAnalysisPath}`);
992
1127
  // Deterministic L-5 dashboard: machine-computed metrics (not Pi-generated).
993
1128
  // manifest is produced by an upstream finalize node; tolerate its absence
994
1129
  // so a minimal DAG without manifest still gets a degraded dashboard.
@@ -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 },