@tea-agent/loop-agent 0.21.0 → 0.23.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 (108) hide show
  1. package/AGENTS.md +42 -108
  2. package/CHANGELOG.md +114 -0
  3. package/README.md +7 -4
  4. package/bin/agent-worker.js +0 -0
  5. package/dist/adapters/loop-agent.js +52 -0
  6. package/dist/application/context-usage/skill-resolution-stats.js +263 -0
  7. package/dist/application/dag/generate-task-dag.js +17 -3
  8. package/dist/cli/command-definitions.js +8 -7
  9. package/dist/cli/program.js +17 -15
  10. package/dist/commands/doctor.js +269 -18
  11. package/dist/commands/init.js +198 -86
  12. package/dist/commands/stats.js +40 -11
  13. package/dist/executors/dag-pi-executor.js +2 -0
  14. package/dist/executors/shell-executor.js +162 -19
  15. package/dist/shared/openspec-spec.js +49 -0
  16. package/dist/shared/operator/capabilities.js +11 -1
  17. package/dist/worker/console/app-data.js +4 -0
  18. package/dist/worker/console/chat/instruction-skills.js +217 -0
  19. package/dist/worker/console/chat/model-resolver.js +106 -0
  20. package/dist/worker/console/chat/pi-runtime.js +605 -0
  21. package/dist/worker/console/chat/resource-loader.js +66 -0
  22. package/dist/worker/console/chat/routes.js +357 -0
  23. package/dist/worker/console/chat/session-store.js +238 -0
  24. package/dist/worker/console/chat/tool-adapter.js +238 -0
  25. package/dist/worker/console/chat/tools.js +171 -0
  26. package/dist/worker/console/server.js +55 -0
  27. package/dist/worker/console/static/assets/index-DVl7Jxt5.js +25 -0
  28. package/dist/worker/console/static/assets/index-lVcIr9Ju.css +1 -0
  29. package/dist/worker/console/static/index.html +2 -2
  30. package/dist/worker/feature/profile-schema.js +1 -1
  31. package/dist/worker/observability/read-model.js +21 -1
  32. package/dist/worker/observe/spec-evidence.js +12 -15
  33. package/dist/worker/observe/static/dag-helpers.js +22 -0
  34. package/dist/worker/observe/static/views/dag.js +5 -0
  35. package/dist/workflows/dag/backend-test-markdown-workflow.js +37 -0
  36. package/dist/workflows/dag/frontend-implementation-contract.js +141 -32
  37. package/dist/workflows/dag/frontend-lint-baseline.js +471 -0
  38. package/dist/workflows/dag/frontend-prewrite-gate.js +79 -16
  39. package/dist/workflows/dag/frontend-project-capability.js +11 -8
  40. package/dist/workflows/dag/frontend-repair.js +6 -4
  41. package/dist/workflows/dag/frontend-review-context.js +67 -0
  42. package/dist/workflows/dag/frontend-test-case-quality.js +105 -0
  43. package/dist/workflows/dag/frontend-test-result-contract.js +71 -66
  44. package/dist/workflows/dag/frontend-verification-trace.js +31 -1
  45. package/dist/workflows/dag/frontend-worktree-diff.js +81 -6
  46. package/dist/workflows/dag/init-hybrid.js +412 -84
  47. package/dist/workflows/dag/node-execution.js +38 -1
  48. package/dist/workflows/dag/output-protocol.js +89 -0
  49. package/dist/workflows/dag/prompt.js +35 -1
  50. package/dist/workflows/dag/recovery-recommendation.js +45 -0
  51. package/dist/workflows/dag/report.js +28 -1
  52. package/dist/workflows/dag/rerun-task.js +1 -1
  53. package/dist/workflows/dag/scheduler.js +9 -0
  54. package/dist/workflows/dag/types.js +74 -1
  55. package/dist/workflows/dag/validate.js +55 -0
  56. package/docs/README.md +73 -156
  57. package/docs/architecture/README.md +3 -2
  58. package/docs/architecture/dag-execution.md +2 -2
  59. package/docs/architecture/evolution.md +14 -12
  60. package/docs/architecture/system-overview.md +1 -1
  61. package/docs/architecture/worker-and-feature.md +3 -3
  62. package/docs/governance/README.md +15 -0
  63. package/docs/{harness-methodology-debugging.md → governance/harness-methodology-debugging.md} +27 -3
  64. package/docs/init-surface.manifest.json +22 -4
  65. package/docs/operations/README.md +12 -0
  66. package/docs/{local-development-environment.md → operations/local-development-environment.md} +1 -1
  67. package/docs/skills/vetted-skill-registry.md +23 -3
  68. package/docs/templates/README.md +55 -0
  69. package/docs/templates/agent-dag.schema.json +15 -5
  70. package/docs/templates/backend-test-dag.json +1 -1
  71. package/docs/templates/evaluation/agents-map-slim-v1.candidate.json +9 -0
  72. package/docs/templates/evaluation/agents-map-slim-v1.md +87 -0
  73. package/docs/templates/evaluation/agents-map-verbose-v0.candidate.json +9 -0
  74. package/docs/templates/evaluation/agents-map-verbose-v0.md +153 -0
  75. package/docs/templates/frontend-implementation-contract.schema.json +4 -3
  76. package/docs/templates/frontend-test-case-checklist.md +6 -2
  77. package/docs/templates/frontend-test-dag.json +2 -2
  78. package/docs/templates/hybrid-dag.json +1 -1
  79. package/docs/templates/progress-log.md +9 -2
  80. package/harness.json +5 -5
  81. package/package.json +5 -5
  82. package/scripts/kb-bootstrap-init-skeleton.sh +2 -2
  83. package/skills/agent-worker/SKILL.md +1 -1
  84. package/skills/frontend-design-review/SKILL.md +12 -10
  85. package/skills/frontend-design-review/references/review-checklist.md +4 -4
  86. package/skills/frontend-implementation/SKILL.md +2 -2
  87. package/skills/frontend-implementation/references/code-standards.md +4 -3
  88. package/skills/frontend-implementation/references/design-spec.md +19 -14
  89. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  90. package/skills/frontend-review/SKILL.md +15 -28
  91. package/skills/frontend-review/references/review-findings.md +16 -18
  92. package/skills/frontend-verification/SKILL.md +16 -13
  93. package/skills/frontend-verification/references/verification-checklist.md +18 -30
  94. package/skills/grill-with-docs/SKILL.md +44 -52
  95. package/skills/grill-with-docs/adr-format.md +37 -26
  96. package/skills/grill-with-docs/context-format.md +18 -26
  97. package/skills/loop-agent/SKILL.md +28 -112
  98. package/skills/loop-agent/references/command-reference.md +9 -3
  99. package/skills/loop-agent/references/harness-policy.md +3 -3
  100. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  101. package/skills/loop-agent/references/orchestrator-and-interventions.md +1 -1
  102. package/skills/loop-agent/references/task-workflow.md +2 -0
  103. package/skills/systematic-debugging/SKILL.md +20 -4
  104. package/skills/test-driven-development/SKILL.md +10 -3
  105. package/dist/worker/console/static/assets/index-CUDke82y.js +0 -18
  106. package/dist/worker/console/static/assets/index-wSEksVSO.css +0 -1
  107. /package/docs/{harness-methodology-tdd.md → governance/harness-methodology-tdd.md} +0 -0
  108. /package/docs/{harness-methodology-verification.md → governance/harness-methodology-verification.md} +0 -0
@@ -0,0 +1,605 @@
1
+ /**
2
+ * Operator Chat — Pi SDK runtime wrapper (plan W1 / M1).
3
+ *
4
+ * Wraps the Pi SDK's services-mode session API
5
+ * (createAgentSessionServices + createAgentSessionFromServices + SessionManager)
6
+ * into a small, testable surface that the Console HTTP layer can drive.
7
+ *
8
+ * Isolation guarantees (plan D4 / V12 / V14):
9
+ * - Chat sessions are persisted to a DEDICATED sessionDir (not the user's
10
+ * default ~/.pi/agent/sessions). Naming prefix `operator-chat-` makes them
11
+ * auditable and cleanable (plan Q4 / Q8).
12
+ * - The ResourceLoader is configured to NOT auto-load user extensions/skills
13
+ * (noContextFiles / closed surface); credential/model plane is shared via
14
+ * the same agentDir auth.json/models.json.
15
+ * - Active tools are pinned to the operator-chat surface at session create
16
+ * AND re-pinned before each prompt (three-gate, design §7.5): the FULL
17
+ * operator action set (dynamic, from the registry) PLUS the built-in
18
+ * read/explore tools (bash/read/grep/find/ls). File-WRITING coding tools
19
+ * (edit/write/apply_patch/full-tools/shell/coding-chat) are excluded from
20
+ * the SDK registry and can never be activated (2026-07-25 widening).
21
+ *
22
+ * The actual SDK calls are injected via `PiSdkBindings` so this module is
23
+ * unit-testable without a live Pi install. Production bindings come from
24
+ * `createDefaultPiSdkBindings()`.
25
+ */
26
+ import { randomBytes } from "node:crypto";
27
+ import path from "node:path";
28
+ import { OPERATOR_CHAT_ALLOWED_TOOLS, authorizeOperatorChatTool, assertNoWriteToolInList, } from "./tools.js";
29
+ import { createOperatorChatResourceLoader, } from "./resource-loader.js";
30
+ import { loadOperatorChatInstructionSkills, composeInstructionSkillsPrompt, } from "./instruction-skills.js";
31
+ import { buildOperatorChatToolSchemas } from "./tool-adapter.js";
32
+ import { resolveDefaultChatModel, } from "./model-resolver.js";
33
+ /**
34
+ * Base system prompt fragment every General Operator Chat session receives.
35
+ *
36
+ * Establishes the operator-vs-implementer role boundary (ADR 0005 D2) and the
37
+ * current tool surface contract (2026-07-25 widening): the Chat exposes the
38
+ * FULL operator action surface (all registry actions, including high-risk
39
+ * mutations like contractApply / runDag / dagRerun) PLUS the built-in
40
+ * read/explore tools (bash / read / grep / find / ls). The ONLY thing still
41
+ * forbidden is direct file-WRITING via coding tools (edit / write /
42
+ * apply_patch / full-tools / shell / coding-chat) — those are excluded from
43
+ * the SDK registry entirely and can never be activated.
44
+ *
45
+ * Instruction skills are appended AFTER this as read-only context (plan D1).
46
+ */
47
+ const OPERATOR_CHAT_SYSTEM_PROMPT_BASE = [
48
+ "You are the General Operator Chat for loop-agent / agent-worker.",
49
+ "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.",
54
+ ].join("\n");
55
+ /**
56
+ * Built-in SDK read/explore tool names registered for every Chat session.
57
+ *
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.
62
+ */
63
+ export const OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS = Object.freeze([
64
+ "bash",
65
+ "read",
66
+ "grep",
67
+ "find",
68
+ "ls",
69
+ ]);
70
+ /**
71
+ * 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.
75
+ */
76
+ export function OPERATOR_CHAT_ACTIVE_TOOL_NAMES() {
77
+ return [...OPERATOR_CHAT_ALLOWED_TOOLS, ...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS];
78
+ }
79
+ /**
80
+ * Build Pi custom ToolDefinition objects for every whitelisted operator action.
81
+ * Each tool's execute() routes into the operator action dispatcher (Gate 3).
82
+ * Requires the `typebox` + `defineTool` packages (both bundled with the SDK).
83
+ */
84
+ async function buildCustomOperatorTools(options) {
85
+ // typebox is a transitive dep of @earendil-works/pi-coding-agent; Node's
86
+ // resolver hoists to the SDK's nested copy. defineTool is re-exported by SDK.
87
+ const [{ Type }, { defineTool }] = await Promise.all([
88
+ import("typebox"),
89
+ import("@earendil-works/pi-coding-agent"),
90
+ ]);
91
+ const schemas = buildOperatorChatToolSchemas();
92
+ const ctx = options.actionContext;
93
+ return schemas.map((schema) => {
94
+ // TypeBox schema is built dynamically; cast through unknown to the builder
95
+ // since Static<TParams> is structural and cannot be statically inferred here.
96
+ const parameters = Type.Object(schema.inputParams.reduce((acc, param) => {
97
+ let t;
98
+ switch (param.type) {
99
+ case "string":
100
+ t = Type.String();
101
+ break;
102
+ case "boolean":
103
+ t = Type.Boolean();
104
+ break;
105
+ case "number":
106
+ t = Type.Number();
107
+ break;
108
+ case "array":
109
+ t = Type.Array(Type.String());
110
+ break;
111
+ default:
112
+ t = Type.Object({}, { additionalProperties: true });
113
+ }
114
+ acc[param.name] = param.required
115
+ ? t
116
+ : Type.Optional(t);
117
+ return acc;
118
+ }, {}), { additionalProperties: false });
119
+ return defineTool({
120
+ name: schema.toolId,
121
+ label: schema.toolId,
122
+ description: `${schema.description} (kind=${schema.kind}; cli: ${schema.cli})`,
123
+ promptSnippet: `${schema.toolId}: ${schema.description}`,
124
+ parameters: parameters,
125
+ async execute(toolCallId, params) {
126
+ const decision = authorizeOperatorChatTool(schema.toolId);
127
+ if (!decision.ok) {
128
+ return {
129
+ content: [
130
+ { type: "text", text: JSON.stringify({ error: decision.message, code: decision.code }) },
131
+ ],
132
+ details: { error: decision.message, code: decision.code },
133
+ };
134
+ }
135
+ // Defer require to avoid circular import at module load.
136
+ const { dispatchChatToolCall } = await import("./session-store.js");
137
+ const result = await dispatchChatToolCall(ctx, decision.toolId, params, toolCallId);
138
+ const payload = result.ok
139
+ ? result.result
140
+ : { error: result.message, code: result.errorCode };
141
+ return {
142
+ content: [{ type: "text", text: JSON.stringify(payload) }],
143
+ details: payload,
144
+ };
145
+ },
146
+ });
147
+ });
148
+ }
149
+ /**
150
+ * Extract the latest assistant text fragment from an SDK message object.
151
+ * Tolerant of multiple message shapes the SDK emits.
152
+ */
153
+ export function extractAssistantText(message) {
154
+ if (!message || typeof message !== "object")
155
+ return "";
156
+ const m = message;
157
+ if (typeof m.text === "string")
158
+ return m.text;
159
+ const contents = Array.isArray(m.content) ? m.content : [];
160
+ const parts = Array.isArray(m.parts) ? m.parts : [];
161
+ const segments = [];
162
+ for (const c of [...contents, ...parts]) {
163
+ if (!c || typeof c !== "object")
164
+ continue;
165
+ const seg = c;
166
+ if (typeof seg.text === "string")
167
+ segments.push(seg.text);
168
+ }
169
+ return segments.join("");
170
+ }
171
+ /**
172
+ * Console Pi runtime — holds Chat sessions and drives prompt() with three-gate
173
+ * tool enforcement. Session-per-sessionId; cross-request reuse via open().
174
+ */
175
+ export class ConsolePiRuntime {
176
+ options;
177
+ loader;
178
+ sessions = new Map();
179
+ bindings;
180
+ constructor(options) {
181
+ this.options = options;
182
+ this.loader = createOperatorChatResourceLoader();
183
+ this.bindings =
184
+ options.bindings ?? createDefaultPiSdkBindings();
185
+ }
186
+ get sessionDir() {
187
+ return this.options.sessionDir;
188
+ }
189
+ /** Operator action context used by custom-tool dispatchers (Gate 3). */
190
+ get actionContext() {
191
+ return this.options.actionContext;
192
+ }
193
+ /**
194
+ * Resolve the Chat default model descriptor from harness.json
195
+ * executors.pi.MED + the SDK available-model list. Returns undefined when
196
+ * harness is unset or no provider surfaces the model. No hardcoded fallback.
197
+ */
198
+ async resolveDefaultModel() {
199
+ let available;
200
+ try {
201
+ // Build a throwaway services just to read modelRuntime.snapshot. This is
202
+ // cheap (SDK caches); tests inject bindings that stub listAvailableModels.
203
+ const { services } = await this.bindings.createServices({
204
+ cwd: this.options.cwd,
205
+ agentDir: await safeGetAgentDir(this.bindings),
206
+ });
207
+ available = this.bindings.listAvailableModels({
208
+ modelRuntime: services.modelRuntime,
209
+ });
210
+ }
211
+ catch {
212
+ return undefined;
213
+ }
214
+ const descriptor = await resolveDefaultChatModel(this.options.cwd, available);
215
+ if (!descriptor)
216
+ return undefined;
217
+ return {
218
+ provider: descriptor.provider,
219
+ modelId: descriptor.modelId,
220
+ };
221
+ }
222
+ /**
223
+ * Load the operator-context instruction skills (plan D1) and compose the
224
+ * read-only methodology fragment for the Chat system prompt. Returns an
225
+ * empty string when no skills load (e.g. skills dir missing) so the
226
+ * session still boots — skills are context, not a hard dependency.
227
+ */
228
+ async composeSystemPromptSkills() {
229
+ try {
230
+ const result = await loadOperatorChatInstructionSkills(this.options.skillsDir);
231
+ return composeInstructionSkillsPrompt(result.loaded);
232
+ }
233
+ catch (error) {
234
+ process.stderr.write(`[console] chat instruction-skills load failed: ${error instanceof Error ? error.message : String(error)}\n`);
235
+ return "";
236
+ }
237
+ }
238
+ /** Create a new isolated Chat session pinned to the operator-chat tool set. */
239
+ async createSession(init) {
240
+ const sessionId = `operator-chat-${randomBytes(12).toString("hex")}`;
241
+ const agentDir = await safeGetAgentDir(this.bindings);
242
+ // Resolve default model from harness.json executors.pi.MED (no hardcode).
243
+ // Caller may pass an explicit model; otherwise we read the repo harness and
244
+ // search the SDK available-model list for a credentialled provider.
245
+ let model = init?.model;
246
+ if (!model) {
247
+ const resolved = await this.resolveDefaultModel();
248
+ if (resolved)
249
+ model = resolved;
250
+ }
251
+ // Final fallback only when nothing resolved: let the SDK pick its default
252
+ // (we do NOT hardcode a provider here).
253
+ // Load operator-context instruction skills (plan D1) and compose the
254
+ // read-only methodology fragment injected into the Chat system prompt.
255
+ // Skills are NEVER registered as executable tools (D1 hard rule).
256
+ const skillFragment = await this.composeSystemPromptSkills();
257
+ const appendSystemPrompt = [
258
+ OPERATOR_CHAT_SYSTEM_PROMPT_BASE,
259
+ ...(skillFragment ? [skillFragment] : []),
260
+ ...(init?.systemPromptSuffix ? [init.systemPromptSuffix] : []),
261
+ ];
262
+ const { services } = await this.bindings.createServices({
263
+ cwd: this.options.cwd,
264
+ agentDir,
265
+ resourceLoaderOptions: {
266
+ // Closed surface: do not load user/project extensions, skills,
267
+ // AGENTS.md, context files. The Chat tool surface is fixed by
268
+ // the whitelist, not by filesystem discovery.
269
+ noContextFiles: true,
270
+ noSkills: true,
271
+ noExtensions: true,
272
+ // Inject the audited instruction skills as READ-ONLY context
273
+ // (plan D1). appendSystemPrompt is the SDK's documented entry
274
+ // point for adding system-prompt text without enabling tools.
275
+ appendSystemPrompt,
276
+ },
277
+ });
278
+ const modelRuntime = services.modelRuntime;
279
+ // Resolve the concrete SDK model only when we have a descriptor. When no
280
+ // model is resolved (harness unset + no snapshot match), we omit `model`
281
+ // and let the SDK session use its own default (no hardcoded fallback).
282
+ const resolvedModel = model
283
+ ? (this.bindings.resolveModel({
284
+ modelRuntime,
285
+ provider: model.provider,
286
+ modelId: model.modelId,
287
+ }) ?? undefined)
288
+ : undefined;
289
+ const sessionManager = await this.bindings.createSessionManager({
290
+ cwd: this.options.cwd,
291
+ sessionDir: this.options.sessionDir,
292
+ sessionId,
293
+ });
294
+ // Gate 1: pin the tool set at session create. assertNoWriteTool guards
295
+ // that no file-WRITING coding tool slipped into the allowed list (bash
296
+ // is intentionally allowed since the 2026-07-25 widening).
297
+ assertNoWriteToolInList(OPERATOR_CHAT_ALLOWED_TOOLS);
298
+ // Build custom Pi tools that route whitelisted operator actions into
299
+ // the dispatcher (Gate 3 lives inside each tool's execute()).
300
+ let customTools;
301
+ if (this.options.actionContext) {
302
+ try {
303
+ customTools = await buildCustomOperatorTools({
304
+ actionContext: this.options.actionContext,
305
+ });
306
+ }
307
+ catch (error) {
308
+ // If custom-tool build fails (e.g. typebox unavailable), fall back to
309
+ // a tool-less session — Chat still works as text-only, and the
310
+ // failure is surfaced via doctor / readiness diagnostics.
311
+ process.stderr.write(`[console] chat custom-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
312
+ }
313
+ }
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
318
+ // by passing `tools: OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS` (Gate 1).
319
+ // This is REQUIRED: the SDK's default builtin set is only
320
+ // `read, bash, edit, write` (grep/find/ls are NOT default builtins —
321
+ // 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
+ const { session } = await this.bindings.createSessionFromServices({
328
+ services,
329
+ 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).
334
+ tools: [...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS],
335
+ excludeTools: [
336
+ "edit",
337
+ "write",
338
+ "apply_patch",
339
+ "apply-patch",
340
+ "full-tools",
341
+ "full_tools",
342
+ "coding-chat",
343
+ "coding_chat",
344
+ "shell",
345
+ ],
346
+ ...(customTools && customTools.length > 0
347
+ ? { customTools }
348
+ : {}),
349
+ ...(resolvedModel ? { model: resolvedModel } : {}),
350
+ });
351
+ // Gate 2: re-pin active tools (defensive — some SDK paths may seed
352
+ // defaults). Active set = all operator actions + built-in explore tools.
353
+ // File-writing tools are excluded above and thus cannot appear here.
354
+ session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
355
+ this.sessions.set(sessionId, session);
356
+ return {
357
+ sessionId,
358
+ sessionFile: session.sessionFile,
359
+ createdAt: new Date().toISOString(),
360
+ model,
361
+ activeTools: session.getActiveToolNames(),
362
+ };
363
+ }
364
+ /** Re-open a previously created session (cross-request). */
365
+ open(sessionId) {
366
+ return this.sessions.get(sessionId);
367
+ }
368
+ hasSession(sessionId) {
369
+ return this.sessions.has(sessionId);
370
+ }
371
+ /**
372
+ * Send a prompt and stream events. Gate 3: each tool invocation is
373
+ * re-authorized by authorizeOperatorChatTool before the dispatcher runs it
374
+ * (the dispatcher is wired by the HTTP layer, see chat-session.ts).
375
+ */
376
+ async prompt(sessionId, text, onEvent, options) {
377
+ const session = this.sessions.get(sessionId);
378
+ if (!session) {
379
+ throw new Error(`chat session not found: ${sessionId}`);
380
+ }
381
+ // Gate 2 (re-pin before each turn): defend against any mid-session drift.
382
+ session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
383
+ const unsub = session.subscribe((event) => {
384
+ const mapped = mapSdkEvent(sessionId, event);
385
+ if (mapped)
386
+ onEvent(mapped);
387
+ });
388
+ const onAbort = () => {
389
+ try {
390
+ session.dispose();
391
+ }
392
+ catch {
393
+ // ignore
394
+ }
395
+ };
396
+ options?.signal?.addEventListener("abort", onAbort);
397
+ try {
398
+ await session.prompt(text);
399
+ // Wait until the session reports idle / not streaming. The SDK prompt()
400
+ // resolves when the agent turn completes, but post-turn continuation
401
+ // (auto-compaction / follow-up) may still be in flight. We poll isIdle.
402
+ // While polling we emit periodic heartbeat events so the SSE stream
403
+ // keeps proxies/browsers from timing out (no other data is flowing).
404
+ await waitForIdle(session, {
405
+ signal: options?.signal,
406
+ onHeartbeat: () => onEvent({ type: "heartbeat", sessionId }),
407
+ });
408
+ onEvent({ type: "agent_settled", sessionId });
409
+ }
410
+ catch (error) {
411
+ const message = error instanceof Error ? error.message : String(error);
412
+ onEvent({ type: "error", sessionId, message });
413
+ throw error;
414
+ }
415
+ finally {
416
+ options?.signal?.removeEventListener("abort", onAbort);
417
+ unsub();
418
+ }
419
+ }
420
+ dispose(sessionId) {
421
+ const session = this.sessions.get(sessionId);
422
+ if (session) {
423
+ try {
424
+ session.dispose();
425
+ }
426
+ catch {
427
+ // ignore
428
+ }
429
+ this.sessions.delete(sessionId);
430
+ }
431
+ }
432
+ disposeAll() {
433
+ for (const id of [...this.sessions.keys()])
434
+ this.dispose(id);
435
+ }
436
+ }
437
+ function mapSdkEvent(sessionId, event) {
438
+ switch (event.type) {
439
+ case "agent_start":
440
+ return { type: "agent_start", sessionId };
441
+ case "agent_end":
442
+ return {
443
+ type: "agent_end",
444
+ sessionId,
445
+ willRetry: Boolean(event.willRetry),
446
+ };
447
+ case "message_update":
448
+ return {
449
+ type: "message_update",
450
+ sessionId,
451
+ text: extractAssistantText(event.message),
452
+ };
453
+ case "message_end":
454
+ return {
455
+ type: "message_end",
456
+ sessionId,
457
+ text: extractAssistantText(event.message),
458
+ };
459
+ case "tool_execution_start":
460
+ return {
461
+ type: "tool_call",
462
+ sessionId,
463
+ toolCallId: event.toolCallId,
464
+ toolName: event.toolName,
465
+ args: event.args,
466
+ };
467
+ case "tool_execution_end":
468
+ return {
469
+ type: "tool_result",
470
+ sessionId,
471
+ toolCallId: event.toolCallId,
472
+ toolName: event.toolName,
473
+ result: event.result,
474
+ isError: event.isError,
475
+ };
476
+ case "agent_settled":
477
+ return { type: "agent_settled", sessionId };
478
+ default:
479
+ return undefined;
480
+ }
481
+ }
482
+ async function waitForIdle(session, options) {
483
+ // Default 45s — shorter than the previous 120s so a stuck SDK session does
484
+ // not pin an HTTP connection for two full minutes. The SDK agent turn
485
+ // normally settles in seconds; 45s covers auto-compaction/follow-up tails
486
+ // while staying under typical proxy idle timeouts (60s).
487
+ const timeoutMs = options?.timeoutMs ?? 45_000;
488
+ const heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 10_000;
489
+ const start = Date.now();
490
+ let lastHeartbeat = start;
491
+ // eslint-disable-next-line no-constant-condition
492
+ while (true) {
493
+ if (options?.signal?.aborted)
494
+ return;
495
+ if (session.isIdle === true)
496
+ return;
497
+ if (session.isStreaming === false)
498
+ return;
499
+ const now = Date.now();
500
+ if (now - start > timeoutMs)
501
+ return;
502
+ if (options?.onHeartbeat && now - lastHeartbeat >= heartbeatIntervalMs) {
503
+ lastHeartbeat = now;
504
+ try {
505
+ options.onHeartbeat();
506
+ }
507
+ catch {
508
+ // Heartbeat is best-effort; never let it break the wait loop.
509
+ }
510
+ }
511
+ await new Promise((r) => setTimeout(r, 50));
512
+ }
513
+ }
514
+ async function safeGetAgentDir(bindings) {
515
+ try {
516
+ return await bindings.getAgentDir();
517
+ }
518
+ catch {
519
+ // Fall back to a Console-app-data scoped agent dir so we never accidentally
520
+ // touch the user's real ~/.pi/agent config if getAgentDir throws.
521
+ return path.join(process.cwd(), ".harness", "console", "pi-agent-dir");
522
+ }
523
+ }
524
+ /**
525
+ * Cached SDK module loader. The pi-coding-agent package is ESM-only, so we
526
+ * must use dynamic import() (not require). Cached to avoid repeated imports.
527
+ */
528
+ let cachedSdk;
529
+ async function loadSdk() {
530
+ if (!cachedSdk) {
531
+ cachedSdk = import("@earendil-works/pi-coding-agent");
532
+ }
533
+ return cachedSdk;
534
+ }
535
+ /**
536
+ * Default production bindings backed by the real Pi SDK (services mode).
537
+ * Dynamically imported so the worker bundle does not hard-fail if the SDK is
538
+ * absent at module load (discovered lazily by pi-readiness instead).
539
+ */
540
+ export function createDefaultPiSdkBindings() {
541
+ return {
542
+ getAgentDir: async () => {
543
+ const sdk = (await loadSdk());
544
+ return sdk.getAgentDir();
545
+ },
546
+ createServices: async (opts) => {
547
+ const sdk = (await loadSdk());
548
+ // createAgentSessionServices returns the full AgentSessionServices object
549
+ // directly (cwd/agentDir/modelRuntime/settingsManager/resourceLoader).
550
+ // Wrap it so our type contract is satisfied.
551
+ const result = await sdk.createAgentSessionServices({
552
+ cwd: opts.cwd,
553
+ agentDir: opts.agentDir,
554
+ resourceLoaderOptions: opts.resourceLoaderOptions,
555
+ });
556
+ return { services: result };
557
+ },
558
+ createSessionFromServices: async (opts) => {
559
+ const sdk = (await loadSdk());
560
+ return sdk.createAgentSessionFromServices({
561
+ services: opts.services,
562
+ sessionManager: opts.sessionManager,
563
+ ...(opts.tools ? { tools: opts.tools } : {}),
564
+ ...(opts.noTools ? { noTools: opts.noTools } : {}),
565
+ ...(opts.excludeTools ? { excludeTools: opts.excludeTools } : {}),
566
+ ...(opts.customTools ? { customTools: opts.customTools } : {}),
567
+ ...(opts.model ? { model: opts.model } : {}),
568
+ });
569
+ },
570
+ createSessionManager: async (opts) => {
571
+ const sdk = (await loadSdk());
572
+ return sdk.SessionManager.create(opts.cwd, opts.sessionDir, {
573
+ id: opts.sessionId,
574
+ });
575
+ },
576
+ resolveModel: (opts) => {
577
+ const rt = opts.modelRuntime;
578
+ if (typeof rt?.getModel !== "function")
579
+ return undefined;
580
+ try {
581
+ return rt.getModel(opts.provider, opts.modelId);
582
+ }
583
+ catch {
584
+ return undefined;
585
+ }
586
+ },
587
+ listAvailableModels: (opts) => {
588
+ const rt = opts.modelRuntime;
589
+ const available = rt?.snapshot?.available ?? [];
590
+ const configured = rt?.snapshot?.configuredProviders;
591
+ const isConfigured = (provider) => configured instanceof Set ? configured.has(provider) : false;
592
+ return available.map((m) => ({
593
+ id: m.id,
594
+ name: m.name,
595
+ provider: m.provider,
596
+ hasCredentials: isConfigured(m.provider),
597
+ }));
598
+ },
599
+ };
600
+ }
601
+ /**
602
+ * Re-export the tool gate for the dispatcher layer so the per-tool-call gate
603
+ * (Gate 3) lives in exactly one place.
604
+ */
605
+ export { authorizeOperatorChatTool, assertNoWriteToolInList, OPERATOR_CHAT_ALLOWED_TOOLS, };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Operator Chat — closed ResourceLoader contract (design §7.5 / V12 / plan W3).
3
+ *
4
+ * The Chat session does NOT discover user-level / project-level extensions or
5
+ * skills (its tool surface is fixed: the operator-action registry + the
6
+ * built-in explore tools). This module is the typed contract describing the
7
+ * DECLARED Chat tool surface — it mirrors what pi-runtime.ts actually wires
8
+ * into the SDK session, so that capabilities/doctor/red-team can report a
9
+ * single source of truth. It is deliberately framework-agnostic (does not
10
+ * import the Pi SDK at module top-level so it stays unit-testable).
11
+ *
12
+ * Current contract (2026-07-25 widening): bash/read/grep/find/ls are ALLOWED
13
+ * explore tools; the full operator action set is allowed; the ONLY denied
14
+ * tools are file-WRITING coding tools (edit/write/apply_patch/full-tools/
15
+ * shell/coding-chat). hasBash therefore reflects reality: true.
16
+ *
17
+ * Note: this adapter is NOT the SDK's own ResourceLoader (that is
18
+ * DefaultResourceLoader, created inside createAgentSessionServices with
19
+ * noContextFiles/noSkills/noExtensions). This adapter is the operator-chat
20
+ * declarative surface used for capabilities reporting and red-team checks.
21
+ * The three-gate authorization (authorizeOperatorChatTool) is the actual
22
+ * enforcement; this adapter describes what that gate admits.
23
+ */
24
+ import { OPERATOR_CHAT_ALLOWED_TOOLS, OPERATOR_CHAT_DENIED_TOOLS, OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS, authorizeOperatorChatTool, filterOperatorChatTools, assertNoWriteToolInList, } from "./tools.js";
25
+ /** Built-in explore tool names that are ALLOWED in every Chat session. */
26
+ export const OPERATOR_CHAT_BUILTIN_EXPLORE_TOOL_IDS = Object.freeze([
27
+ "bash",
28
+ "read",
29
+ "grep",
30
+ "find",
31
+ "ls",
32
+ ]);
33
+ export function createOperatorChatResourceLoader() {
34
+ let active = new Set();
35
+ return {
36
+ mode: "operator-chat-closed",
37
+ allowedToolIds: OPERATOR_CHAT_ALLOWED_TOOLS,
38
+ deniedToolIds: OPERATOR_CHAT_DENIED_TOOLS,
39
+ deniedOperatorActions: OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS,
40
+ builtinExploreToolIds: OPERATOR_CHAT_BUILTIN_EXPLORE_TOOL_IDS,
41
+ listTools: () => OPERATOR_CHAT_ALLOWED_TOOLS,
42
+ tryActivateTool: (toolId) => {
43
+ // Explore tools (bash/read/grep/find/ls) are run by the SDK's built-in
44
+ // tool registry, not by the operator dispatcher. tryActivateTool only
45
+ // classifies operator actions here, but it must NOT deny an explore
46
+ // tool — that would contradict the real session. Deny only write tools
47
+ // and report unknowns.
48
+ const explore = OPERATOR_CHAT_BUILTIN_EXPLORE_TOOL_IDS.includes(toolId);
49
+ if (explore) {
50
+ return { ok: true, toolId };
51
+ }
52
+ const decision = authorizeOperatorChatTool(toolId);
53
+ if (decision.ok)
54
+ active.add(decision.toolId);
55
+ return decision;
56
+ },
57
+ setActiveToolsByName: (names) => {
58
+ assertNoWriteToolInList(names);
59
+ const { allowed, denied } = filterOperatorChatTools(names);
60
+ active = new Set(allowed);
61
+ return { active: [...active], denied };
62
+ },
63
+ allowsEnvToolRestore: false,
64
+ hasBash: true,
65
+ };
66
+ }