@tea-agent/loop-agent 0.23.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +1 -1
  3. package/bin/agent-worker.js +0 -0
  4. package/dist/executors/shell-executor.js +20 -7
  5. package/dist/shared/operator/capabilities.js +475 -2
  6. package/dist/worker/console/app-data.js +2 -0
  7. package/dist/worker/console/chat/artifact-card.js +23 -0
  8. package/dist/worker/console/chat/chat-event-store.js +495 -0
  9. package/dist/worker/console/chat/chat-ui-policy.js +25 -0
  10. package/dist/worker/console/chat/composer-draft-store.js +45 -0
  11. package/dist/worker/console/chat/context-panel.js +54 -0
  12. package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
  13. package/dist/worker/console/chat/explore-tools.js +299 -0
  14. package/dist/worker/console/chat/human-gate-card.js +37 -0
  15. package/dist/worker/console/chat/interview-adapter.js +136 -0
  16. package/dist/worker/console/chat/operation-card.js +23 -0
  17. package/dist/worker/console/chat/pi-console-config.js +158 -0
  18. package/dist/worker/console/chat/pi-runtime.js +581 -43
  19. package/dist/worker/console/chat/repo-browser.js +140 -0
  20. package/dist/worker/console/chat/repo-walk.js +116 -0
  21. package/dist/worker/console/chat/resource-loader.js +18 -17
  22. package/dist/worker/console/chat/routes.js +1354 -65
  23. package/dist/worker/console/chat/runtime-context.js +24 -0
  24. package/dist/worker/console/chat/runtime-selection.js +37 -0
  25. package/dist/worker/console/chat/session-store.js +210 -11
  26. package/dist/worker/console/chat/shortcuts.js +15 -0
  27. package/dist/worker/console/chat/tool-adapter.js +81 -194
  28. package/dist/worker/console/chat/tools.js +72 -48
  29. package/dist/worker/console/chat/usage.js +37 -0
  30. package/dist/worker/console/chat/workspace-landing.js +56 -0
  31. package/dist/worker/console/dag-confirmation.js +42 -8
  32. package/dist/worker/console/human-gate-token.js +130 -0
  33. package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
  34. package/dist/worker/console/operation-runner.js +6 -2
  35. package/dist/worker/console/operation-sse.js +26 -0
  36. package/dist/worker/console/operator-actions.js +420 -7
  37. package/dist/worker/console/server.js +14 -2
  38. package/dist/worker/console/static/assets/index-BTbrEHnO.css +1 -0
  39. package/dist/worker/console/static/assets/index-D9qLevoP.js +27 -0
  40. package/dist/worker/console/static/index.html +2 -2
  41. package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -5
  42. package/dist/workflows/dag/backend-test-result-contract.js +229 -0
  43. package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
  44. package/dist/workflows/dag/init-hybrid.js +2 -1
  45. package/docs/README.md +1 -1
  46. package/docs/architecture/README.md +5 -5
  47. package/docs/architecture/evolution.md +4 -4
  48. package/docs/architecture/worker-and-feature.md +1 -1
  49. package/docs/templates/backend-test-dag.json +2 -2
  50. package/harness.json +1 -1
  51. package/package.json +1 -1
  52. package/dist/worker/console/static/assets/index-DVl7Jxt5.js +0 -25
  53. package/dist/worker/console/static/assets/index-lVcIr9Ju.css +0 -1
@@ -1,230 +1,117 @@
1
1
  /**
2
- * Operator Chat — operator action → Pi tool schema adapter (plan W5 / W12).
2
+ * Operator Chat — operator action → Pi tool schema adapter (roadmap M0-C).
3
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)
4
+ * The capabilities registry (src/shared/operator/capabilities.ts) is the
5
+ * SINGLE source of truth for each action's typed input schema AND its model
6
+ * policy (modelCallable / humanConfirmation). This adapter renders that
7
+ * registry into the descriptor shape the Pi model invokes; it no longer
8
+ * maintains a second hand-written INPUT_PARAMS table (that drift risk is gone).
11
9
  *
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.
10
+ * High-risk / long-running actions ARE mapped as tools (2026-07-25 widening),
11
+ * but the registry now declares their modelCallable policy:
12
+ * - "always": model can invoke; dispatch runs server-side.
13
+ * - "prepare-only": model can invoke but the mutation needs a human-origin
14
+ * confirmation (e.g. runDag needs a confirmationId consumed by a human
15
+ * confirm via the browser mutation gate — M0-B).
16
+ * - "never": the action is the human confirmation itself (confirmDagConfirmation)
17
+ * and MUST NOT appear as a model-callable tool (M0-B / roadmap G03).
14
18
  *
15
19
  * The adapter does NOT register tools with the SDK directly — that is the
16
20
  * session layer's job (chat/pi-runtime.ts). This keeps the adapter pure and
17
21
  * unit-testable against the registry.
18
22
  */
19
23
  import { buildOperatorCapabilitiesDocument } from "../../../shared/operator/capabilities.js";
20
- import { OPERATOR_CHAT_ALLOWED_TOOLS, authorizeOperatorChatTool, } from "./tools.js";
21
24
  /**
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.
25
+ * Executable mutation faces reserved for browser HumanGate routes.
28
26
  *
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.
27
+ * Derived from the capabilities registry: any action that still requires human
28
+ * confirmation (and is not itself the confirm step with modelCallable=never)
29
+ * must NOT be registered as a model-executable tool. The model may only call
30
+ * prepare helpers (prepareDagConfirmation / prepareMutationGate); the browser
31
+ * mutation gate + HMAC token perform the consuming dispatch.
32
+ *
33
+ * `contractApply` is also gate-only even though its registry face remains
34
+ * `humanConfirmation: "conditional"` (assessment soft-gate for workspace UI);
35
+ * Chat executes it only via the contract-apply Human Gate route.
34
36
  */
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
- };
37
+ const EXPLICIT_EXECUTABLE_GATE_ONLY = ["contractApply"];
38
+ export function listExecutableGateOnlyActions() {
39
+ const derived = buildOperatorCapabilitiesDocument()
40
+ .actions.filter((action) => action.humanConfirmation === "required" &&
41
+ action.modelCallable !== "never")
42
+ .map((action) => action.action);
43
+ return [...new Set([...EXPLICIT_EXECUTABLE_GATE_ONLY, ...derived])].sort();
44
+ }
45
+ /** Frozen snapshot for tests / drift checks (recomputed from registry). */
46
+ export const EXECUTABLE_GATE_ONLY_ACTIONS = Object.freeze(listExecutableGateOnlyActions());
47
+ const EXECUTABLE_GATE_ONLY = new Set(EXECUTABLE_GATE_ONLY_ACTIONS);
178
48
  /**
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.
49
+ * Build the FULL Chat tool schema set from the capabilities registry — every
50
+ * registry action, INCLUDING modelCallable="never" ones (e.g.
51
+ * confirmDagConfirmation). Used by internal audits / capability surfaces that
52
+ * need to see the complete registry. The session layer uses
53
+ * buildModelCallableToolSchemas() to render only model-callable tools.
189
54
  */
190
55
  export function buildOperatorChatToolSchemas() {
191
56
  const doc = buildOperatorCapabilitiesDocument();
192
57
  const out = [];
193
- const unschemaed = [];
194
58
  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;
59
+ // Fail-closed: every registry action MUST carry inputParams. An action
60
+ // missing inputParams is a contract gap (the model could call it but we
61
+ // don't know which params its dispatcher reads). Throw at boot so the gap
62
+ // is caught in tests and never ships.
63
+ if (!Array.isArray(action.inputParams)) {
64
+ throw new Error(`Operator Chat tool schema gap: registry action "${action.action}" has no inputParams. Add inputParams (must match the dispatcher's paramsOf reads) in src/shared/operator/capabilities.ts.`);
205
65
  }
206
66
  out.push({
207
- toolId: decision.toolId,
67
+ toolId: action.action,
208
68
  action: action.action,
209
69
  description: action.description,
210
70
  cli: action.cli,
211
71
  kind: action.kind,
212
- inputParams: params,
72
+ inputParams: action.inputParams.map((p) => ({
73
+ name: p.name,
74
+ type: p.type,
75
+ required: p.required,
76
+ description: p.description,
77
+ ...(p.itemsType ? { itemsType: p.itemsType } : {}),
78
+ })),
79
+ modelCallable: action.modelCallable,
80
+ humanConfirmation: action.humanConfirmation,
81
+ ...(action.resultPolicy ? { resultPolicy: action.resultPolicy } : {}),
213
82
  });
214
83
  }
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
84
  return out;
219
85
  }
220
86
  /**
221
- * Verify the adapter covers exactly the allowlist and nothing more.
222
- * Used by boot/red-team checks (V15 the surface equals the registry).
87
+ * Build ONLY the model-callable tool schemas i.e. exclude
88
+ * modelCallable="never" actions (roadmap M0-B / G03). This is the set the Pi
89
+ * session actually registers as custom tools: the human confirmation action
90
+ * (confirmDagConfirmation) is NOT a model tool; it can only be invoked by the
91
+ * browser mutation gate with a server-signed confirmation token.
92
+ */
93
+ export function buildModelCallableToolSchemas() {
94
+ return buildOperatorChatToolSchemas().filter((s) => s.modelCallable !== "never" && !EXECUTABLE_GATE_ONLY.has(s.action));
95
+ }
96
+ /**
97
+ * Names of actions that MUST NOT be model-callable (roadmap G03 / G14).
98
+ * Derived from the registry so adding a "never" policy there is the only edit
99
+ * needed. Used by the three-gate tool whitelist to deny model invocation.
100
+ */
101
+ export function modelForbiddenActions() {
102
+ return buildOperatorChatToolSchemas()
103
+ .filter((s) => s.modelCallable === "never" || EXECUTABLE_GATE_ONLY.has(s.action))
104
+ .map((s) => s.action);
105
+ }
106
+ /**
107
+ * Verify the adapter covers exactly the registry and nothing more.
108
+ * Used by boot/red-team checks (V15 — the full registry is schema'd).
223
109
  */
224
110
  export function verifyOperatorChatToolSurface() {
225
111
  const schemas = buildOperatorChatToolSchemas();
112
+ const doc = buildOperatorCapabilitiesDocument();
226
113
  const covered = schemas.map((s) => s.action).sort();
227
- const expected = [...OPERATOR_CHAT_ALLOWED_TOOLS].sort();
114
+ const expected = doc.actions.map((a) => a.action).sort();
228
115
  const coveredSet = new Set(covered);
229
116
  const expectedSet = new Set(expected);
230
117
  const missing = expected.filter((a) => !coveredSet.has(a));
@@ -1,31 +1,39 @@
1
1
  /**
2
- * Operator Chat — tool whitelist (design §7.5 / plan D3, widened 2026-07-25).
2
+ * Operator Chat — closed tool whitelist (design §7.5 / roadmap M0).
3
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.
4
+ * The Chat exposes the model-callable structured operator surface. Repository
5
+ * exploration is deliberately limited to safe-read/safe-grep/git-status/
6
+ * git-diff plus built-in find/ls; the bare SDK bash/read/grep tools are denied
7
+ * because they bypass the M0-A write and sensitive-data boundaries.
9
8
  *
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.
9
+ * Dynamic discovery derives model-callable operator actions directly from the
10
+ * capabilities registry. Explicit denial remains the defense-in-depth layer:
11
+ * a future resource-loader or SDK drift cannot reactivate removed coding or
12
+ * explore tools merely by changing the active-tool list.
21
13
  */
22
14
  import { buildOperatorCapabilitiesDocument } from "../../../shared/operator/capabilities.js";
23
15
  import { OFFICIAL_DENIED_TOOL_IDS } from "../resource-loader.js";
16
+ import { EXECUTABLE_GATE_ONLY_ACTIONS } from "./tool-adapter.js";
17
+ const EXECUTABLE_GATE_ONLY = new Set(EXECUTABLE_GATE_ONLY_ACTIONS);
18
+ /**
19
+ * Actions the model MAY invoke. Derived from the registry but filtered by the
20
+ * modelCallable policy: modelCallable="never" actions (e.g. confirmDagConfirmation)
21
+ * are the human confirmation itself and MUST NOT be model-callable (roadmap
22
+ * M0-B / G03). They are never registered as Pi tools AND denied at the gate.
23
+ */
24
+ export const OPERATOR_CHAT_ALLOWED_TOOLS = Object.freeze(buildOperatorCapabilitiesDocument()
25
+ .actions.filter((a) => a.modelCallable !== "never" && !EXECUTABLE_GATE_ONLY.has(a.action))
26
+ .map((a) => a.action));
24
27
  /**
25
- * All operator actions registered in the capabilities registry are exposed as
26
- * Chat tools. Derived dynamically so new actions appear automatically.
28
+ * Operator actions that MUST NOT be model-callable (roadmap M0-B / G03).
29
+ * Derived from the registry's modelCallable="never" policy. These are the
30
+ * human confirmation actions (confirmDagConfirmation) — the model can only
31
+ * prepare them; the browser mutation gate executes the confirm with a
32
+ * server-signed confirmation token.
27
33
  */
28
- export const OPERATOR_CHAT_ALLOWED_TOOLS = Object.freeze(buildOperatorCapabilitiesDocument().actions.map((a) => a.action));
34
+ export const OPERATOR_CHAT_MODEL_FORBIDDEN_ACTIONS = Object.freeze(buildOperatorCapabilitiesDocument()
35
+ .actions.filter((a) => a.modelCallable === "never" || EXECUTABLE_GATE_ONLY.has(a.action))
36
+ .map((a) => a.action));
29
37
  /**
30
38
  * Operator actions denied by the legacy MVP boundary. Kept EMPTY since the
31
39
  * owner decided (2026-07-25) that the full operator surface is Chat-callable.
@@ -33,31 +41,40 @@ export const OPERATOR_CHAT_ALLOWED_TOOLS = Object.freeze(buildOperatorCapabiliti
33
41
  * stay defensive against future additions.
34
42
  */
35
43
  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"]);
44
+ // Coding / generic tool ids that must never appear in a Chat session. M0-A
45
+ // removes bare bash (write-via-redirect) and SDK read/grep (no sensitive-file
46
+ // boundary); find/ls remain the only SDK explore builtins. Safe content probes
47
+ // are custom tools, not generic SDK tools.
48
+ const _ALLOWED_SDK_EXPLORE_TOOLS = new Set(["find", "ls"]);
49
+ const _REMOVED_SDK_EXPLORE_TOOLS = ["bash", "read", "grep"];
47
50
  // Operator actions that OFFICIAL_DENIED_TOOL_IDS carries for Interview safety
48
51
  // but that the Chat now deliberately allows. We compare on a COMPACTED form
49
52
  // (strip - and _) because OFFICIAL lists both camelCase (`contractApply`) and
50
53
  // snake_case (`contract_apply`) variants, and authorize() also normalizes —
51
54
  // leaving any variant in would re-deny the action via normalization.
52
55
  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
- }));
56
+ export const OPERATOR_CHAT_DENIED_TOOLS = Object.freeze([
57
+ ...new Set([
58
+ ...OFFICIAL_DENIED_TOOL_IDS.filter((id) => {
59
+ const lower = id.toLowerCase();
60
+ if (_ALLOWED_SDK_EXPLORE_TOOLS.has(lower))
61
+ return false;
62
+ if (_ALLOWED_OPERATOR_ACTIONS_COMPACT.has(lower.replace(/[-_]/g, ""))) {
63
+ return false;
64
+ }
65
+ return true;
66
+ }),
67
+ ..._REMOVED_SDK_EXPLORE_TOOLS,
68
+ ]),
69
+ ]);
70
+ /**
71
+ * Operator actions denied by model policy (modelCallable="never"). These are
72
+ * human-only actions (the confirmation itself) — derived from the registry so
73
+ * adding a "never" policy there is the only edit needed. Kept separate from
74
+ * OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS (legacy empty MVP boundary list) to
75
+ * preserve the two distinct concerns (model policy vs MVP mutation boundary).
76
+ */
77
+ const DENIED_MODEL_POLICY = new Set(OPERATOR_CHAT_MODEL_FORBIDDEN_ACTIONS.map((a) => a.toLowerCase()));
61
78
  const ALLOWED = new Set(OPERATOR_CHAT_ALLOWED_TOOLS.map((t) => t.toLowerCase()));
62
79
  const DENIED_ACTIONS = new Set(OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS.map((a) => a.toLowerCase()));
63
80
  const DENIED_TOOLS = new Set(OPERATOR_CHAT_DENIED_TOOLS.map((t) => t.toLowerCase()));
@@ -70,6 +87,9 @@ export function isOperatorChatToolDenied(toolId) {
70
87
  return true;
71
88
  if (DENIED_ACTIONS.has(id))
72
89
  return true;
90
+ // modelCallable="never" actions are always denied to the model (roadmap G03).
91
+ if (DENIED_MODEL_POLICY.has(id))
92
+ return true;
73
93
  // normalize snake/camel/kebab
74
94
  const compact = id.replace(/[-_]/g, "");
75
95
  for (const d of DENIED_TOOLS) {
@@ -80,6 +100,10 @@ export function isOperatorChatToolDenied(toolId) {
80
100
  if (d.replace(/[-_]/g, "") === compact)
81
101
  return true;
82
102
  }
103
+ for (const d of DENIED_MODEL_POLICY) {
104
+ if (d.replace(/[-_]/g, "") === compact)
105
+ return true;
106
+ }
83
107
  return false;
84
108
  }
85
109
  /**
@@ -101,7 +125,7 @@ export function authorizeOperatorChatTool(toolId) {
101
125
  ok: false,
102
126
  code: "tool-denied",
103
127
  toolId: id,
104
- message: `tool "${id}" is denied in General Operator Chat (no edit/write/apply_patch/full-tools/shell/coding-chat)`,
128
+ message: `tool "${id}" is denied in General Operator Chat (no bash/read/grep/edit/write/apply_patch/full-tools/shell/coding-chat)`,
105
129
  };
106
130
  }
107
131
  if (!isOperatorChatToolAllowed(id)) {
@@ -131,12 +155,9 @@ export function filterOperatorChatTools(requested) {
131
155
  return { allowed, denied };
132
156
  }
133
157
  /**
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.
158
+ * Assert that a requested tool set contains no prohibited coding or removed
159
+ * SDK explore tool. Used by red-team / boot checks (V12 / M0-A). bash is
160
+ * explicitly forbidden: accepting it would recreate the redirect write escape.
140
161
  */
141
162
  export function assertNoWriteToolInList(requested) {
142
163
  const writeVariants = [
@@ -149,6 +170,9 @@ export function assertNoWriteToolInList(requested) {
149
170
  "coding-chat",
150
171
  "coding_chat",
151
172
  "shell",
173
+ "bash",
174
+ "read",
175
+ "grep",
152
176
  ];
153
177
  const lower = new Set(requested.map((t) => t.trim().toLowerCase()));
154
178
  for (const v of writeVariants) {
@@ -158,8 +182,8 @@ export function assertNoWriteToolInList(requested) {
158
182
  }
159
183
  }
160
184
  /**
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).
185
+ * Back-compat alias kept for callers that still reference the old name. It
186
+ * also rejects removed SDK explore tools, including bash.
163
187
  * @deprecated use {@link assertNoWriteToolInList}.
164
188
  */
165
189
  export function assertNoBashInToolList(requested) {
@@ -0,0 +1,37 @@
1
+ function record(value) {
2
+ return value && typeof value === "object" ? value : undefined;
3
+ }
4
+ function numberAt(source, keys) {
5
+ for (const key of keys)
6
+ if (typeof source[key] === "number" && Number.isFinite(source[key]))
7
+ return Math.max(0, source[key]);
8
+ return undefined;
9
+ }
10
+ export function extractUsageSample(value) {
11
+ const event = record(value);
12
+ if (!event)
13
+ return undefined;
14
+ const message = record(event.message);
15
+ const source = record(event.usage) ?? record(message?.usage) ?? record(event.tokenUsage);
16
+ if (!source)
17
+ return undefined;
18
+ const inputTokens = numberAt(source, ["input_tokens", "inputTokens", "prompt_tokens", "promptTokens"]);
19
+ const outputTokens = numberAt(source, ["output_tokens", "outputTokens", "completion_tokens", "completionTokens"]);
20
+ const totalTokens = numberAt(source, ["total_tokens", "totalTokens"]) ?? (inputTokens !== undefined || outputTokens !== undefined ? (inputTokens ?? 0) + (outputTokens ?? 0) : undefined);
21
+ if (inputTokens === undefined && outputTokens === undefined && totalTokens === undefined)
22
+ return undefined;
23
+ const responseKey = [event.responseId, message?.id, source.responseId].find((item) => typeof item === "string");
24
+ return { inputTokens, outputTokens, totalTokens, ...(responseKey ? { responseKey } : {}) };
25
+ }
26
+ export function aggregateUsage(samples) {
27
+ const keyed = new Map();
28
+ const anonymous = [];
29
+ for (const sample of samples)
30
+ sample.responseKey ? keyed.set(sample.responseKey, sample) : anonymous.push(sample);
31
+ const selected = [...keyed.values(), ...anonymous];
32
+ return selected.reduce((sum, sample) => ({
33
+ inputTokens: sum.inputTokens + (sample.inputTokens ?? 0), outputTokens: sum.outputTokens + (sample.outputTokens ?? 0), totalTokens: sum.totalTokens + (sample.totalTokens ?? ((sample.inputTokens ?? 0) + (sample.outputTokens ?? 0))),
34
+ cost: sample.cost === undefined ? sum.cost : (sum.cost ?? 0) + sample.cost,
35
+ estimated: Boolean(sum.estimated || sample.estimated),
36
+ }), { inputTokens: 0, outputTokens: 0, totalTokens: 0 });
37
+ }
@@ -0,0 +1,56 @@
1
+ export const ACTIVE_CHAT_SESSION_STORAGE_KEY = "loop-console.operator-chat.active-session.v1";
2
+ export const DEFAULT_LANDING_STORAGE_KEY = "loop-console.operator-chat.default-landing.v1";
3
+ function configuredLanding(value) {
4
+ const normalized = value?.trim().toLowerCase();
5
+ if (normalized === "chat")
6
+ return "chat";
7
+ if (normalized === "tasks")
8
+ return "tasks";
9
+ return undefined;
10
+ }
11
+ function configuredWorkspace(value) {
12
+ const normalized = value?.trim().toLowerCase();
13
+ if (normalized === "chat" || normalized === "tasks" || normalized === "recovery")
14
+ return normalized;
15
+ return undefined;
16
+ }
17
+ function workspaceFromLocation(location) {
18
+ if (!location)
19
+ return undefined;
20
+ const query = new URLSearchParams(location.search);
21
+ const queryValue = configuredLanding(query.get("landing") ?? query.get("workspace"));
22
+ if (queryValue)
23
+ return queryValue;
24
+ const hash = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash;
25
+ return configuredLanding(new URLSearchParams(hash).get("workspace"));
26
+ }
27
+ export function resolveDefaultLanding(input) {
28
+ return workspaceFromLocation(input.location) ??
29
+ configuredLanding(input.storage?.getItem(DEFAULT_LANDING_STORAGE_KEY)) ??
30
+ configuredLanding(input.env) ??
31
+ "tasks";
32
+ }
33
+ export function buildWorkspaceHref(workspace, params = {}) {
34
+ const query = new URLSearchParams({ workspace });
35
+ for (const [key, value] of Object.entries(params)) {
36
+ if (value?.trim())
37
+ query.set(key, value.trim());
38
+ }
39
+ return `/?${query.toString()}`;
40
+ }
41
+ export function parseWorkspaceHref(location) {
42
+ const query = new URLSearchParams(location.search);
43
+ const hash = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash;
44
+ const hashQuery = new URLSearchParams(hash);
45
+ const workspace = configuredWorkspace(query.get("workspace") ?? query.get("landing") ?? hashQuery.get("workspace"));
46
+ if (!workspace)
47
+ return null;
48
+ const read = (key) => query.get(key)?.trim() || undefined;
49
+ return {
50
+ workspace,
51
+ ...(read("taskId") ? { taskId: read("taskId") } : {}),
52
+ ...(read("dagRunId") ? { dagRunId: read("dagRunId") } : {}),
53
+ ...(read("fromNodeId") ? { fromNodeId: read("fromNodeId") } : {}),
54
+ ...(read("featureId") ? { featureId: read("featureId") } : {}),
55
+ };
56
+ }