@tea-agent/loop-agent 0.22.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 (73) hide show
  1. package/AGENTS.md +42 -108
  2. package/CHANGELOG.md +68 -0
  3. package/README.md +7 -4
  4. package/dist/application/context-usage/skill-resolution-stats.js +263 -0
  5. package/dist/application/dag/generate-task-dag.js +17 -3
  6. package/dist/cli/command-definitions.js +8 -7
  7. package/dist/cli/program.js +17 -15
  8. package/dist/commands/doctor.js +269 -18
  9. package/dist/commands/init.js +101 -86
  10. package/dist/commands/stats.js +40 -11
  11. package/dist/shared/operator/capabilities.js +11 -1
  12. package/dist/worker/console/app-data.js +4 -0
  13. package/dist/worker/console/chat/instruction-skills.js +217 -0
  14. package/dist/worker/console/chat/model-resolver.js +106 -0
  15. package/dist/worker/console/chat/pi-runtime.js +605 -0
  16. package/dist/worker/console/chat/resource-loader.js +66 -0
  17. package/dist/worker/console/chat/routes.js +357 -0
  18. package/dist/worker/console/chat/session-store.js +238 -0
  19. package/dist/worker/console/chat/tool-adapter.js +238 -0
  20. package/dist/worker/console/chat/tools.js +171 -0
  21. package/dist/worker/console/server.js +55 -0
  22. package/dist/worker/console/static/assets/index-DVl7Jxt5.js +25 -0
  23. package/dist/worker/console/static/assets/index-lVcIr9Ju.css +1 -0
  24. package/dist/worker/console/static/index.html +2 -2
  25. package/dist/worker/feature/profile-schema.js +1 -1
  26. package/dist/workflows/dag/init-hybrid.js +69 -21
  27. package/dist/workflows/dag/node-execution.js +38 -1
  28. package/dist/workflows/dag/output-protocol.js +89 -0
  29. package/dist/workflows/dag/prompt.js +35 -1
  30. package/dist/workflows/dag/recovery-recommendation.js +45 -0
  31. package/dist/workflows/dag/report.js +28 -1
  32. package/dist/workflows/dag/rerun-task.js +1 -1
  33. package/dist/workflows/dag/scheduler.js +9 -0
  34. package/dist/workflows/dag/types.js +12 -0
  35. package/dist/workflows/dag/validate.js +55 -0
  36. package/docs/README.md +73 -156
  37. package/docs/architecture/README.md +3 -2
  38. package/docs/architecture/dag-execution.md +2 -2
  39. package/docs/architecture/evolution.md +14 -12
  40. package/docs/architecture/system-overview.md +1 -1
  41. package/docs/architecture/worker-and-feature.md +3 -3
  42. package/docs/governance/README.md +15 -0
  43. package/docs/{harness-methodology-debugging.md → governance/harness-methodology-debugging.md} +27 -3
  44. package/docs/init-surface.manifest.json +22 -4
  45. package/docs/operations/README.md +12 -0
  46. package/docs/{local-development-environment.md → operations/local-development-environment.md} +1 -1
  47. package/docs/skills/vetted-skill-registry.md +23 -3
  48. package/docs/templates/README.md +55 -0
  49. package/docs/templates/evaluation/agents-map-slim-v1.candidate.json +9 -0
  50. package/docs/templates/evaluation/agents-map-slim-v1.md +87 -0
  51. package/docs/templates/evaluation/agents-map-verbose-v0.candidate.json +9 -0
  52. package/docs/templates/evaluation/agents-map-verbose-v0.md +153 -0
  53. package/docs/templates/hybrid-dag.json +1 -1
  54. package/docs/templates/progress-log.md +9 -2
  55. package/harness.json +5 -5
  56. package/package.json +5 -5
  57. package/scripts/kb-bootstrap-init-skeleton.sh +2 -2
  58. package/skills/agent-worker/SKILL.md +1 -1
  59. package/skills/grill-with-docs/SKILL.md +44 -52
  60. package/skills/grill-with-docs/adr-format.md +37 -26
  61. package/skills/grill-with-docs/context-format.md +18 -26
  62. package/skills/loop-agent/SKILL.md +28 -112
  63. package/skills/loop-agent/references/command-reference.md +9 -3
  64. package/skills/loop-agent/references/harness-policy.md +3 -3
  65. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  66. package/skills/loop-agent/references/orchestrator-and-interventions.md +1 -1
  67. package/skills/loop-agent/references/task-workflow.md +2 -0
  68. package/skills/systematic-debugging/SKILL.md +20 -4
  69. package/skills/test-driven-development/SKILL.md +10 -3
  70. package/dist/worker/console/static/assets/index-CUDke82y.js +0 -18
  71. package/dist/worker/console/static/assets/index-wSEksVSO.css +0 -1
  72. /package/docs/{harness-methodology-tdd.md → governance/harness-methodology-tdd.md} +0 -0
  73. /package/docs/{harness-methodology-verification.md → governance/harness-methodology-verification.md} +0 -0
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Operator Chat — operator action → Pi tool schema adapter (plan W5 / W12).
3
+ *
4
+ * Maps the FULL operator action surface (every action in the capabilities
5
+ * registry — read, mutation, and long-running — derived dynamically) into
6
+ * tool-schema descriptors the Pi model can invoke. Each descriptor carries:
7
+ * - toolId (canonical action name)
8
+ * - description (from capabilities registry)
9
+ * - inputParams (the parameters the model may supply; MUST match the
10
+ * dispatcher's paramsOf reads — see INPUT_PARAMS contract note)
11
+ *
12
+ * High-risk / long-running actions ARE mapped as tools (2026-07-25 widening).
13
+ * They still fail closed at dispatch time without their prepared receipt.
14
+ *
15
+ * The adapter does NOT register tools with the SDK directly — that is the
16
+ * session layer's job (chat/pi-runtime.ts). This keeps the adapter pure and
17
+ * unit-testable against the registry.
18
+ */
19
+ import { buildOperatorCapabilitiesDocument } from "../../../shared/operator/capabilities.js";
20
+ import { OPERATOR_CHAT_ALLOWED_TOOLS, authorizeOperatorChatTool, } from "./tools.js";
21
+ /**
22
+ * Minimal input parameter specs per action. Only the parameters the model is
23
+ * allowed to supply are listed; the dispatch layer fills the rest (repoRoot,
24
+ * operatorSessionId, request identity / clientRequestId — note the dispatcher
25
+ * reads `req.clientRequestId`, not `p.clientRequestId`, so the model MUST NOT
26
+ * be asked for it). Kept intentionally small to avoid over-permissive tool
27
+ * surfaces.
28
+ *
29
+ * CONTRACT SOURCE OF TRUTH: each entry here MUST match the fields the
30
+ * dispatcher in operator-actions.ts actually reads from `p` (paramsOf). The
31
+ * `verifyOperatorChatToolSurfaceMatchesDispatcher` test pins this. Adding a
32
+ * new operator action to the registry WITHOUT an entry here is a build-time
33
+ * failure (fail-closed dynamic discovery) — see buildOperatorChatToolSchemas.
34
+ */
35
+ const INPUT_PARAMS = {
36
+ operatorCapabilities: [],
37
+ inspect: [],
38
+ doctor: [],
39
+ contractShow: [
40
+ { name: "taskId", type: "string", required: true, description: "task id" },
41
+ ],
42
+ // contractValidate / contractDiff: dispatcher reads draftJson/inputText
43
+ // (a JSON string staged to a file), NOT a `draft` object. The model may
44
+ // pass a stored-draft taskId with NO draftJson to validate the saved draft.
45
+ contractValidate: [
46
+ { name: "taskId", type: "string", required: true, description: "task id" },
47
+ {
48
+ name: "draftJson",
49
+ type: "string",
50
+ required: false,
51
+ description: "TaskContractDraftV1 serialized as a JSON string. When omitted, the dispatcher validates the task's currently-saved Console draft.",
52
+ },
53
+ ],
54
+ contractDiff: [
55
+ { name: "taskId", type: "string", required: true, description: "task id" },
56
+ {
57
+ name: "draftJson",
58
+ type: "string",
59
+ required: false,
60
+ description: "TaskContractDraftV1 serialized as a JSON string to diff against the saved draft. When omitted, diffs the saved draft.",
61
+ },
62
+ ],
63
+ sourceInstructions: [
64
+ { name: "taskId", type: "string", required: true, description: "task id" },
65
+ ],
66
+ prepareDagConfirmation: [
67
+ { name: "taskId", type: "string", required: true, description: "task id" },
68
+ ],
69
+ observeLink: [
70
+ {
71
+ name: "taskId",
72
+ type: "string",
73
+ required: false,
74
+ description: "task id (optional, one of taskId/dagRunId/featureId)",
75
+ },
76
+ {
77
+ name: "dagRunId",
78
+ type: "string",
79
+ required: false,
80
+ description: "dag run id",
81
+ },
82
+ {
83
+ name: "featureId",
84
+ type: "string",
85
+ required: false,
86
+ description: "feature id",
87
+ },
88
+ ],
89
+ statsContext: [],
90
+ dagReport: [
91
+ {
92
+ name: "runId",
93
+ type: "string",
94
+ required: true,
95
+ description: "dag run id",
96
+ },
97
+ ],
98
+ status: [
99
+ { name: "taskId", type: "string", required: true, description: "task id" },
100
+ ],
101
+ spineCheck: [
102
+ { name: "taskId", type: "string", required: true, description: "task id" },
103
+ ],
104
+ dagValidate: [
105
+ {
106
+ name: "dagPath",
107
+ type: "string",
108
+ required: true,
109
+ description: "path to DAG json",
110
+ },
111
+ ],
112
+ dagRerunPlan: [
113
+ {
114
+ name: "runId",
115
+ type: "string",
116
+ required: true,
117
+ description: "dag run id",
118
+ },
119
+ {
120
+ name: "fromNode",
121
+ type: "string",
122
+ required: true,
123
+ description: "node id to rerun from",
124
+ },
125
+ ],
126
+ // high-risk mutation — formerly human-confirmation-only
127
+ importPrd: [
128
+ { name: "taskId", type: "string", required: true, description: "task id" },
129
+ { name: "content", type: "string", required: true, description: "PRD markdown content" },
130
+ ],
131
+ contractApply: [
132
+ { name: "taskId", type: "string", required: true, description: "task id" },
133
+ { name: "assessmentId", type: "string", required: false, description: "assessment id binding (defaults to the draft's bound assessment)" },
134
+ { name: "expectedRevision", type: "string", required: false, description: "expected contract revision (defaults to live observed)" },
135
+ { name: "expectedObservedHash", type: "string", required: false, description: "expected observed hash (defaults to live observed)" },
136
+ ],
137
+ confirmDagConfirmation: [
138
+ { name: "confirmationId", type: "string", required: true, description: "confirmation id from prepareDagConfirmation" },
139
+ { name: "challenges", type: "array", required: true, itemsType: "string", description: "all challenge answers" },
140
+ ],
141
+ workerTaskRetry: [
142
+ { name: "taskId", type: "string", required: true, description: "task id" },
143
+ { name: "featureId", type: "string", required: false, description: "feature id (canonical retry carries Feature identity; passed to the CLI as --feature-id when present)" },
144
+ { name: "reason", type: "string", required: true, description: "retry reason" },
145
+ ],
146
+ // long-running execution — formerly human-confirmation-only
147
+ dagRunTask: [
148
+ { name: "taskId", type: "string", required: true, description: "task id to run as a DAG" },
149
+ { name: "profile", type: "string", required: false, description: "DAG profile (defaults to auto)" },
150
+ ],
151
+ runDag: [
152
+ { name: "confirmationId", type: "string", required: true, description: "confirmation id (from prepareDagConfirmation) — raw dag path is never accepted from the model" },
153
+ ],
154
+ dagRerun: [
155
+ { name: "runId", type: "string", required: true, description: "dag run id" },
156
+ { name: "fromNode", type: "string", required: true, description: "node id to rerun from" },
157
+ { name: "planHash", type: "string", required: true, description: "rerun plan hash" },
158
+ { name: "reason", type: "string", required: true, description: "rerun reason" },
159
+ ],
160
+ standaloneTaskRerun: [
161
+ { name: "runId", type: "string", required: true, description: "dag run id" },
162
+ { name: "reason", type: "string", required: true, description: "rerun reason" },
163
+ ],
164
+ newTask: [
165
+ { name: "taskId", type: "string", required: true, description: "task id" },
166
+ { name: "title", type: "string", required: false, description: "task title (defaults to taskId)" },
167
+ ],
168
+ contractDraftSave: [
169
+ { name: "taskId", type: "string", required: false, description: "task id (defaults to draft.taskId)" },
170
+ {
171
+ name: "draft",
172
+ type: "object",
173
+ required: true,
174
+ description: "TaskContractDraftV1 object",
175
+ },
176
+ ],
177
+ };
178
+ /**
179
+ * Build the full Chat tool schema set from the capabilities registry.
180
+ *
181
+ * FAIL-CLOSED dynamic discovery: every whitelisted action MUST have an entry
182
+ * in INPUT_PARAMS. An action present in the registry (and thus allowed by the
183
+ * dynamic allowlist) but missing an INPUT_PARAMS entry is a CONTRACT GAP — it
184
+ * means we don't know which params its dispatcher reads, so exposing it as a
185
+ * zero-param tool would be a silent schema degradation (the model could call
186
+ * it but couldn't supply required fields). Instead we throw at boot so the
187
+ * gap is caught in tests and never ships. Diagnostics list the offending
188
+ * actions so adding a registry action is a one-line INPUT_PARAMS update.
189
+ */
190
+ export function buildOperatorChatToolSchemas() {
191
+ const doc = buildOperatorCapabilitiesDocument();
192
+ const out = [];
193
+ const unschemaed = [];
194
+ for (const action of doc.actions) {
195
+ const decision = authorizeOperatorChatTool(action.action);
196
+ if (!decision.ok)
197
+ continue;
198
+ const params = INPUT_PARAMS[action.action];
199
+ if (!params) {
200
+ // Fail-closed: an allowed action without a schema entry is a contract
201
+ // gap, not a zero-param tool. Collect then throw so boot/red-team tests
202
+ // surface it loudly.
203
+ unschemaed.push(action.action);
204
+ continue;
205
+ }
206
+ out.push({
207
+ toolId: decision.toolId,
208
+ action: action.action,
209
+ description: action.description,
210
+ cli: action.cli,
211
+ kind: action.kind,
212
+ inputParams: params,
213
+ });
214
+ }
215
+ if (unschemaed.length > 0) {
216
+ throw new Error(`Operator Chat tool schema gap: allowed operator actions lack an INPUT_PARAMS entry: ${unschemaed.sort().join(", ")}. Add each to INPUT_PARAMS in tool-adapter.ts (must match the dispatcher's paramsOf reads).`);
217
+ }
218
+ return out;
219
+ }
220
+ /**
221
+ * Verify the adapter covers exactly the allowlist and nothing more.
222
+ * Used by boot/red-team checks (V15 — the surface equals the registry).
223
+ */
224
+ export function verifyOperatorChatToolSurface() {
225
+ const schemas = buildOperatorChatToolSchemas();
226
+ const covered = schemas.map((s) => s.action).sort();
227
+ const expected = [...OPERATOR_CHAT_ALLOWED_TOOLS].sort();
228
+ const coveredSet = new Set(covered);
229
+ const expectedSet = new Set(expected);
230
+ const missing = expected.filter((a) => !coveredSet.has(a));
231
+ const extra = covered.filter((a) => !expectedSet.has(a));
232
+ return {
233
+ ok: missing.length === 0 && extra.length === 0,
234
+ covered,
235
+ missing,
236
+ extra,
237
+ };
238
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Operator Chat — tool whitelist (design §7.5 / plan D3, widened 2026-07-25).
3
+ *
4
+ * The General Operator Chat exposes the loop-agent runtime's FULL structured
5
+ * operator surface as Pi tools, plus the built-in read/explore tools
6
+ * (bash / read / grep / find / ls) so the model can probe the repo. It still
7
+ * forbids file-WRITING coding tools (edit / write / apply_patch / full-tools /
8
+ * shell / coding-chat) — those can never be activated.
9
+ *
10
+ * DYNAMIC DISCOVERY: the allowed operator-action list is derived directly from
11
+ * the capabilities registry (`buildOperatorCapabilitiesDocument()`), NOT
12
+ * hard-coded. When runtime adds a new operator action to the registry, it
13
+ * appears here automatically — zero code sync, and the previous
14
+ * "whitelist/registry drift" risk (old plan D3 W5 constraint) is eliminated.
15
+ * The only hand-maintained list is the DENY list of file-writing tools.
16
+ *
17
+ * bash is ALLOWED but bare (no command filter). The residual risk that bash
18
+ * can still write files via `echo >` / `tee` / `sed -i` is accepted by the
19
+ * owner and documented in the exec-plan Open Questions Q9. A future
20
+ * command-filter layer can close that gap without changing this contract.
21
+ */
22
+ import { buildOperatorCapabilitiesDocument } from "../../../shared/operator/capabilities.js";
23
+ import { OFFICIAL_DENIED_TOOL_IDS } from "../resource-loader.js";
24
+ /**
25
+ * All operator actions registered in the capabilities registry are exposed as
26
+ * Chat tools. Derived dynamically so new actions appear automatically.
27
+ */
28
+ export const OPERATOR_CHAT_ALLOWED_TOOLS = Object.freeze(buildOperatorCapabilitiesDocument().actions.map((a) => a.action));
29
+ /**
30
+ * Operator actions denied by the legacy MVP boundary. Kept EMPTY since the
31
+ * owner decided (2026-07-25) that the full operator surface is Chat-callable.
32
+ * Retained (empty) so downstream guards referencing it keep compiling and
33
+ * stay defensive against future additions.
34
+ */
35
+ export const OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS = Object.freeze([]);
36
+ // Coding / generic tool ids that must never appear in a Chat session.
37
+ // File-WRITING coding tools are forbidden (edit/write/apply_patch/full-tools/
38
+ // coding-chat/shell). bash / read / grep / find / ls are intentionally
39
+ // ALLOWED (owner decision 2026-07-25 — the Chat must be able to probe the
40
+ // repo and run read-only commands). We therefore build the deny list from
41
+ // OFFICIAL_DENIED_TOOL_IDS and then explicitly UN-deny BOTH the explore
42
+ // tools AND the high-risk operator actions (contractApply / runDag / dagRerun
43
+ // / etc. are now Chat-callable per the widening — OFFICIAL_DENIED_TOOL_IDS
44
+ // listed them as a defense against accidental activation in the Interview
45
+ // flow, but the Chat deliberately exposes the full operator surface).
46
+ const _EXPLORE_TOOLS = new Set(["bash", "read", "grep", "find", "ls"]);
47
+ // Operator actions that OFFICIAL_DENIED_TOOL_IDS carries for Interview safety
48
+ // but that the Chat now deliberately allows. We compare on a COMPACTED form
49
+ // (strip - and _) because OFFICIAL lists both camelCase (`contractApply`) and
50
+ // snake_case (`contract_apply`) variants, and authorize() also normalizes —
51
+ // leaving any variant in would re-deny the action via normalization.
52
+ const _ALLOWED_OPERATOR_ACTIONS_COMPACT = new Set(OPERATOR_CHAT_ALLOWED_TOOLS.map((t) => t.toLowerCase().replace(/[-_]/g, "")));
53
+ export const OPERATOR_CHAT_DENIED_TOOLS = Object.freeze(OFFICIAL_DENIED_TOOL_IDS.filter((id) => {
54
+ const lower = id.toLowerCase();
55
+ if (_EXPLORE_TOOLS.has(lower))
56
+ return false;
57
+ if (_ALLOWED_OPERATOR_ACTIONS_COMPACT.has(lower.replace(/[-_]/g, "")))
58
+ return false;
59
+ return true;
60
+ }));
61
+ const ALLOWED = new Set(OPERATOR_CHAT_ALLOWED_TOOLS.map((t) => t.toLowerCase()));
62
+ const DENIED_ACTIONS = new Set(OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS.map((a) => a.toLowerCase()));
63
+ const DENIED_TOOLS = new Set(OPERATOR_CHAT_DENIED_TOOLS.map((t) => t.toLowerCase()));
64
+ export function isOperatorChatToolAllowed(toolId) {
65
+ return ALLOWED.has(toolId.trim().toLowerCase());
66
+ }
67
+ export function isOperatorChatToolDenied(toolId) {
68
+ const id = toolId.trim().toLowerCase();
69
+ if (DENIED_TOOLS.has(id))
70
+ return true;
71
+ if (DENIED_ACTIONS.has(id))
72
+ return true;
73
+ // normalize snake/camel/kebab
74
+ const compact = id.replace(/[-_]/g, "");
75
+ for (const d of DENIED_TOOLS) {
76
+ if (d.replace(/[-_]/g, "") === compact)
77
+ return true;
78
+ }
79
+ for (const d of DENIED_ACTIONS) {
80
+ if (d.replace(/[-_]/g, "") === compact)
81
+ return true;
82
+ }
83
+ return false;
84
+ }
85
+ /**
86
+ * Three-gate authorization (design §7.5): session create / setActiveTools /
87
+ * per-tool-call. Server-side only — UI hiding is insufficient.
88
+ */
89
+ export function authorizeOperatorChatTool(toolId) {
90
+ const id = toolId.trim();
91
+ if (!id) {
92
+ return {
93
+ ok: false,
94
+ code: "tool-unknown",
95
+ toolId: id,
96
+ message: "empty tool id is not allowed in General Operator Chat",
97
+ };
98
+ }
99
+ if (isOperatorChatToolDenied(id)) {
100
+ return {
101
+ ok: false,
102
+ code: "tool-denied",
103
+ toolId: id,
104
+ message: `tool "${id}" is denied in General Operator Chat (no edit/write/apply_patch/full-tools/shell/coding-chat)`,
105
+ };
106
+ }
107
+ if (!isOperatorChatToolAllowed(id)) {
108
+ return {
109
+ ok: false,
110
+ code: "tool-unknown",
111
+ toolId: id,
112
+ message: `tool "${id}" is not registered for General Operator Chat (new operator actions default to deny)`,
113
+ };
114
+ }
115
+ return { ok: true, toolId: id };
116
+ }
117
+ /**
118
+ * Filter a requested tool list against the Chat whitelist.
119
+ * Returns allowed + denied (for diagnostics / red-team evidence).
120
+ */
121
+ export function filterOperatorChatTools(requested) {
122
+ const allowed = [];
123
+ const denied = [];
124
+ for (const toolId of requested) {
125
+ const decision = authorizeOperatorChatTool(toolId);
126
+ if (decision.ok)
127
+ allowed.push(decision.toolId);
128
+ else
129
+ denied.push(toolId);
130
+ }
131
+ return { allowed, denied };
132
+ }
133
+ /**
134
+ * Assert that a full requested tool set contains NO file-writing coding tool.
135
+ * Used by red-team / boot checks (V12 — closed coding surface). Note: bash is
136
+ * intentionally ALLOWED since the 2026-07-25 widening (owner decision); this
137
+ * guard now only blocks tools whose PRIMARY purpose is editing files
138
+ * (edit/write/apply_patch/full-tools/shell). The residual write-via-bash risk
139
+ * (`echo >` / `tee`) is documented in exec-plan Open Questions Q9.
140
+ */
141
+ export function assertNoWriteToolInList(requested) {
142
+ const writeVariants = [
143
+ "edit",
144
+ "write",
145
+ "apply_patch",
146
+ "apply-patch",
147
+ "full-tools",
148
+ "full_tools",
149
+ "coding-chat",
150
+ "coding_chat",
151
+ "shell",
152
+ ];
153
+ const lower = new Set(requested.map((t) => t.trim().toLowerCase()));
154
+ for (const v of writeVariants) {
155
+ if (lower.has(v)) {
156
+ throw new Error(`file-writing tool "${v}" must not appear in Operator Chat tool list (V12 closed coding surface)`);
157
+ }
158
+ }
159
+ }
160
+ /**
161
+ * Back-compat alias kept for callers that still reference the old name. Now
162
+ * asserts absence of file-writing tools (bash is allowed post-widening).
163
+ * @deprecated use {@link assertNoWriteToolInList}.
164
+ */
165
+ export function assertNoBashInToolList(requested) {
166
+ assertNoWriteToolInList(requested);
167
+ }
168
+ /** Canonical sorted tool list to hand to setActiveToolsByName. */
169
+ export function canonicalOperatorChatToolList() {
170
+ return [...OPERATOR_CHAT_ALLOWED_TOOLS].sort();
171
+ }
@@ -10,6 +10,9 @@ import { DraftStore } from "./draft-store.js";
10
10
  import { AssessmentStore } from "./interview/assessment.js";
11
11
  import { InterviewSessionStore } from "./interview/session.js";
12
12
  import { isLoopbackHost } from "./loopback.js";
13
+ import { ChatSessionStore } from "./chat/session-store.js";
14
+ import { ConsolePiRuntime } from "./chat/pi-runtime.js";
15
+ import { handleChatRequest } from "./chat/routes.js";
13
16
  import { createOperationEventStore, } from "./operation-sse.js";
14
17
  import { OperationStore } from "./operation-store.js";
15
18
  import { probePiReadiness, } from "./pi-readiness.js";
@@ -99,6 +102,30 @@ export async function createConsoleServer(options) {
99
102
  let boundHost = host;
100
103
  let boundPort = port;
101
104
  const getConsoleOrigin = () => `http://${boundHost}:${boundPort}`;
105
+ // Phase 4: General Operator Chat runtime. The actionContext is forwarded so
106
+ // custom-tool execute() calls dispatch into the operator action layer (Gate 3).
107
+ // Chat session dir is dedicated (app-data/chat-sessions), NOT user default.
108
+ const chatStore = new ChatSessionStore(appData);
109
+ const skillsDir = resolveLoopAgentSkillsDir(repoRoot);
110
+ const chatRuntime = new ConsolePiRuntime({
111
+ cwd: repoRoot,
112
+ skillsDir,
113
+ sessionDir: appData.chatSessions,
114
+ actionContext,
115
+ });
116
+ // chatRouteDeps is a plain object literal — it cannot throw. The previous
117
+ // try/catch was dead code that created a false "handled failure" impression.
118
+ // If a future field can throw, scope it explicitly instead of wrapping the
119
+ // literal.
120
+ const chatRouteDeps = {
121
+ runtime: chatRuntime,
122
+ store: chatStore,
123
+ appData,
124
+ bootToken,
125
+ getConsoleOrigin,
126
+ allowNonLoopbackAccess: !loopbackBind,
127
+ skillsDir,
128
+ };
102
129
  const routeContext = {
103
130
  repoRoot,
104
131
  staticDir,
@@ -114,6 +141,7 @@ export async function createConsoleServer(options) {
114
141
  operations,
115
142
  events,
116
143
  getReadiness,
144
+ chat: chatRouteDeps,
117
145
  },
118
146
  };
119
147
  return new Promise((resolve, reject) => {
@@ -173,6 +201,11 @@ export async function createConsoleServer(options) {
173
201
  }
174
202
  if (pathname.startsWith("/api/operator/v1/") ||
175
203
  pathname.startsWith("/api/session/")) {
204
+ // Phase 4: General Operator Chat routes (mounted under chat/).
205
+ if (pathname.startsWith("/api/operator/v1/chat/")) {
206
+ await handleChatRequest(req, res, chatRouteDeps, pathname);
207
+ return;
208
+ }
176
209
  await handleConsoleOperatorRequest(req, res, routeContext);
177
210
  return;
178
211
  }
@@ -221,12 +254,34 @@ export async function createConsoleServer(options) {
221
254
  getReadiness,
222
255
  operatorSessionId,
223
256
  close: () => new Promise((closeResolve, closeReject) => {
257
+ // Dispose all Chat Pi sessions BEFORE closing the HTTP server so
258
+ // no prompt is left executing and no session/SDK state/file handle
259
+ // leaks across Console restarts (regression: close() previously
260
+ // only closed the socket, leaving ConsolePiRuntime sessions live).
261
+ try {
262
+ chatRuntime.disposeAll();
263
+ }
264
+ catch (error) {
265
+ // Best-effort: surface but do not block the HTTP close.
266
+ process.stderr.write(`[console] chatRuntime.disposeAll() failed during close: ${error instanceof Error ? error.message : String(error)}\n`);
267
+ }
224
268
  server.close((err) => (err ? closeReject(err) : closeResolve()));
225
269
  }),
226
270
  });
227
271
  });
228
272
  });
229
273
  }
274
+ /** Resolve the loop-agent repo skills/ directory (source repo or npm pack asset). */
275
+ function resolveLoopAgentSkillsDir(repoRoot) {
276
+ // When running from dist (npm pack), skills/ is a sibling of dist/ at the
277
+ // package root. When running from source (tsx), it's the repo root.
278
+ const fromDist = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "skills");
279
+ const fromRepo = path.resolve(repoRoot, "skills");
280
+ // Prefer whichever exists; dist path wins if present (npm-installed runtime).
281
+ if (existsSync(fromDist))
282
+ return fromDist;
283
+ return fromRepo;
284
+ }
230
285
  function requestSource(req) {
231
286
  const forwarded = req.headers["x-forwarded-for"];
232
287
  if (typeof forwarded === "string" && forwarded.trim()) {