@tea-agent/loop-agent 0.28.13 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +44 -15
  3. package/dist/commands/client-recovery.js +56 -1
  4. package/dist/commands/init-upgrade.js +186 -21
  5. package/dist/executors/dag-pi-executor.js +49 -2
  6. package/dist/executors/shell-executor.js +135 -0
  7. package/dist/worker/console/app-data.js +132 -11
  8. package/dist/worker/console/chat/pi-runtime.js +24 -42
  9. package/dist/worker/console/chat/resource-loader.js +11 -20
  10. package/dist/worker/console/chat/routes.js +7 -8
  11. package/dist/worker/console/chat/runtime-context.js +1 -1
  12. package/dist/worker/console/chat/tools.js +67 -54
  13. package/dist/worker/console/operation-runner.js +15 -1
  14. package/dist/worker/console/operation-store.js +70 -49
  15. package/dist/worker/console/operator-actions.js +57 -1
  16. package/dist/worker/console/static/assets/index-Cwx-ZVEQ.js +29 -0
  17. package/dist/worker/console/static/favicon.svg +37 -0
  18. package/dist/worker/console/static/index.html +2 -1
  19. package/dist/workflows/dag/backend-test-scenario-param.js +846 -0
  20. package/dist/workflows/dag/backend-test-writer-completeness.js +418 -0
  21. package/dist/workflows/dag/init-hybrid.js +20 -6
  22. package/dist/workflows/dag/node-execution.js +31 -2
  23. package/dist/workflows/dag/retry-policy.js +55 -18
  24. package/dist/workflows/dag/types.js +1 -0
  25. package/dist/workflows/dag/validate.js +38 -3
  26. package/docs/operations/README.md +1 -1
  27. package/docs/templates/README.md +1 -1
  28. package/docs/templates/agent-dag.schema.json +3 -3
  29. package/docs/templates/backend-test-dag.json +36 -11
  30. package/docs/templates/init-managed-agents.md +1 -1
  31. package/harness.json +2 -2
  32. package/package.json +2 -1
  33. package/dist/worker/console/static/assets/index-BfRgtLF4.js +0 -29
@@ -1,15 +1,15 @@
1
1
  /**
2
- * Operator Chat — closed tool whitelist (design §7.5 / roadmap M0).
2
+ * Operator Chat — tool policy (ADR 0011 UX-first full Pi tools).
3
3
  *
4
- * The Chat exposes the model-callable structured operator surface. Repository
5
- * exploration is deliberately limited to safe-read/safe-grep/git-status/
6
- * git-diff plus built-in find/ls; the bare SDK bash/read/grep tools are denied
7
- * because they bypass the M0-A write and sensitive-data boundaries.
4
+ * Operator Chat registers and activates full Pi builtins
5
+ * (read/write/edit/bash/grep/find/ls) plus model-callable operator actions and
6
+ * optional safe-* explore custom tools. Discipline is primarily soft
7
+ * (system prompt: prefer DAG / Human Gate). Hard denies remain for non-Pi
8
+ * write channels (apply_patch / full-tools / shell / coding-chat) and for
9
+ * modelCallable="never" actions (e.g. confirmDagConfirmation).
8
10
  *
9
- * Dynamic discovery derives model-callable operator actions directly from the
10
- * capabilities registry. Explicit denial remains the defense-in-depth layer:
11
- * a future resource-loader or SDK drift cannot reactivate removed coding or
12
- * explore tools merely by changing the active-tool list.
11
+ * Interview / Official surfaces keep their own OFFICIAL_DENIED path; this
12
+ * module only filters Chat-side denial so Interview is not widened.
13
13
  */
14
14
  import { buildOperatorCapabilitiesDocument } from "../../../shared/operator/capabilities.js";
15
15
  import { OFFICIAL_DENIED_TOOL_IDS } from "../resource-loader.js";
@@ -41,31 +41,37 @@ export const OPERATOR_CHAT_MODEL_FORBIDDEN_ACTIONS = Object.freeze(buildOperator
41
41
  * stay defensive against future additions.
42
42
  */
43
43
  export const OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS = Object.freeze([]);
44
- // Coding / generic tool ids that must never appear in a Chat session. M0-A
45
- // removes bare bash (write-via-redirect) and SDK read/grep (no sensitive-file
46
- // boundary); find/ls remain the only SDK explore builtins. Safe content probes
47
- // are custom tools, not generic SDK tools.
48
- const _ALLOWED_SDK_EXPLORE_TOOLS = new Set(["find", "ls"]);
49
- const _REMOVED_SDK_EXPLORE_TOOLS = ["bash", "read", "grep"];
44
+ /** Full Pi builtin explore / coding tools admitted by ADR 0011. */
45
+ export const OPERATOR_CHAT_PI_BUILTIN_TOOLS = Object.freeze([
46
+ "read",
47
+ "write",
48
+ "edit",
49
+ "bash",
50
+ "grep",
51
+ "find",
52
+ "ls",
53
+ ]);
54
+ const _PI_BUILTIN_ALLOW = new Set(OPERATOR_CHAT_PI_BUILTIN_TOOLS.map((t) => t.toLowerCase()));
50
55
  // Operator actions that OFFICIAL_DENIED_TOOL_IDS carries for Interview safety
51
- // but that the Chat now deliberately allows. We compare on a COMPACTED form
52
- // (strip - and _) because OFFICIAL lists both camelCase (`contractApply`) and
53
- // snake_case (`contract_apply`) variants, and authorize() also normalizes —
54
- // leaving any variant in would re-deny the action via normalization.
56
+ // but that the Chat now deliberately allows. Compare on a COMPACTED form
57
+ // (strip - and _) because OFFICIAL lists both camelCase and snake_case.
55
58
  const _ALLOWED_OPERATOR_ACTIONS_COMPACT = new Set(OPERATOR_CHAT_ALLOWED_TOOLS.map((t) => t.toLowerCase().replace(/[-_]/g, "")));
59
+ /**
60
+ * Tools denied for Operator Chat. Built from OFFICIAL_DENIED minus:
61
+ * - Pi builtins allowed by ADR 0011
62
+ * - model-callable operator actions
63
+ * Does NOT re-add bash/read/grep/edit/write.
64
+ */
56
65
  export const OPERATOR_CHAT_DENIED_TOOLS = Object.freeze([
57
- ...new Set([
58
- ...OFFICIAL_DENIED_TOOL_IDS.filter((id) => {
59
- const lower = id.toLowerCase();
60
- if (_ALLOWED_SDK_EXPLORE_TOOLS.has(lower))
61
- return false;
62
- if (_ALLOWED_OPERATOR_ACTIONS_COMPACT.has(lower.replace(/[-_]/g, ""))) {
63
- return false;
64
- }
65
- return true;
66
- }),
67
- ..._REMOVED_SDK_EXPLORE_TOOLS,
68
- ]),
66
+ ...new Set(OFFICIAL_DENIED_TOOL_IDS.filter((id) => {
67
+ const lower = id.toLowerCase();
68
+ if (_PI_BUILTIN_ALLOW.has(lower))
69
+ return false;
70
+ if (_ALLOWED_OPERATOR_ACTIONS_COMPACT.has(lower.replace(/[-_]/g, ""))) {
71
+ return false;
72
+ }
73
+ return true;
74
+ })),
69
75
  ]);
70
76
  /**
71
77
  * Operator actions denied by model policy (modelCallable="never"). These are
@@ -78,11 +84,19 @@ const DENIED_MODEL_POLICY = new Set(OPERATOR_CHAT_MODEL_FORBIDDEN_ACTIONS.map((a
78
84
  const ALLOWED = new Set(OPERATOR_CHAT_ALLOWED_TOOLS.map((t) => t.toLowerCase()));
79
85
  const DENIED_ACTIONS = new Set(OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS.map((a) => a.toLowerCase()));
80
86
  const DENIED_TOOLS = new Set(OPERATOR_CHAT_DENIED_TOOLS.map((t) => t.toLowerCase()));
87
+ /** Pi builtins are authorized without being operator registry actions. */
88
+ const PI_BUILTIN_AUTHORIZED = new Set(OPERATOR_CHAT_PI_BUILTIN_TOOLS.map((t) => t.toLowerCase()));
81
89
  export function isOperatorChatToolAllowed(toolId) {
82
- return ALLOWED.has(toolId.trim().toLowerCase());
90
+ const id = toolId.trim().toLowerCase();
91
+ if (PI_BUILTIN_AUTHORIZED.has(id))
92
+ return true;
93
+ return ALLOWED.has(id);
83
94
  }
84
95
  export function isOperatorChatToolDenied(toolId) {
85
96
  const id = toolId.trim().toLowerCase();
97
+ // Pi builtins are never denied under ADR 0011.
98
+ if (PI_BUILTIN_AUTHORIZED.has(id))
99
+ return false;
86
100
  if (DENIED_TOOLS.has(id))
87
101
  return true;
88
102
  if (DENIED_ACTIONS.has(id))
@@ -125,7 +139,7 @@ export function authorizeOperatorChatTool(toolId) {
125
139
  ok: false,
126
140
  code: "tool-denied",
127
141
  toolId: id,
128
- message: `tool "${id}" is denied in General Operator Chat (no bash/read/grep/edit/write/apply_patch/full-tools/shell/coding-chat)`,
142
+ message: `tool "${id}" is denied in General Operator Chat (no apply_patch/full-tools/shell/coding-chat; modelCallable=never still blocked)`,
129
143
  };
130
144
  }
131
145
  if (!isOperatorChatToolAllowed(id)) {
@@ -155,35 +169,34 @@ export function filterOperatorChatTools(requested) {
155
169
  return { allowed, denied };
156
170
  }
157
171
  /**
158
- * Assert that a requested tool set contains no prohibited coding or removed
159
- * SDK explore tool. Used by red-team / boot checks (V12 / M0-A). bash is
160
- * explicitly forbidden: accepting it would recreate the redirect write escape.
172
+ * Non-Pi write / coding channels that must never appear in Operator Chat.
173
+ * ADR 0011 allows Pi builtins read/write/edit/bash/grep/find/ls; this guard
174
+ * only blocks alternate coding surfaces (apply_patch, full-tools, shell,
175
+ * coding-chat).
176
+ */
177
+ const NON_PI_WRITE_CHANNELS = [
178
+ "apply_patch",
179
+ "apply-patch",
180
+ "full-tools",
181
+ "full_tools",
182
+ "coding-chat",
183
+ "coding_chat",
184
+ "shell",
185
+ ];
186
+ /**
187
+ * Assert that a requested tool set contains no prohibited non-Pi write
188
+ * channel. Pi builtins (including write/edit/bash) are allowed (ADR 0011).
161
189
  */
162
190
  export function assertNoWriteToolInList(requested) {
163
- const writeVariants = [
164
- "edit",
165
- "write",
166
- "apply_patch",
167
- "apply-patch",
168
- "full-tools",
169
- "full_tools",
170
- "coding-chat",
171
- "coding_chat",
172
- "shell",
173
- "bash",
174
- "read",
175
- "grep",
176
- ];
177
191
  const lower = new Set(requested.map((t) => t.trim().toLowerCase()));
178
- for (const v of writeVariants) {
192
+ for (const v of NON_PI_WRITE_CHANNELS) {
179
193
  if (lower.has(v)) {
180
- throw new Error(`file-writing tool "${v}" must not appear in Operator Chat tool list (V12 closed coding surface)`);
194
+ throw new Error(`non-Pi write channel "${v}" must not appear in Operator Chat tool list (ADR 0011)`);
181
195
  }
182
196
  }
183
197
  }
184
198
  /**
185
- * Back-compat alias kept for callers that still reference the old name. It
186
- * also rejects removed SDK explore tools, including bash.
199
+ * Back-compat alias kept for callers that still reference the old name.
187
200
  * @deprecated use {@link assertNoWriteToolInList}.
188
201
  */
189
202
  export function assertNoBashInToolList(requested) {
@@ -34,6 +34,7 @@ export async function runOperation(operationId, deps) {
34
34
  ? deps.client.runExternal(op.externalCommand, cliArgs, options)
35
35
  : deps.client.run(cliArgs, options));
36
36
  let finished;
37
+ let spawnUpdate = Promise.resolve();
37
38
  try {
38
39
  const result = await run(args, {
39
40
  cwd: deps.repoRoot,
@@ -41,10 +42,21 @@ export async function runOperation(operationId, deps) {
41
42
  expectJson: true,
42
43
  timeoutMs: deps.defaultTimeoutMs,
43
44
  onSpawn: (info) => {
44
- void deps.store.update(operationId, {
45
+ // Chain onto the per-operation write lock; never fire-and-forget a
46
+ // rejected update (unhandled rejection can crash Console on Windows).
47
+ spawnUpdate = deps.store
48
+ .update(operationId, {
45
49
  state: "running",
46
50
  pid: info.pid,
47
51
  processStartIdentity: info.startedAt,
52
+ })
53
+ .catch((error) => {
54
+ const message = error instanceof Error ? error.message : String(error);
55
+ deps.events.append(operationId, {
56
+ at: new Date().toISOString(),
57
+ kind: "error",
58
+ message: `failed to persist running state: ${message}`,
59
+ });
48
60
  });
49
61
  stateEvent(deps.events, op, "running", `pid=${info.pid ?? "?"}`);
50
62
  },
@@ -63,9 +75,11 @@ export async function runOperation(operationId, deps) {
63
75
  });
64
76
  },
65
77
  });
78
+ await spawnUpdate;
66
79
  finished = await finalizeFromWorkerResult(operationId, result, deps);
67
80
  }
68
81
  catch (error) {
82
+ await spawnUpdate.catch(() => undefined);
69
83
  // Cannot prove CLI exit classification → needs-reconcile (not Cancel)
70
84
  finished = await deps.store.update(operationId, {
71
85
  state: "needs-reconcile",
@@ -17,73 +17,94 @@ export function newOperationId() {
17
17
  }
18
18
  export class OperationStore {
19
19
  appData;
20
+ /**
21
+ * Per-key write lock. Concurrent update/save/create on the same operation
22
+ * or clientRequestId must not race rename() into the same JSON path
23
+ * (Windows EPERM) or last-write-wins lost patches.
24
+ */
25
+ writeChains = new Map();
20
26
  constructor(appData) {
21
27
  this.appData = appData;
22
28
  }
29
+ serialize(key, op) {
30
+ const prev = this.writeChains.get(key) ?? Promise.resolve();
31
+ const next = prev.then(op, op);
32
+ this.writeChains.set(key, next.then(() => undefined, () => undefined));
33
+ return next;
34
+ }
23
35
  async create(input) {
24
36
  const clientRequestId = input.clientRequestId.trim();
25
37
  if (!clientRequestId) {
26
38
  throw new Error("clientRequestId is required for mutation operations");
27
39
  }
28
40
  const payloadHash = computeActionPayloadHash(input.action, input.actionParams);
29
- const indexPath = requestIndexFile(this.appData, clientRequestId);
30
- const existingIndex = await readJsonIfExists(indexPath);
31
- if (existingIndex) {
32
- const existing = await this.get(existingIndex.operationId);
33
- if (existing) {
34
- if (existing.payloadHash === payloadHash) {
35
- return { kind: "idempotent", operation: existing };
41
+ return this.serialize(`req:${clientRequestId}`, async () => {
42
+ const indexPath = requestIndexFile(this.appData, clientRequestId);
43
+ const existingIndex = await readJsonIfExists(indexPath);
44
+ if (existingIndex) {
45
+ const existing = await this.get(existingIndex.operationId);
46
+ if (existing) {
47
+ if (existing.payloadHash === payloadHash) {
48
+ return { kind: "idempotent", operation: existing };
49
+ }
50
+ return {
51
+ kind: "conflict",
52
+ code: "REQUEST_ID_REUSE_CONFLICT",
53
+ message: `clientRequestId ${clientRequestId} was reused with a different payload`,
54
+ existing,
55
+ };
36
56
  }
37
- return {
38
- kind: "conflict",
39
- code: "REQUEST_ID_REUSE_CONFLICT",
40
- message: `clientRequestId ${clientRequestId} was reused with a different payload`,
41
- existing,
42
- };
43
57
  }
44
- }
45
- const operationId = newOperationId();
46
- const now = new Date().toISOString();
47
- const operation = {
48
- schemaVersion: 1,
49
- operationId,
50
- clientRequestId,
51
- action: input.action,
52
- actionParams: input.actionParams,
53
- payloadHash,
54
- state: "queued",
55
- createdAt: now,
56
- taskId: input.taskId,
57
- cliArgs: input.cliArgs,
58
- ...(input.externalCommand ? { externalCommand: input.externalCommand } : {}),
59
- };
60
- await writeSecureJson(operationFile(this.appData, operationId), operation);
61
- await writeSecureJson(indexPath, { operationId, payloadHash });
62
- return { kind: "created", operation };
58
+ const operationId = newOperationId();
59
+ const now = new Date().toISOString();
60
+ const operation = {
61
+ schemaVersion: 1,
62
+ operationId,
63
+ clientRequestId,
64
+ action: input.action,
65
+ actionParams: input.actionParams,
66
+ payloadHash,
67
+ state: "queued",
68
+ createdAt: now,
69
+ taskId: input.taskId,
70
+ cliArgs: input.cliArgs,
71
+ ...(input.externalCommand
72
+ ? { externalCommand: input.externalCommand }
73
+ : {}),
74
+ };
75
+ await writeSecureJson(operationFile(this.appData, operationId), operation);
76
+ await writeSecureJson(indexPath, { operationId, payloadHash });
77
+ return { kind: "created", operation };
78
+ });
63
79
  }
64
80
  async get(operationId) {
65
81
  return readJsonIfExists(operationFile(this.appData, operationId));
66
82
  }
67
83
  async save(operation) {
68
- await writeSecureJson(operationFile(this.appData, operation.operationId), operation);
69
- return operation;
84
+ return this.serialize(operation.operationId, async () => {
85
+ await writeSecureJson(operationFile(this.appData, operation.operationId), operation);
86
+ return operation;
87
+ });
70
88
  }
71
89
  async update(operationId, patch) {
72
- const current = await this.get(operationId);
73
- if (!current) {
74
- throw new Error(`operation not found: ${operationId}`);
75
- }
76
- const next = {
77
- ...current,
78
- ...patch,
79
- operationId: current.operationId,
80
- schemaVersion: 1,
81
- clientRequestId: current.clientRequestId,
82
- payloadHash: current.payloadHash,
83
- action: current.action,
84
- createdAt: current.createdAt,
85
- };
86
- return this.save(next);
90
+ return this.serialize(operationId, async () => {
91
+ const current = await this.get(operationId);
92
+ if (!current) {
93
+ throw new Error(`operation not found: ${operationId}`);
94
+ }
95
+ const next = {
96
+ ...current,
97
+ ...patch,
98
+ operationId: current.operationId,
99
+ schemaVersion: 1,
100
+ clientRequestId: current.clientRequestId,
101
+ payloadHash: current.payloadHash,
102
+ action: current.action,
103
+ createdAt: current.createdAt,
104
+ };
105
+ await writeSecureJson(operationFile(this.appData, operationId), next);
106
+ return next;
107
+ });
87
108
  }
88
109
  async list() {
89
110
  const files = await listJsonFiles(this.appData.operations);
@@ -72,6 +72,49 @@ function readWriteSetGateToken(reviewPacket) {
72
72
  const token = reviewPacket.writeSetGateToken;
73
73
  return typeof token === "string" && token.includes(":") ? token : undefined;
74
74
  }
75
+ /**
76
+ * Prefer structured stdout JSON failure semantics (findings / message) over bare
77
+ * `exit N` when stderr is empty — spine audit and similar read CLIs write details
78
+ * to stdout JSON and exit non-zero with empty stderr.
79
+ */
80
+ function summarizeReadCliFailure(result) {
81
+ const fromJson = summarizeJsonFailure(result.json);
82
+ if (fromJson)
83
+ return fromJson;
84
+ const stderr = (result.stderr ?? "").trim();
85
+ if (stderr)
86
+ return stderr;
87
+ return `exit ${result.exitCode}`;
88
+ }
89
+ function summarizeJsonFailure(json) {
90
+ if (!json || typeof json !== "object")
91
+ return undefined;
92
+ const raw = json;
93
+ if (Array.isArray(raw.findings)) {
94
+ const messages = raw.findings
95
+ .map((finding) => {
96
+ if (!finding || typeof finding !== "object")
97
+ return "";
98
+ const message = finding.message;
99
+ return typeof message === "string" ? message.trim() : "";
100
+ })
101
+ .filter(Boolean);
102
+ if (messages.length > 0) {
103
+ const head = messages.slice(0, 6).join("; ");
104
+ return messages.length > 6 ? `${head}…` : head;
105
+ }
106
+ }
107
+ const nestedError = raw.error;
108
+ if (nestedError && typeof nestedError === "object") {
109
+ const message = nestedError.message;
110
+ if (typeof message === "string" && message.trim())
111
+ return message.trim();
112
+ }
113
+ if (typeof raw.message === "string" && raw.message.trim()) {
114
+ return raw.message.trim();
115
+ }
116
+ return undefined;
117
+ }
75
118
  async function runReadCli(ctx, args, command) {
76
119
  const run = ctx.runCommand ?? ((a, o) => ctx.client.run(a, o));
77
120
  try {
@@ -124,11 +167,24 @@ async function runReadCli(ctx, args, command) {
124
167
  raw: result.json,
125
168
  });
126
169
  }
170
+ const message = summarizeReadCliFailure(result);
171
+ if (result.json && typeof result.json === "object") {
172
+ return buildOperatorResult({
173
+ command,
174
+ ok: false,
175
+ outcome: "rejected",
176
+ result: result.json,
177
+ error: {
178
+ code: "INTERNAL_ERROR",
179
+ message,
180
+ },
181
+ });
182
+ }
127
183
  return operatorFailed({
128
184
  command,
129
185
  outcome: "rejected",
130
186
  code: "INTERNAL_ERROR",
131
- message: result.stderr || `exit ${result.exitCode}`,
187
+ message,
132
188
  });
133
189
  }
134
190
  catch (error) {