@saadnvd1/agent-os 0.3.738 → 0.3.743

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 (41) hide show
  1. package/components/Chat/Activity.test.ts +2 -1
  2. package/components/Chat/Activity.tsx +102 -64
  3. package/components/Chat/ChatPanel.tsx +4 -0
  4. package/components/Chat/LiveOutput.test.ts +36 -0
  5. package/components/Chat/Tools.tsx +42 -5
  6. package/data/chat/useChat.ts +10 -1
  7. package/lib/chat/demo.ts +42 -4
  8. package/lib/chat/drivers/claude-mapper.ts +10 -0
  9. package/lib/chat/drivers/claude.ts +9 -1
  10. package/lib/chat/drivers/demo-script.ts +148 -0
  11. package/lib/chat/drivers/demo.ts +21 -4
  12. package/lib/chat/drivers/edit-guard.ts +311 -0
  13. package/lib/chat/drivers/index.ts +2 -2
  14. package/lib/chat/drivers/opencode-config.ts +111 -0
  15. package/lib/chat/drivers/opencode-mapper.ts +125 -13
  16. package/lib/chat/drivers/opencode-plugin.ts +78 -0
  17. package/lib/chat/drivers/opencode-server.ts +7 -2
  18. package/lib/chat/drivers/opencode.ts +108 -9
  19. package/lib/chat/drivers/pi-edit-guard.ts +87 -0
  20. package/lib/chat/drivers/pi-mapper.ts +74 -1
  21. package/lib/chat/drivers/pi.ts +82 -3
  22. package/lib/chat/drivers/transient-retry.ts +87 -0
  23. package/lib/chat/drivers/turn-items.ts +74 -1
  24. package/lib/chat/drivers/turn-outcome.ts +48 -1
  25. package/lib/chat/events.ts +15 -1
  26. package/lib/chat/model-wait-live.ts +19 -0
  27. package/lib/chat/model-wait.ts +88 -0
  28. package/lib/chat/progress.ts +169 -0
  29. package/lib/chat/registry.ts +5 -0
  30. package/lib/chat/runner-watch.ts +71 -0
  31. package/lib/chat/runner.ts +4 -70
  32. package/lib/chat/tools.ts +14 -0
  33. package/lib/security/demo.ts +8 -0
  34. package/lib/tasks/move-testing.ts +6 -1
  35. package/package.json +1 -1
  36. package/scripts/screenshots/run.ts +27 -3
  37. package/scripts/screenshots/running-turn.ts +54 -0
  38. package/scripts/screenshots/seed.ts +15 -3
  39. package/scripts/screenshots/shoot.ts +3 -1
  40. package/scripts/screenshots/shots.ts +5 -0
  41. package/server.ts +10 -2
@@ -2,12 +2,16 @@ import { randomUUID } from "crypto";
2
2
  import type { DriverEvent, UsageTotals } from "../events";
3
3
  import { openCodeTool } from "./opencode-rules";
4
4
  import { TurnItems } from "./turn-items";
5
- import { logTurnEnd, turnOutcome } from "./turn-outcome";
5
+ import { logTurnEnd, needsSummary, turnOutcome } from "./turn-outcome";
6
+ import { retryNotice, TurnRetry, type RetryDecision } from "./transient-retry";
6
7
 
7
8
  type P = Record<string, unknown>;
8
9
  const str = (v: unknown) => (typeof v === "string" ? v : "");
9
10
  const num = (v: unknown) => Number(v) || 0;
10
11
 
12
+ // A prompt this big (tokens) is worth compacting before the last retry.
13
+ const HEAVY_TOKENS = 16_000;
14
+
11
15
  const TODO = new Set(["pending", "in_progress", "completed"]);
12
16
 
13
17
  // OpenCode's event stream as chat items, for one session (its subagents'
@@ -37,6 +41,10 @@ export class OpenCodeMapper {
37
41
  calls: new Set<string>(),
38
42
  };
39
43
  private aborted = false;
44
+ // Tool calls this turn, kept through the nudged run that continues it.
45
+ private toolIds = new Set<string>();
46
+ private nudged = false;
47
+ private nudgeDue = false;
40
48
  // Idle from before the prompt is stale until the session goes busy.
41
49
  private admitted = false;
42
50
  totals: UsageTotals = {
@@ -47,15 +55,34 @@ export class OpenCodeMapper {
47
55
  cacheWriteTokens: 0,
48
56
  };
49
57
 
58
+ // Retrying a turn the model's server dropped.
59
+ readonly retry = new TurnRetry();
60
+ // OpenCode retried the failure itself: AgentOS adds nothing on top.
61
+ private agentRetried = false;
62
+ // The last answer's prompt size, to judge whether compacting would help.
63
+ lastInputTokens = 0;
64
+ // Compacting between retries makes the session busy and idle: not a turn.
65
+ private holding = false;
66
+
50
67
  constructor(
51
68
  private sessionId: () => string | null,
52
- private emit: (e: DriverEvent) => void
69
+ private emit: (e: DriverEvent) => void,
70
+ // Called when a failed turn is to be tried again.
71
+ private onRetry: (
72
+ d: Extract<RetryDecision, { retry: true }>,
73
+ error: string
74
+ ) => void = () => {}
53
75
  ) {
54
76
  this.items = new TurnItems(emit);
55
77
  }
56
78
 
57
- // A prompt went out: its turn starts now.
58
- prompted(): void {
79
+ // A prompt went out: its turn starts now. A retry is the same turn.
80
+ prompted(retried = false): void {
81
+ this.retrying = false;
82
+ if (!retried) {
83
+ this.retry.reset();
84
+ this.agentRetried = false;
85
+ }
59
86
  this.admitted = false;
60
87
  this.items.startTurn();
61
88
  }
@@ -66,9 +93,18 @@ export class OpenCodeMapper {
66
93
  switch (e.type) {
67
94
  case "session.status": {
68
95
  const type = str((p.status as P)?.type);
96
+ if (type === "retry") this.agentRetried = true;
69
97
  if (type === "busy" || type === "retry") {
70
98
  this.admitted = true;
71
99
  this.items.startTurn();
100
+ if (type === "retry") {
101
+ const st = p.status as P;
102
+ this.emit({
103
+ type: "model_retry",
104
+ attempt: num(st.attempt) || 1,
105
+ message: str(st.message) || undefined,
106
+ });
107
+ }
72
108
  } else if (type === "idle") this.idle();
73
109
  return;
74
110
  }
@@ -125,6 +161,12 @@ export class OpenCodeMapper {
125
161
  this.counted.add(id);
126
162
  const t = (info.tokens ?? {}) as P;
127
163
  const cache = (t.cache ?? {}) as P;
164
+ this.lastInputTokens = num(t.input) + num(cache.read);
165
+ if (this.lastInputTokens + num(cache.write) > 0)
166
+ this.emit({
167
+ type: "prompt_tokens",
168
+ tokens: this.lastInputTokens + num(cache.write),
169
+ });
128
170
  this.totals = {
129
171
  costUsd: this.totals.costUsd + num(info.cost),
130
172
  inputTokens: this.totals.inputTokens + num(t.input),
@@ -157,6 +199,7 @@ export class OpenCodeMapper {
157
199
  return;
158
200
  }
159
201
  if (part.type === "tool") {
202
+ this.toolIds.add(id);
160
203
  if (this.last.id === str(part.messageID)) this.last.calls.add(id);
161
204
  this.tool(id, part);
162
205
  }
@@ -183,7 +226,9 @@ export class OpenCodeMapper {
183
226
  if (tool === "todowrite" || status === "pending") return;
184
227
  const start = openCodeTool(tool, state.input);
185
228
  if (!this.items.hasTool(id)) this.items.tool(id, start);
186
- if (status === "completed")
229
+ if (status === "running")
230
+ this.items.progress(id, str((state.metadata as P | undefined)?.output));
231
+ else if (status === "completed")
187
232
  this.items.toolDone(id, "done", str(state.output), start);
188
233
  else if (status === "error")
189
234
  this.items.toolDone(id, "error", str(state.error), start);
@@ -197,10 +242,72 @@ export class OpenCodeMapper {
197
242
  str((error.data as P)?.message) || str(error.name) || "OpenCode failed";
198
243
  }
199
244
 
245
+ // A turn that failed on a dropped request goes on in place: the notice,
246
+ // then the driver sends the prompt again. False when it should end.
247
+ private retryTurn(): boolean {
248
+ const error = this.error ?? "";
249
+ const d = this.retry.next({
250
+ error,
251
+ harnessRetried: this.agentRetried,
252
+ contextHeavy: this.lastInputTokens >= HEAVY_TOKENS,
253
+ });
254
+ if (!d.retry) return false;
255
+ this.items.note(retryNotice(d, error));
256
+ this.clearTurn();
257
+ this.retrying = true;
258
+ this.onRetry(d, error);
259
+ return true;
260
+ }
261
+
262
+ private clearTurn(): void {
263
+ this.error = null;
264
+ this.last.id = "";
265
+ this.toolIds.clear();
266
+ this.aborted = false;
267
+ this.admitted = false;
268
+ for (const id of this.current) this.past.add(id);
269
+ this.current.clear();
270
+ this.parts.clear();
271
+ }
272
+
273
+ // A failed turn is waiting for its retry to go out.
274
+ retrying = false;
275
+
276
+ // The reader stopped the turn while it waited to retry.
277
+ stopRetrying(): void {
278
+ this.retrying = false;
279
+ this.holding = false;
280
+ this.emit({ type: "usage", totals: { ...this.totals } });
281
+ this.items.endTurn({ interrupted: true });
282
+ }
283
+
284
+ holdIdle(on: boolean): void {
285
+ this.holding = on;
286
+ }
287
+
200
288
  private idle(): void {
201
- if (!this.admitted || !this.items.inTurn) return;
289
+ if (this.holding || !this.admitted || !this.items.inTurn) return;
290
+ if (this.error && !this.aborted && this.retryTurn()) return;
202
291
  this.emit({ type: "usage", totals: { ...this.totals } });
203
292
  const text = [...this.last.text.values()].join("\n");
293
+ if (
294
+ !this.nudged &&
295
+ needsSummary({
296
+ toolsRun: this.toolIds.size,
297
+ text,
298
+ toolCalls: this.last.calls.size,
299
+ finishReason: this.last.finish,
300
+ error: this.error ?? undefined,
301
+ aborted: this.aborted,
302
+ })
303
+ ) {
304
+ // The turn stays open: the driver asks for the summary, once.
305
+ this.nudged = this.nudgeDue = true;
306
+ this.last.id = "";
307
+ this.admitted = false;
308
+ console.info("[chat] asked the model for its results summary");
309
+ return;
310
+ }
204
311
  const ended =
205
312
  this.aborted || !this.last.id
206
313
  ? undefined
@@ -209,6 +316,9 @@ export class OpenCodeMapper {
209
316
  text,
210
317
  toolCalls: this.last.calls.size,
211
318
  error: this.error ?? undefined,
319
+ retries: this.retry.tried,
320
+ agentRetried: this.agentRetried,
321
+ summary: this.items.summary(),
212
322
  });
213
323
  if (ended)
214
324
  logTurnEnd({
@@ -225,13 +335,15 @@ export class OpenCodeMapper {
225
335
  finishReason: this.last.finish || undefined,
226
336
  ended,
227
337
  });
228
- this.error = null;
229
- this.last.id = "";
230
- this.aborted = false;
231
- this.admitted = false;
232
- for (const id of this.current) this.past.add(id);
233
- this.current.clear();
234
- this.parts.clear();
338
+ this.clearTurn();
339
+ this.nudged = this.nudgeDue = false;
340
+ }
341
+
342
+ // True once after a turn ended without its results: send SUMMARY_NUDGE.
343
+ takeNudge(): boolean {
344
+ const due = this.nudgeDue;
345
+ this.nudgeDue = false;
346
+ return due;
235
347
  }
236
348
 
237
349
  // The prompt never reached a busy session (it failed, or finished before
@@ -0,0 +1,78 @@
1
+ import { writeEditGuard } from "./edit-guard";
2
+
3
+ // OpenCode's edit and write tools, with the edit guard beside them as a
4
+ // plugin. A throw in tool.execute.before becomes the tool's error, so a
5
+ // doomed edit never runs and the model reads why; tool.execute.after adds
6
+ // what a finished edit broke to the result. OpenCode runs every export of a
7
+ // plugin file as a plugin, so this has exactly one. Exported for its test.
8
+ export const OPENCODE_GUARD_PLUGIN = String.raw`import { createHash } from "node:crypto";
9
+ import {
10
+ FailureLog,
11
+ absolute,
12
+ failureMessage,
13
+ guardEdit,
14
+ readText,
15
+ syntaxReport,
16
+ } from "./agentos-edit-guard-lib.mjs";
17
+
18
+ const EDITS = new Set(["edit", "multiedit"]);
19
+ const hash = (t) => createHash("sha1").update(String(t ?? "")).digest("hex");
20
+
21
+ function oldTexts(args) {
22
+ if (Array.isArray(args?.edits)) return args.edits.map((e) => e?.oldString).filter((t) => typeof t === "string");
23
+ return typeof args?.oldString === "string" ? [args.oldString] : [];
24
+ }
25
+
26
+ export const AgentOSGuard = async (ctx) => {
27
+ const cwd = ctx?.directory || process.cwd();
28
+ const log = new FailureLog();
29
+ const before = new Map();
30
+ // Edits that passed the check but never reached tool.execute.after: the
31
+ // tool itself failed them.
32
+ const inflight = new Map();
33
+ return {
34
+ "tool.execute.before": async (input, output) => {
35
+ const tool = input?.tool;
36
+ const args = output?.args || {};
37
+ if (!EDITS.has(tool) && tool !== "write") return;
38
+ if (typeof args.filePath !== "string") return;
39
+ const file = absolute(args.filePath, cwd);
40
+ if (EDITS.has(tool)) {
41
+ const why = guardEdit(log, args.filePath, oldTexts(args), cwd);
42
+ if (why) throw new Error(why);
43
+ const old = oldTexts(args)[0];
44
+ const text = readText(file);
45
+ if (tool === "edit" && old !== undefined && text != null) {
46
+ const key = log.key(file, old);
47
+ for (const [id, e] of inflight) {
48
+ if (id === input.callID || e.key !== key || e.hash !== hash(text)) continue;
49
+ inflight.delete(id);
50
+ log.record(file, old);
51
+ throw new Error(failureMessage(args.filePath, old, text, log.record(file, old)));
52
+ }
53
+ inflight.set(input.callID, { key, hash: hash(text) });
54
+ }
55
+ }
56
+ before.set(input.callID, readText(file));
57
+ },
58
+ "tool.execute.after": async (input, output) => {
59
+ try {
60
+ const tool = input?.tool;
61
+ const args = input?.args || {};
62
+ if (!EDITS.has(tool) && tool !== "write") return;
63
+ if (typeof args.filePath !== "string") return;
64
+ const file = absolute(args.filePath, cwd);
65
+ const prior = before.get(input.callID);
66
+ before.delete(input.callID);
67
+ inflight.delete(input.callID);
68
+ log.clear(file);
69
+ const report = syntaxReport(args.filePath, prior, readText(file), cwd);
70
+ if (report && output) output.output = String(output.output ?? "") + "\n\n" + report;
71
+ } catch {}
72
+ },
73
+ };
74
+ };
75
+ `;
76
+
77
+ export const writeOpenCodeGuard = (folder?: string) =>
78
+ writeEditGuard("agentos-opencode-guard.mjs", OPENCODE_GUARD_PLUGIN, folder);
@@ -85,7 +85,12 @@ export class OpenCodeServer {
85
85
  }
86
86
 
87
87
  // One JSON call. Errors carry OpenCode's message, never the URL.
88
- async call<T>(method: string, path: string, body?: unknown): Promise<T> {
88
+ async call<T>(
89
+ method: string,
90
+ path: string,
91
+ body?: unknown,
92
+ timeoutMs = CALL_MS
93
+ ): Promise<T> {
89
94
  await this.ready;
90
95
  const res = await fetch(this.endpoint(path), {
91
96
  method,
@@ -94,7 +99,7 @@ export class OpenCodeServer {
94
99
  ...(body !== undefined && { "content-type": "application/json" }),
95
100
  },
96
101
  body: body === undefined ? undefined : JSON.stringify(body),
97
- signal: AbortSignal.timeout(CALL_MS),
102
+ signal: AbortSignal.timeout(timeoutMs),
98
103
  });
99
104
  const text = await res.text();
100
105
  const data = text ? (JSON.parse(text) as T & P) : (undefined as T);
@@ -7,14 +7,52 @@ import { OpenCodeMapper } from "./opencode-mapper";
7
7
  import { openCodeRules } from "./opencode-rules";
8
8
  import { OpenCodeServer } from "./opencode-server";
9
9
  import { OpenCodeApprovals } from "./opencode-approvals";
10
+ import { RESULTS_BRIEF, SUMMARY_NUDGE } from "./turn-outcome";
11
+ import { openCodeConfigContent } from "./opencode-config";
12
+ import { writeOpenCodeGuard } from "./opencode-plugin";
13
+ import { typescriptPath } from "./edit-guard";
14
+ import { continuePrompt, type RetryDecision } from "./transient-retry";
10
15
 
11
16
  type P = Record<string, unknown>;
12
17
  const str = (v: unknown) => (typeof v === "string" ? v : "");
13
18
 
19
+ // Compacting can take a while; the retry waits for it.
20
+ const COMPACT_MS = 300_000;
21
+
22
+ // The server's environment for one conversation: the edit guard plugin, a
23
+ // longer header wait for its providers, and the TypeScript to check syntax
24
+ // with. Where any of it can't be set up the conversation runs without it.
25
+ function conversationEnv(
26
+ cwd: string,
27
+ model: string,
28
+ env: NodeJS.ProcessEnv
29
+ ): NodeJS.ProcessEnv {
30
+ try {
31
+ const ts = typescriptPath();
32
+ const withTs = { ...env, ...(ts && { AGENTOS_TYPESCRIPT: ts }) };
33
+ return {
34
+ ...withTs,
35
+ OPENCODE_CONFIG_CONTENT: openCodeConfigContent({
36
+ cwd,
37
+ env: withTs,
38
+ model,
39
+ pluginFile: writeOpenCodeGuard(),
40
+ }),
41
+ };
42
+ } catch {
43
+ return env;
44
+ }
45
+ }
46
+
14
47
  // How long a prompt may go without the session turning busy before the
15
48
  // turn is checked on.
16
49
  const ADMIT_MS = 15_000;
17
50
 
51
+ // AgentOS's own brief, then the results line every session gets.
52
+ export function openCodeSystem(append?: string): string {
53
+ return [append, RESULTS_BRIEF].filter(Boolean).join("\n\n");
54
+ }
55
+
18
56
  export function openCodeModel(model: string) {
19
57
  const slash = model.indexOf("/");
20
58
  if (!model || model === AGENT_DEFAULT_MODEL || slash < 1) return undefined;
@@ -79,13 +117,23 @@ export const openCodeDriver: ChatDriver = {
79
117
  start(options): ChatConversation {
80
118
  const out = new InputQueue<DriverEvent>();
81
119
  const emit = (e: DriverEvent) => out.push(e);
82
- const server = new OpenCodeServer(options.cwd, {
83
- ...sessionBaseEnv(),
84
- ...options.env,
85
- });
120
+ const server = new OpenCodeServer(
121
+ options.cwd,
122
+ conversationEnv(options.cwd, options.model ?? "", {
123
+ ...sessionBaseEnv(),
124
+ ...options.env,
125
+ })
126
+ );
86
127
  let sessionId: string | null = null;
87
128
  const children = new Set<string>();
88
- const mapper = new OpenCodeMapper(() => sessionId, emit);
129
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
130
+ // Bumped when a retry is cancelled, so one already under way stops.
131
+ let retryGen = 0;
132
+ const mapper = new OpenCodeMapper(
133
+ () => sessionId,
134
+ emit,
135
+ (d, error) => scheduleRetry(d, error)
136
+ );
89
137
  const approvals = new OpenCodeApprovals(emit, server, (e) => fail(e));
90
138
  let access = options.access;
91
139
  let plan = !!options.plan;
@@ -106,6 +154,7 @@ export const openCodeDriver: ChatDriver = {
106
154
  if (ours(p.sessionID)) approvals.handle(e.type, p);
107
155
  const wasInTurn = mapper.items.inTurn;
108
156
  mapper.map(e);
157
+ if (mapper.takeNudge()) return void prompt(SUMMARY_NUDGE);
109
158
  if (wasInTurn && !mapper.items.inTurn && held.length) {
110
159
  const next = held.shift()!;
111
160
  void prompt(next.text, next.images);
@@ -153,15 +202,59 @@ export const openCodeDriver: ChatDriver = {
153
202
  });
154
203
  }
155
204
 
156
- async function prompt(text: string, images?: ChatImage[]) {
157
- mapper.prompted();
205
+ // The failed request goes again after its wait, on a compacted context
206
+ // for the last try. Stopping or closing cancels it.
207
+ function scheduleRetry(
208
+ d: Extract<RetryDecision, { retry: true }>,
209
+ error: string
210
+ ) {
211
+ cancelRetry();
212
+ const gen = retryGen;
213
+ retryTimer = setTimeout(() => {
214
+ retryTimer = null;
215
+ void resume(d, error, gen);
216
+ }, d.delayMs);
217
+ }
218
+
219
+ // Whether a retry was waiting or under way.
220
+ function cancelRetry(): boolean {
221
+ const pending = retryTimer !== null || mapper.retrying;
222
+ if (retryTimer) clearTimeout(retryTimer);
223
+ retryTimer = null;
224
+ retryGen++;
225
+ return pending;
226
+ }
227
+
228
+ async function resume(
229
+ d: Extract<RetryDecision, { retry: true }>,
230
+ error: string,
231
+ gen: number
232
+ ) {
233
+ const live = () => !closed && mapper.items.inTurn && gen === retryGen;
234
+ if (!live()) return;
235
+ if (d.compactFirst && model) {
236
+ mapper.holdIdle(true);
237
+ await server
238
+ .call("POST", `/session/${sessionId}/summarize`, model, COMPACT_MS)
239
+ .catch(() => {});
240
+ mapper.holdIdle(false);
241
+ if (!live()) return;
242
+ }
243
+ await prompt(continuePrompt(error), undefined, true);
244
+ }
245
+
246
+ async function prompt(text: string, images?: ChatImage[], retried = false) {
247
+ if (!retried) cancelRetry();
248
+ mapper.prompted(retried);
158
249
  try {
159
250
  await ready;
160
251
  await server.call("POST", `/session/${sessionId}/prompt_async`, {
161
252
  model,
162
253
  agent: plan ? "plan" : "build",
163
- system: options.systemAppend || undefined,
164
- parts: openCodeParts(text, images),
254
+ system: openCodeSystem(options.systemAppend),
255
+ parts: retried
256
+ ? [{ type: "text", text, synthetic: true }]
257
+ : openCodeParts(text, images),
165
258
  });
166
259
  } catch (error) {
167
260
  fail(error as Error);
@@ -180,6 +273,11 @@ export const openCodeDriver: ChatDriver = {
180
273
  }
181
274
 
182
275
  async function abort() {
276
+ // A turn waiting to retry has nothing running to abort: it ends here.
277
+ if (cancelRetry()) {
278
+ mapper.stopRetrying();
279
+ for (const next of held.splice(0)) void prompt(next.text, next.images);
280
+ }
183
281
  await ready;
184
282
  for (const id of [sessionId, ...children])
185
283
  await server.call("POST", `/session/${id}/abort`).catch(() => {});
@@ -188,6 +286,7 @@ export const openCodeDriver: ChatDriver = {
188
286
  function close() {
189
287
  if (closed) return;
190
288
  closed = true;
289
+ cancelRetry();
191
290
  approvals.pending.expireAll();
192
291
  stream.abort();
193
292
  server.close();
@@ -0,0 +1,87 @@
1
+ import { writeEditGuard } from "./edit-guard";
2
+
3
+ // Pi's edit and write tools, with the edit guard beside them: a doomed edit
4
+ // is blocked before it runs (and before the approvals extension asks about
5
+ // it), and what a finished edit or write broke comes back in its result.
6
+ // Exported for its test.
7
+ export const PI_EDIT_GUARD_EXTENSION = String.raw`import {
8
+ FailureLog,
9
+ absolute,
10
+ closestMatch,
11
+ guardEdit,
12
+ readText,
13
+ syntaxReport,
14
+ } from "./agentos-edit-guard-lib.mjs";
15
+
16
+ const log = new FailureLog();
17
+ const before = new Map();
18
+
19
+ function oldTexts(input) {
20
+ let edits = input?.edits;
21
+ if (typeof edits === "string") {
22
+ try {
23
+ edits = JSON.parse(edits);
24
+ } catch {
25
+ edits = null;
26
+ }
27
+ }
28
+ if (Array.isArray(edits)) return edits.map((e) => e?.oldText).filter((t) => typeof t === "string");
29
+ return typeof input?.oldText === "string" ? [input.oldText] : [];
30
+ }
31
+
32
+ const textOf = (content) =>
33
+ (Array.isArray(content) ? content : []).map((c) => (typeof c?.text === "string" ? c.text : "")).join("\n");
34
+
35
+ export default function (pi) {
36
+ pi.on("tool_call", async (event) => {
37
+ try {
38
+ const tool = event.toolName;
39
+ const input = event.input || {};
40
+ if (typeof input.path !== "string") return;
41
+ const file = absolute(input.path, process.cwd());
42
+ if (tool === "read") return void log.clear(file);
43
+ if (tool !== "edit" && tool !== "write") return;
44
+ if (tool === "edit") {
45
+ const why = guardEdit(log, input.path, oldTexts(input), process.cwd());
46
+ if (why) return { block: true, reason: why };
47
+ }
48
+ before.set(event.toolCallId, readText(file));
49
+ } catch {}
50
+ });
51
+
52
+ pi.on("tool_result", async (event) => {
53
+ try {
54
+ const tool = event.toolName;
55
+ const input = event.input || {};
56
+ if ((tool !== "edit" && tool !== "write") || typeof input.path !== "string") return;
57
+ const file = absolute(input.path, process.cwd());
58
+ const prior = before.get(event.toolCallId);
59
+ before.delete(event.toolCallId);
60
+ const content = Array.isArray(event.content) ? event.content : [];
61
+ if (event.isError) {
62
+ const said = textOf(content);
63
+ if (tool !== "edit" || !/could not find|not find/i.test(said)) return;
64
+ const olds = oldTexts(input);
65
+ const named = /edits\[(\d+)\]/.exec(said);
66
+ const old = olds[named ? Number(named[1]) : 0];
67
+ const text = readText(file);
68
+ if (old === undefined || text == null) return;
69
+ const repeat = log.record(file, old);
70
+ const near = closestMatch(text, old);
71
+ const more = [
72
+ repeat >= 2 ? "This is the same edit that has already failed " + (repeat - 1) + " time(s). Do not send it again unchanged." : "",
73
+ near ? "The file's current text near the closest match (lines " + near.from + "-" + near.to + "):\n" + near.excerpt : "",
74
+ "Read " + input.path + " again and build the edit from what is there now.",
75
+ ].filter(Boolean);
76
+ return { content: [...content, { type: "text", text: more.join("\n") }] };
77
+ }
78
+ log.clear(file);
79
+ const report = syntaxReport(input.path, prior, readText(file), process.cwd());
80
+ if (report) return { content: [...content, { type: "text", text: report }] };
81
+ } catch {}
82
+ });
83
+ }
84
+ `;
85
+
86
+ export const writePiEditGuard = (folder?: string) =>
87
+ writeEditGuard("agentos-pi-edit-guard.mjs", PI_EDIT_GUARD_EXTENSION, folder);