@oh-my-pi/pi-coding-agent 17.1.1 → 17.1.3

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 (105) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/cli.js +6317 -4085
  3. package/dist/types/cli/bench-cli.d.ts +1 -0
  4. package/dist/types/config/model-resolver.d.ts +6 -3
  5. package/dist/types/config/settings-schema.d.ts +4 -0
  6. package/dist/types/launch/broker-list-order.test.d.ts +1 -0
  7. package/dist/types/modes/interactive-mode.d.ts +2 -6
  8. package/dist/types/modes/types.d.ts +8 -5
  9. package/dist/types/modes/utils/ui-helpers.d.ts +1 -6
  10. package/dist/types/task/executor.d.ts +3 -1
  11. package/dist/types/task/structured-subagent.d.ts +3 -0
  12. package/dist/types/task/types.d.ts +11 -11
  13. package/dist/types/thinking.d.ts +13 -0
  14. package/dist/types/tools/auto-generated-guard.d.ts +5 -2
  15. package/dist/types/tools/eval-format/index.d.ts +7 -0
  16. package/dist/types/tools/eval-format/javascript.d.ts +2 -0
  17. package/dist/types/tools/eval-format/julia.d.ts +2 -0
  18. package/dist/types/tools/eval-format/python.d.ts +2 -0
  19. package/dist/types/tools/eval-format/ruby.d.ts +2 -0
  20. package/dist/types/tools/memory-render.d.ts +1 -4
  21. package/dist/types/tools/resolve.d.ts +7 -0
  22. package/dist/types/tools/todo.d.ts +4 -1
  23. package/dist/types/web/search/providers/base.d.ts +16 -0
  24. package/dist/types/web/search/providers/brave.d.ts +2 -0
  25. package/dist/types/web/search/providers/firecrawl.d.ts +2 -0
  26. package/dist/types/web/search/providers/gemini.d.ts +3 -0
  27. package/dist/types/web/search/providers/jina.d.ts +2 -0
  28. package/dist/types/web/search/providers/kagi.d.ts +2 -0
  29. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  30. package/dist/types/web/search/providers/parallel.d.ts +2 -0
  31. package/dist/types/web/search/providers/perplexity.d.ts +3 -0
  32. package/dist/types/web/search/providers/searxng.d.ts +10 -0
  33. package/dist/types/web/search/providers/tavily.d.ts +8 -0
  34. package/dist/types/web/search/query.d.ts +190 -0
  35. package/package.json +12 -12
  36. package/src/cli/bench-cli.ts +12 -1
  37. package/src/cli/web-search-cli.ts +7 -0
  38. package/src/config/model-resolver.ts +17 -6
  39. package/src/config/settings-schema.ts +5 -0
  40. package/src/debug/raw-sse-buffer.ts +157 -30
  41. package/src/edit/hashline/filesystem.ts +1 -1
  42. package/src/edit/modes/patch.ts +1 -1
  43. package/src/export/share.ts +4 -3
  44. package/src/launch/broker-list-order.test.ts +89 -0
  45. package/src/launch/broker.ts +49 -8
  46. package/src/modes/controllers/input-controller.ts +8 -8
  47. package/src/modes/interactive-mode.ts +60 -5
  48. package/src/modes/types.ts +9 -4
  49. package/src/modes/utils/ui-helpers.ts +7 -8
  50. package/src/prompts/system/resolve-device-reminder.md +1 -1
  51. package/src/prompts/system/workflow-notice.md +1 -1
  52. package/src/prompts/tools/ast-edit.md +2 -1
  53. package/src/prompts/tools/bash.md +1 -0
  54. package/src/prompts/tools/eval.md +2 -2
  55. package/src/prompts/tools/task.md +5 -2
  56. package/src/prompts/tools/web-search.md +2 -0
  57. package/src/sdk.ts +8 -4
  58. package/src/session/agent-session.ts +14 -0
  59. package/src/session/queued-messages.ts +7 -1
  60. package/src/session/stream-guards.ts +1 -1
  61. package/src/task/executor.ts +17 -9
  62. package/src/task/index.ts +14 -34
  63. package/src/task/structured-subagent.ts +4 -0
  64. package/src/task/types.ts +15 -13
  65. package/src/thinking.ts +27 -0
  66. package/src/tools/ast-edit.ts +4 -1
  67. package/src/tools/auto-generated-guard.ts +18 -5
  68. package/src/tools/bash.ts +13 -0
  69. package/src/tools/browser/render.ts +2 -1
  70. package/src/tools/eval-format/index.ts +24 -0
  71. package/src/tools/eval-format/javascript.ts +952 -0
  72. package/src/tools/eval-format/julia.ts +446 -0
  73. package/src/tools/eval-format/python.ts +544 -0
  74. package/src/tools/eval-format/ruby.ts +380 -0
  75. package/src/tools/eval-render.ts +12 -6
  76. package/src/tools/memory-render.ts +11 -2
  77. package/src/tools/render-utils.ts +8 -3
  78. package/src/tools/resolve.ts +10 -1
  79. package/src/tools/todo.ts +58 -6
  80. package/src/tools/write.ts +1 -1
  81. package/src/web/search/index.ts +28 -5
  82. package/src/web/search/providers/anthropic.ts +62 -4
  83. package/src/web/search/providers/base.ts +16 -0
  84. package/src/web/search/providers/brave.ts +30 -3
  85. package/src/web/search/providers/codex.ts +14 -1
  86. package/src/web/search/providers/duckduckgo.ts +23 -1
  87. package/src/web/search/providers/ecosia.ts +5 -1
  88. package/src/web/search/providers/exa.ts +28 -1
  89. package/src/web/search/providers/firecrawl.ts +37 -3
  90. package/src/web/search/providers/gemini.ts +12 -2
  91. package/src/web/search/providers/google.ts +2 -1
  92. package/src/web/search/providers/jina.ts +31 -8
  93. package/src/web/search/providers/kagi.ts +10 -1
  94. package/src/web/search/providers/kimi.ts +16 -1
  95. package/src/web/search/providers/mojeek.ts +14 -1
  96. package/src/web/search/providers/parallel.ts +48 -2
  97. package/src/web/search/providers/perplexity.ts +94 -6
  98. package/src/web/search/providers/searxng.ts +145 -9
  99. package/src/web/search/providers/startpage.ts +8 -2
  100. package/src/web/search/providers/synthetic.ts +7 -1
  101. package/src/web/search/providers/tavily.ts +52 -3
  102. package/src/web/search/providers/tinyfish.ts +6 -1
  103. package/src/web/search/providers/xai.ts +49 -2
  104. package/src/web/search/providers/zai.ts +19 -1
  105. package/src/web/search/query.ts +850 -0
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { TempDir } from "@oh-my-pi/pi-utils";
5
+ import { createDaemonBrokerClient, type DaemonBrokerClient } from "./client";
6
+ import type { DaemonSnapshot, DaemonSpec } from "./protocol";
7
+
8
+ const TERMINAL_HISTORY_LIMIT = 10;
9
+
10
+ function spec(name: string, cwd: string): DaemonSpec {
11
+ return {
12
+ name,
13
+ application: process.execPath,
14
+ args: [],
15
+ env: {},
16
+ cwd,
17
+ pty: false,
18
+ restart: "no",
19
+ persist: false,
20
+ detached: false,
21
+ };
22
+ }
23
+
24
+ function terminalSnapshot(index: number): DaemonSnapshot {
25
+ const name = `exited-${index}`;
26
+ return {
27
+ name,
28
+ id: name,
29
+ state: index % 2 === 0 ? "exited" : "failed",
30
+ createdAt: index * 10,
31
+ startedAt: index * 10,
32
+ exitedAt: index * 10 + 1,
33
+ exitReason: `historical exit ${index}`,
34
+ restartCount: 0,
35
+ outputBytes: 0,
36
+ persist: false,
37
+ detached: false,
38
+ };
39
+ }
40
+
41
+ async function seedTerminalRecord(runtimeDir: string, cwd: string, snapshot: DaemonSnapshot): Promise<void> {
42
+ const metaPath = path.join(runtimeDir, "daemons", snapshot.name, "meta.json");
43
+ await Bun.write(metaPath, JSON.stringify({ daemon: snapshot, spec: spec(snapshot.name, cwd) }));
44
+ }
45
+
46
+ async function shutdown(client: DaemonBrokerClient, activeName: string): Promise<void> {
47
+ await client.request({ op: "stop", name: activeName, timeoutMs: 2_000 }).catch(() => undefined);
48
+ await client.request({ op: "shutdown" }).catch(() => undefined);
49
+ client.close();
50
+ }
51
+
52
+ describe("broker list", () => {
53
+ it("returns active daemons first and caps recovered terminal history by real exit time", async () => {
54
+ using tempDir = TempDir.createSync("@omp-launch-list-");
55
+ const projectDir = path.join(tempDir.path(), "project");
56
+ const runtimeDir = path.join(tempDir.path(), "runtime");
57
+ await fs.mkdir(projectDir);
58
+
59
+ for (let index = 0; index < TERMINAL_HISTORY_LIMIT + 5; index++) {
60
+ await seedTerminalRecord(runtimeDir, projectDir, terminalSnapshot(index));
61
+ }
62
+
63
+ const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 });
64
+ const activeName = "active-server";
65
+ try {
66
+ const started = await client.request({
67
+ op: "start",
68
+ spec: {
69
+ ...spec(activeName, projectDir),
70
+ args: ["-e", "process.stdin.resume()"],
71
+ },
72
+ });
73
+ expect(started.op).toBe("start");
74
+
75
+ const listed = await client.request({ op: "list" });
76
+ expect(listed.op).toBe("list");
77
+ if (listed.op !== "list") throw new Error(`Unexpected broker result: ${listed.op}`);
78
+
79
+ expect(listed.daemons.map(daemon => daemon.name)).toEqual([
80
+ activeName,
81
+ ...Array.from({ length: TERMINAL_HISTORY_LIMIT }, (_, offset) => `exited-${14 - offset}`),
82
+ ]);
83
+ expect(listed.daemons[0]?.state).toBe("running");
84
+ expect(listed.daemons.at(-1)?.exitedAt).toBe(51);
85
+ } finally {
86
+ await shutdown(client, activeName);
87
+ }
88
+ }, 20_000);
89
+ });
@@ -33,6 +33,12 @@ const MAX_LOG_BYTES = 25 * 1024 * 1024;
33
33
  const LOG_READ_BYTES = 2 * 1024 * 1024;
34
34
  const READINESS_BUFFER_CHARS = 64 * 1024;
35
35
  const RESTART_MAX_DELAY_MS = 30_000;
36
+ /**
37
+ * Cap on terminal (exited/failed) daemons surfaced by `list`. Active daemons
38
+ * are always shown in full; older history is truncated so the response stays
39
+ * bounded over a long-lived project (issue #6517).
40
+ */
41
+ const MAX_TERMINAL_DAEMONS_LISTED = 10;
36
42
  const TOKEN_FILE = "broker.token";
37
43
  const PID_FILE = "broker.pid";
38
44
  const META_FILE = "meta.json";
@@ -95,6 +101,41 @@ function terminalState(state: DaemonSnapshot["state"]): boolean {
95
101
  return state === "exited" || state === "failed";
96
102
  }
97
103
 
104
+ /**
105
+ * Order daemons for the `list` response: non-terminal (active) daemons first,
106
+ * oldest to newest, so the process the user is acting on is immediately visible
107
+ * instead of buried behind exited history; then the most recently exited/failed
108
+ * ones, capped at {@link MAX_TERMINAL_DAEMONS_LISTED} to keep the response from
109
+ * growing without bound. Truncated terminal records stay addressable by name
110
+ * via `describe`/`logs`/`restart`.
111
+ */
112
+ function orderDaemonsForListing(snapshots: DaemonSnapshot[]): DaemonSnapshot[] {
113
+ const active: DaemonSnapshot[] = [];
114
+ const terminal: DaemonSnapshot[] = [];
115
+ for (const snapshot of snapshots) {
116
+ (terminalState(snapshot.state) ? terminal : active).push(snapshot);
117
+ }
118
+ active.sort((left, right) => left.createdAt - right.createdAt);
119
+ terminal.sort((left, right) => (right.exitedAt ?? right.createdAt) - (left.exitedAt ?? left.createdAt));
120
+ return [...active, ...terminal.slice(0, MAX_TERMINAL_DAEMONS_LISTED)];
121
+ }
122
+
123
+ /**
124
+ * Reap a recovered non-detached daemon snapshot in place. Already-terminal
125
+ * records are left untouched so `list` keeps their real {@link DaemonSnapshot.exitedAt}
126
+ * for recency ranking; records that were still alive when the previous broker
127
+ * exited are marked `exited` at `now`, since their process died with that broker
128
+ * (issue #6517). Returns whether the record was reaped.
129
+ */
130
+ function reapRecoveredSnapshot(snapshot: DaemonSnapshot, now: number): boolean {
131
+ if (terminalState(snapshot.state)) return false;
132
+ snapshot.pid = undefined;
133
+ snapshot.state = "exited";
134
+ snapshot.exitedAt = now;
135
+ snapshot.exitReason = "previous broker exited";
136
+ return true;
137
+ }
138
+
98
139
  /** Mirror per-condition readiness progress into the snapshot so clients can see which condition is unmet. */
99
140
  function syncReadyPending(record: ManagedDaemon): void {
100
141
  if (record.snapshot.state !== "starting") {
@@ -402,9 +443,7 @@ class DaemonBroker {
402
443
  await Promise.all([...this.#records.values()].map(record => this.#refreshDetached(record)));
403
444
  return {
404
445
  op: "list",
405
- daemons: [...this.#records.values()]
406
- .sort((left, right) => left.snapshot.createdAt - right.snapshot.createdAt)
407
- .map(record => record.snapshot),
446
+ daemons: orderDaemonsForListing([...this.#records.values()].map(record => record.snapshot)),
408
447
  };
409
448
  }
410
449
  case "logs":
@@ -973,11 +1012,13 @@ class DaemonBroker {
973
1012
  snapshot.state !== "stopping" &&
974
1013
  processRef?.status() === "running";
975
1014
  if (!detached) {
976
- if (processRef) await processRef.terminate({ group: true, gracefulMs: 500, timeoutMs: 2_000 });
977
- snapshot.pid = undefined;
978
- snapshot.state = "exited";
979
- snapshot.exitedAt = Date.now();
980
- snapshot.exitReason = "previous broker exited";
1015
+ // Reap only records that were still alive when the previous broker
1016
+ // exited; already-terminal records keep their real exit time so
1017
+ // `list` ranks exited history by true recency (issue #6517).
1018
+ if (!terminalState(snapshot.state) && processRef) {
1019
+ await processRef.terminate({ group: true, gracefulMs: 500, timeoutMs: 2_000 });
1020
+ }
1021
+ reapRecoveredSnapshot(snapshot, Date.now());
981
1022
  } else if (snapshot.state === "restarting") {
982
1023
  snapshot.state = spec.ready ? "starting" : "running";
983
1024
  }
@@ -179,9 +179,9 @@ export class InputController {
179
179
  // (>= LEFT_DOUBLE_TAP_MAX_GAP_MS) starts a fresh sequence. See
180
180
  // #detectLeftDoubleTap.
181
181
  #leftTapCount = 0;
182
- // Sequential index for `local://attachment-N` references created by large-paste and
183
- // pasted-file attachments. Seeded from 0 and bumped past existing attachment files.
184
- #attachmentCounter = 0;
182
+ // Sequential index for `local://paste-N.md` references created by the large-paste
183
+ // flow. Seeded from 0 and bumped past existing paste files.
184
+ #pasteCounter = 0;
185
185
 
186
186
  #showTinyTitleDownloadProgress(modelKey: string): void {
187
187
  if (!isTinyTitleLocalModelKey(modelKey)) return;
@@ -1711,7 +1711,7 @@ export class InputController {
1711
1711
  `Pasted ${lineCount} lines`,
1712
1712
  [
1713
1713
  { label: WRAPPED_BLOCK, description: "Wrap the text in <attachment> tags, collapsed to a marker" },
1714
- { label: LOCAL_FILE, description: "Save the text to a local://attachment file" },
1714
+ { label: LOCAL_FILE, description: "Save the text to a local://paste file" },
1715
1715
  { label: INLINE, description: "Collapse the text to an inline paste marker" },
1716
1716
  ],
1717
1717
  { helpText: "Esc to paste inline" },
@@ -1740,7 +1740,7 @@ export class InputController {
1740
1740
  }
1741
1741
 
1742
1742
  /**
1743
- * Save a large paste to the session's `local://` store and insert a clean `local://attachment-N`
1743
+ * Save a large paste to the session's `local://` store and insert a clean `local://paste-N.md`
1744
1744
  * reference into the editor so the agent can `read` it on demand — instead of inlining the text or
1745
1745
  * leaking a raw temp path. Falls back to an inline paste marker when the write fails, so the
1746
1746
  * content is never lost.
@@ -1748,7 +1748,7 @@ export class InputController {
1748
1748
  async #attachPasteAsFile(text: string, lineCount: number): Promise<void> {
1749
1749
  try {
1750
1750
  // Mirror the exact mapping the read tool's local:// resolver uses so a later
1751
- // `read local://attachment-N` lands on the file written here.
1751
+ // `read local://paste-N.md` lands on the file written here.
1752
1752
  const localRoot = resolveLocalRoot({
1753
1753
  getArtifactsDir: () => this.ctx.sessionManager.getArtifactsDir(),
1754
1754
  getSessionId: () => this.ctx.sessionManager.getSessionId(),
@@ -1756,8 +1756,8 @@ export class InputController {
1756
1756
  let name: string;
1757
1757
  let filePath: string;
1758
1758
  do {
1759
- this.#attachmentCounter++;
1760
- name = `attachment-${this.#attachmentCounter}`;
1759
+ this.#pasteCounter++;
1760
+ name = `paste-${this.#pasteCounter}.md`;
1761
1761
  filePath = path.join(localRoot, name);
1762
1762
  } while (await Bun.file(filePath).exists());
1763
1763
  await Bun.write(filePath, text);
@@ -209,6 +209,7 @@ import type {
209
209
  InteractiveModeContext,
210
210
  InteractiveModeInitOptions,
211
211
  InteractiveSelectorDialogOptions,
212
+ RenderSessionContextOptions,
212
213
  SubmittedUserInput,
213
214
  TodoItem,
214
215
  TodoPhase,
@@ -1740,6 +1741,60 @@ export class InteractiveMode implements InteractiveModeContext {
1740
1741
  const context = this.viewSession.buildTranscriptSessionContext({
1741
1742
  collapseCompactedHistory: settings.get("display.collapseCompacted"),
1742
1743
  });
1744
+ const preservedLiveToolCallIds = new Set<string>();
1745
+ // A preserved pending-tool component whose result has already landed in
1746
+ // the replayed transcript is re-rendered by `renderSessionContext` itself
1747
+ // (the toolResult message reconstructs the block with its output). Keeping
1748
+ // it in the live set too re-appends a second identical block below the
1749
+ // replayed one — the tool call renders twice (#6516). The preservation
1750
+ // above assumes every pending-tool component is still dangling (its result
1751
+ // lives outside `state.messages`), which stops holding the instant the
1752
+ // result is persisted while the component lingers in `pendingTools` (a
1753
+ // rebuild racing tool-completion, a background/displaceable snapshot).
1754
+ // Drop the already-resolved ones and let the replay own them; only
1755
+ // genuinely in-flight (dangling, replay-stripped) calls still need
1756
+ // preserving.
1757
+ for (const message of context.messages) {
1758
+ if (message.role !== "toolResult") continue;
1759
+ const resolved = livePendingTools.get(message.toolCallId);
1760
+ if (!resolved) continue;
1761
+ // A background task's initial `async.state === "running"` result is
1762
+ // persisted while `EventController#handleToolExecutionEnd` deliberately
1763
+ // keeps its component in `pendingTools` so a later
1764
+ // `tool_execution_update`/`_end` settles it. Such a handle is still
1765
+ // live — dropping it would strand those updates on the running snapshot
1766
+ // — so keep it and let the live component retain ownership; only
1767
+ // terminal results are owned by the replay. (Cast mirrors the async
1768
+ // detail reads in tool-execution.ts / event-controller.ts.)
1769
+ const details = message.details as { async?: { state?: string } } | undefined;
1770
+ if (details?.async?.state === "running") {
1771
+ preservedLiveToolCallIds.add(message.toolCallId);
1772
+ continue;
1773
+ }
1774
+ livePendingTools.delete(message.toolCallId);
1775
+ // A `ReadToolGroupComponent` is shared by every read id it renders
1776
+ // (ui-helpers sets the same group for each collapsed read call). While a
1777
+ // sibling read id still points at it the component must stay on screen
1778
+ // and preserved — splicing it here would detach the pending read's
1779
+ // display and strand its future result on an off-screen component.
1780
+ // Splice only once no remaining pending id shares it.
1781
+ let stillShared = false;
1782
+ for (const other of livePendingTools.values()) {
1783
+ if (other === resolved) {
1784
+ stillShared = true;
1785
+ break;
1786
+ }
1787
+ }
1788
+ if (stillShared) {
1789
+ // The shared component still owns this completed member as well as
1790
+ // its pending sibling. Suppress the replay copy so the group remains
1791
+ // a single on-screen block while future results keep routing to it.
1792
+ preservedLiveToolCallIds.add(message.toolCallId);
1793
+ continue;
1794
+ }
1795
+ const index = liveComponents.indexOf(resolved as unknown as Component);
1796
+ if (index >= 0) liveComponents.splice(index, 1);
1797
+ }
1743
1798
  // Prune the settled-component cache to the messages this rebuild will
1744
1799
  // actually render. Message objects stay strongly reachable through
1745
1800
  // session entries for the whole session, so entries for compacted-away
@@ -1751,7 +1806,10 @@ export class InteractiveMode implements InteractiveModeContext {
1751
1806
  if (component) retained.set(message, component);
1752
1807
  }
1753
1808
  this.transcriptMessageComponents = retained;
1754
- this.renderSessionContext(context, { reuseSettledComponents: options.reuseSettledComponents });
1809
+ this.renderSessionContext(context, {
1810
+ reuseSettledComponents: options.reuseSettledComponents,
1811
+ preservedLiveToolCallIds,
1812
+ });
1755
1813
  for (const child of liveComponents) {
1756
1814
  this.chatContainer.addChild(child);
1757
1815
  }
@@ -4294,10 +4352,7 @@ export class InteractiveMode implements InteractiveModeContext {
4294
4352
  return this.#uiHelpers.addMessageToChat(message, options);
4295
4353
  }
4296
4354
 
4297
- renderSessionContext(
4298
- sessionContext: SessionContext,
4299
- options?: { updateFooter?: boolean; populateHistory?: boolean; reuseSettledComponents?: boolean },
4300
- ): void {
4355
+ renderSessionContext(sessionContext: SessionContext, options?: RenderSessionContextOptions): void {
4301
4356
  for (const message of sessionContext.messages) {
4302
4357
  this.noteDisplayableThinkingContent(message);
4303
4358
  }
@@ -92,6 +92,14 @@ export interface InteractiveModeInitOptions {
92
92
 
93
93
  export type InteractiveSelectorDialogOptions = ExtensionUIDialogOptions & Pick<HookSelectorOptions, "disabledIndices">;
94
94
 
95
+ export interface RenderSessionContextOptions {
96
+ updateFooter?: boolean;
97
+ populateHistory?: boolean;
98
+ reuseSettledComponents?: boolean;
99
+ /** Tool calls whose existing live component remains the sole render owner across a rebuild. */
100
+ preservedLiveToolCallIds?: ReadonlySet<string>;
101
+ }
102
+
95
103
  export interface InteractiveModeContext {
96
104
  // UI access
97
105
  ui: TUI;
@@ -310,10 +318,7 @@ export interface InteractiveModeContext {
310
318
  reuseSettledComponent?: boolean;
311
319
  },
312
320
  ): Component[];
313
- renderSessionContext(
314
- sessionContext: SessionContext,
315
- options?: { updateFooter?: boolean; populateHistory?: boolean; reuseSettledComponents?: boolean },
316
- ): void;
321
+ renderSessionContext(sessionContext: SessionContext, options?: RenderSessionContextOptions): void;
317
322
  renderInitialMessages(options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean }): void;
318
323
  getUserMessageText(message: Message): string;
319
324
  findLastAssistantMessage(): AssistantMessage | undefined;
@@ -33,7 +33,7 @@ import { UserMessageComponent } from "../../modes/components/user-message";
33
33
  import { decodeStreamedToolArgs, streamingStringKeysForTool } from "../../modes/controllers/tool-args-reveal";
34
34
  import { materializeImageReferenceLinksSync } from "../../modes/image-references";
35
35
  import { theme } from "../../modes/theme/theme";
36
- import type { CompactionQueuedMessage, InteractiveModeContext } from "../../modes/types";
36
+ import type { CompactionQueuedMessage, InteractiveModeContext, RenderSessionContextOptions } from "../../modes/types";
37
37
  import {
38
38
  BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE,
39
39
  type CustomMessage,
@@ -72,12 +72,6 @@ type AddMessageOptions = {
72
72
  reuseSettledComponent?: boolean;
73
73
  };
74
74
 
75
- type RenderSessionContextOptions = {
76
- updateFooter?: boolean;
77
- populateHistory?: boolean;
78
- reuseSettledComponents?: boolean;
79
- };
80
-
81
75
  function imageLinksForMessage(
82
76
  message: Extract<AgentMessage, { role: "developer" | "user" }>,
83
77
  putBlobSync: InteractiveModeContext["sessionManager"]["putBlobSync"],
@@ -422,8 +416,12 @@ export class UiHelpers {
422
416
  if (content.type !== "toolCall") {
423
417
  continue;
424
418
  }
425
- resolveWaitingPoll(content.name);
426
419
  const afterToolSegment = timeline.afterToolCalls.get(content.id);
420
+ if (options.preservedLiveToolCallIds?.has(content.id)) {
421
+ appendAssistantSegment(afterToolSegment);
422
+ continue;
423
+ }
424
+ resolveWaitingPoll(content.name);
427
425
 
428
426
  if (content.name === "read" && readArgsCollapseIntoGroup(content.arguments)) {
429
427
  if (hasErrorStop && errorMessage) {
@@ -538,6 +536,7 @@ export class UiHelpers {
538
536
  pendingUsageTtft = message.ttft;
539
537
  pendingUsageTimestamp = message.timestamp;
540
538
  } else if (message.role === "toolResult") {
539
+ if (options.preservedLiveToolCallIds?.has(message.toolCallId)) continue;
541
540
  const pendingReadComponent = this.ctx.pendingTools.get(message.toolCallId);
542
541
  const isReadGroupResult =
543
542
  message.toolName === "read" &&
@@ -1,3 +1,3 @@
1
1
  <system-reminder>
2
- This is a preview. Finalize it now with the `write` tool: write a one-sentence reason as plain text to `xd://resolve` to APPLY it, or to `xd://reject` to DISCARD it.
2
+ The `{{toolName}}` result above is a PREVIEW — no files were changed. Finalize it now with the `write` tool: write a one-sentence reason as plain text to `xd://resolve` to APPLY it, or to `xd://reject` to DISCARD it.
3
3
  </system-reminder>
@@ -13,7 +13,7 @@ Worth it when the task benefits from decomposition + parallel coverage, or from
13
13
  <helpers>
14
14
  State persists across eval calls, so scout in one call and fan out in the next. Every eval call has:
15
15
 
16
- - `agent(prompt, *, agent="task", model=None, label=None, schema=None, isolated=None, apply=None, merge=None, handle=False)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent` picks a discovered agent ("scout", "reviewer", …); `label` names the artifact. Shared background goes in a `local://` file referenced from each prompt, not a parameter. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes. Recursion follows `task.maxRecursionDepth` (default 2; a negative value disables the cap): main agent depth = 0, each `agent()` child increments depth by 1, and, when the cap is non-negative, a spawner may call `agent()` only while its current `taskDepth < cap`. Pass `isolated=True` to run the spawn in a copy-on-write worktree so parallel `agent()` calls can edit overlapping files safely — strict opt-in, mirrors the `task` tool, defaults off regardless of `task.isolation.mode`; `isolated=True` while the setting is `"none"` errors out instead of silently downgrading. With isolation, `apply=False` keeps changes in the worktree, and `merge=False` forces patch mode even when the setting is `"branch"`. Captured root patch path, branch name, nested repo patches, and apply summary reach the workflow through `handle=True` — combine it with `apply=False` (or `apply=False, schema=…`) and read `node["patch_path"]`, `node["branch_name"]`, `node["nested_patches"]`, `node["changes_applied"]`, `node["isolation_summary"]` (JS: same keys camelCased) to recover artifacts.
16
+ - `agent(prompt, *, agent="task", label=None, schema=None, isolated=None, apply=None, merge=None, handle=False)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent` picks a discovered agent ("scout", "reviewer", …); `label` names the artifact. Shared background goes in a `local://` file referenced from each prompt, not a parameter. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes. Recursion follows `task.maxRecursionDepth` (default 2; a negative value disables the cap); deeper calls raise an error.
17
17
  - `parallel(thunks)` — run zero-arg callables concurrently through a bounded pool, preserving input order; returns once all finish. The pool is bounded by the session's `task` concurrency — don't hand-tune it; fan out as wide as the work divides. A thunk that raises propagates — wrap risky work in `try/except` inside the thunk to keep partial results. In a loop, bind each closure's value with a default arg (`lambda d=d: …`) or every thunk captures the last one.
18
18
  - `pipeline(items, *stages)` — map items through `stages` left-to-right. There is a BARRIER between stages: ALL items clear stage N before stage N+1 begins. Each stage is a one-arg callable; stage 1 gets the original item, later stages get the previous result. Same pool width as `parallel()`.
19
19
  - `completion(prompt, *, model="default", system=None, schema=None)` — oneshot, stateless model call (no tools, no history). Tiers: "smol", "default", "slow". Cheap classification/scoring inside a fan-out.
@@ -1,4 +1,4 @@
1
- Structural AST-aware rewrites via ast-grep. Use for codemods where text replace is unsafe. Narrow each call to one language.
1
+ Structural AST-aware rewrites via ast-grep. Use for codemods where text replace is unsafe. Mixed-language paths are fine: each file is parsed in its own language, and a pattern only rewrites files it parses in.
2
2
 
3
3
  - Metavariables in `pat` (`$A`, `$$$ARGS`) substitute into `out`.
4
4
  - **Patterns match AST structure, not text.** `$NAME` = one node; `$_` = unbound; `$$$NAME` = zero-or-more.
@@ -7,4 +7,5 @@ Structural AST-aware rewrites via ast-grep. Use for codemods where text replace
7
7
  - Rewrite patterns MUST parse as single AST node. Non-standalone → wrap: `class $_ { … }`.
8
8
  - TS: tolerate annotations — `async function $NAME($$$ARGS): $_ { $$$BODY }`. Delete with empty `out`: `{"pat":"console.log($$$)","out":""}`.
9
9
  - 1:1 substitution — no splitting/merging captures.
10
+ - Matches are STAGED as a proposal, not applied: finalize by writing a one-sentence reason to `xd://resolve` (apply) or `xd://reject` (discard).
10
11
  - Parse issues → malformed rewrite, not clean no-op. For one-off text edits, prefer the Edit tool.
@@ -9,6 +9,7 @@ Use ONLY for: single binary call or short pipeline that COMPUTES a fact (`wc -l`
9
9
  - `pty: true` only for real terminal needs (`sudo`, `ssh`); default `false`.
10
10
  - Multiple calls run concurrently; NEVER split order-dependent commands — chain with `&&` in one call (`;` only to continue past failure).
11
11
  - Internal URIs (`skill://`, `agent://`, …) auto-resolve to FS paths.
12
+ {{#if hasShellBuiltins}}- aux utils available: mkdir, head, tail, wc, sort, ls, find, grep, rg, fd, cat, uniq, base64, cmp, md5sum, sha{1,224,256,384,512}sum, b2sum, basename, dirname, readlink, realpath, touch, stat, date, mktemp, seq, yes, printenv, truncate, tac, nproc, uname, whoami, hostname, which, diff, cut, tee, tr, paste, comm, sed, xargs, jq, rm, mv, ln, ts, sponge, ifne, isutf8, combine{{#unless isWindows}}, errno{{/unless}}{{/if}}
12
13
  {{#if asyncEnabled}}- `async: true` defers reporting for finite commands needing no later input.{{/if}}
13
14
  </instruction>
14
15
 
@@ -20,9 +20,9 @@ tool.<name>(args) → unknown
20
20
  Invoke any session tool; `args` = its parameter object.
21
21
  completion(prompt, model?="default"|"smol"|"slow", system?=None, schema?=None) → str | dict
22
22
  Oneshot, stateless (no history/tools). `model`: "smol" fast | "default" session | "slow" most capable. `schema` (JSON-Schema) → parsed object.
23
- {{#if spawns}}agent(prompt, agent?="{{spawnDefaultAgent}}", model?=None, label?=None, schema?=None, schema{{#if js}}Mode{{else}}_mode{{/if}}?="permissive", isolated?=None, apply?=None, merge?=None, handle?=False) → str | dict
23
+ {{#if spawns}}agent(prompt, agent?="{{spawnDefaultAgent}}", label?=None, schema?=None, schema{{#if js}}Mode{{else}}_mode{{/if}}?="permissive", isolated?=None, apply?=None, merge?=None, handle?=False) → str | dict
24
24
  Run a subagent → final output. `agent` selects a discovered agent; omit it to use `{{spawnDefaultAgent}}`.{{#if spawnAllowedAgentsText}} Allowed agents: {{spawnAllowedAgentsText}}.{{/if}} `schema` overrides agent/session schemas; `schemaMode`/`schema_mode`: "permissive" | "strict". Effective schemas return parsed data. `isolated` requests a worktree; `apply`/`merge` control its changes. Background via `local://` files named in the prompt. `handle` → { text, output, handle: "agent://<id>", id, agent }, parsed `data` when structured.
25
- {{#if js}} JS: ONE trailing object — agent(prompt, { agent, model, label, schema, schemaMode, isolated, apply, merge, handle }).{{/if}}
25
+ {{#if js}} JS: ONE trailing object — agent(prompt, { agent, label, schema, schemaMode, isolated, apply, merge, handle }).{{/if}}
26
26
  {{/if}}
27
27
  parallel(thunks) → list pipeline(items, ...stages) → list
28
28
  log(message) → None phase(title) → None
@@ -14,6 +14,9 @@ Agents marked BLOCKING run inline — results return in this call; non-blocking
14
14
  - **Agent typing:** Pick each item's `agent` type. Read-only research MUST use `agent: "scout"` (faster model). Use default worker only when no specialist fits.
15
15
  - **No overhead:** Each `task` MUST instruct its agent to skip formatters, linters, and project-wide test suites. Run those once at the end.
16
16
  - **One-pass:** Prefer agents that investigate AND edit in one pass; spin a read-only scout only when affected files are genuinely unknown.
17
+ - **Overlap is safe:** Concurrent edits to the same files auto-resolve{{#if ircEnabled}}; worst case, agents coordinate directly over IRC{{/if}}. NEVER shrink or serialize a batch to avoid file overlap. Two prerequisites:
18
+ 1. Every task MUST skip validation (build/lint/tests) — validating mid-flight blocks agents on each other's edits.
19
+ 2. Decide cross-task contracts up front (e.g. the interface A implements and B consumes) and state them in the {{#if batchEnabled}}batch `context`{{else}}task{{/if}}, not left for agents to negotiate.
17
20
 
18
21
  # Inputs
19
22
  {{#if batchEnabled}}
@@ -22,7 +25,7 @@ Agents marked BLOCKING run inline — results return in this call; non-blocking
22
25
  - `name`: A stable CamelCase identifier (≤32 chars), used to address the agent (IRC, job ids). Generated automatically if omitted.
23
26
  - `agent`: The agent type running this item (e.g. `scout`, `reviewer`). Omitting it gives you the general-purpose worker (`{{defaultAgent}}`) — NEVER pass that name explicitly. Only omit it after checking the agent list below and finding no specialist that fits.{{#if allowedAgentsText}} Current spawn policy allows: {{allowedAgentsText}}.{{/if}}
24
27
  - `task`: Complete, self-contained instructions. One-liners or missing acceptance criteria are PROHIBITED.
25
- - `model`: Explicit non-empty model selector or non-empty fallback chain for this spawn. A `:reasoning` suffix is preserved. Overrides agent-specific model settings.
28
+ - `effort`: Scale w/ complexity of this task: `"lo"`|`"med"`|`"hi"`
26
29
  - `outputSchema`: Invocation-specific JSON Schema. Overrides the selected agent and parent-session schemas.
27
30
  - `schemaMode`: `"permissive"` (default) accepts a retry-exhausted invalid result with a warning; `"strict"` fails it.
28
31
  {{#if isolationEnabled}}
@@ -36,7 +39,7 @@ Agents marked BLOCKING run inline — results return in this call; non-blocking
36
39
  - `name`: A stable CamelCase identifier (≤32 chars), used to address the agent (IRC, job ids). Generated automatically if omitted.
37
40
  - `agent`: The agent type to spawn (e.g. `scout`, `reviewer`). Omitting it gives you the general-purpose worker (`{{defaultAgent}}`) — NEVER pass that name explicitly. Only omit it after checking the agent list below and finding no specialist that fits.{{#if allowedAgentsText}} Current spawn policy allows: {{allowedAgentsText}}.{{/if}}
38
41
  - `task`: Complete, self-contained instructions. One-liners or missing acceptance criteria are PROHIBITED.
39
- - `model`: Explicit non-empty model selector or non-empty fallback chain for this spawn. A `:reasoning` suffix is preserved. Overrides agent-specific model settings.
42
+ - `effort`: Scale w/ complexity of this task: `"lo"`|`"med"`|`"hi"`
40
43
  - `outputSchema`: Invocation-specific JSON Schema. Overrides the selected agent and parent-session schemas.
41
44
  - `schemaMode`: `"permissive"` (default) accepts a retry-exhausted invalid result with a warning; `"strict"` fails it.
42
45
  {{#if isolationEnabled}}
@@ -3,4 +3,6 @@ Searches the web for up-to-date information beyond knowledge cutoff.
3
3
  <instruction>
4
4
  - You SHOULD prefer primary sources (papers, official docs) and corroborate key claims with multiple sources
5
5
  - You MUST include links for cited sources in the final response
6
+ - NEVER use for content that is programmatically accessible or whose URL you already know (GitHub repos/issues, a known arXiv paper, a Wikipedia page, official docs) — `read` the URL directly instead
7
+ - `query` supports Google-style directives on every provider: `site:`/`-site:`, `after:`/`before:` (`YYYY-MM-DD`), `inurl:`, `intitle:`, `filetype:`, `"exact phrase"`, `-term`, `OR`. Constraints map to native provider filters where available; otherwise results are filtered leniently — a constraint matching nothing is relaxed and reported instead of returning zero results.
6
8
  </instruction>
package/src/sdk.ts CHANGED
@@ -2594,9 +2594,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2594
2594
  // ExtensionToolWrapper, so the registry holds them unwrapped. The normal
2595
2595
  // `write xd://<tool>` path runs approval through the wrapped `write` tool's
2596
2596
  // tier gate, but Cursor invokes advertised devices via `tool.execute()`
2597
- // directly so wrap unwrapped devices here to keep the approval/deny/prompt
2598
- // gate. Dynamic mounts (custom/MCP) already come from the wrapped registry.
2599
- const resolveCursorDevice = (name: string): AgentTool | undefined => {
2597
+ // directly, and the agent loop's fallback resolver executes mounted
2598
+ // devices the model called by their top-level name so wrap unwrapped
2599
+ // devices here to keep the approval/deny/prompt gate. Dynamic mounts
2600
+ // (custom/MCP) already come from the wrapped registry.
2601
+ const resolveDeviceTool = (name: string): AgentTool | undefined => {
2600
2602
  const device = toolSession.xdevRegistry?.get(name);
2601
2603
  if (!device) return undefined;
2602
2604
  return device instanceof ExtensionToolWrapper ? device : new ExtensionToolWrapper(device, extensionRunner);
@@ -2604,7 +2606,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2604
2606
  const cursorExecHandlers = new CursorExecHandlers({
2605
2607
  cwd,
2606
2608
  tools: toolRegistry,
2607
- getTool: resolveCursorDevice,
2609
+ getTool: resolveDeviceTool,
2608
2610
  getToolContext: () => toolContextStore.getContext(),
2609
2611
  emitEvent: event => cursorEventEmitter?.(event),
2610
2612
  });
@@ -3012,6 +3014,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3012
3014
  cursorExecHandlers,
3013
3015
  getCursorTools: () => [...(toolSession.xdevRegistry?.list() ?? [])],
3014
3016
  transformToolCallArguments,
3017
+ resolveFallbackTool: resolveDeviceTool,
3015
3018
  intentTracing: !!intentField,
3016
3019
  pruneToolDescriptions: inlineToolDescriptors,
3017
3020
  dialect: resolveDialect(settings.get("tools.format"), model),
@@ -3360,6 +3363,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3360
3363
  getToolContext: toolCall => toolContextStore.getContext(toolCall),
3361
3364
  streamFn: settingsAwareStreamFn,
3362
3365
  transformToolCallArguments,
3366
+ resolveFallbackTool: resolveDeviceTool,
3363
3367
  intentTracing: !!intentField,
3364
3368
  pruneToolDescriptions: inlineToolDescriptors,
3365
3369
  dialect: resolveDialect(settings.get("tools.format"), captureModel),
@@ -2406,6 +2406,20 @@ export class AgentSession {
2406
2406
  baseUrl: this.#modelRegistry.getProviderBaseUrl?.(assistantMsg.provider),
2407
2407
  });
2408
2408
  }
2409
+ // Broker deployments: report this request's burn so the broker can
2410
+ // attribute token usage per install. No-op with a local auth store.
2411
+ this.#modelRegistry.authStorage.recordObservedUsage({
2412
+ provider: assistantMsg.provider,
2413
+ model: assistantMsg.model,
2414
+ at: assistantMsg.timestamp,
2415
+ usage: {
2416
+ input: assistantMsg.usage.input,
2417
+ output: assistantMsg.usage.output,
2418
+ cacheRead: assistantMsg.usage.cacheRead,
2419
+ cacheWrite: assistantMsg.usage.cacheWrite,
2420
+ },
2421
+ costUsd: assistantMsg.usage.cost.total,
2422
+ });
2409
2423
  }
2410
2424
  if (event.message.role === "toolResult") {
2411
2425
  const { toolName, toolCallId, isError, content } = event.message;
@@ -44,7 +44,13 @@ export function isTerminalTextAssistantAnswer(message: AgentMessage | undefined)
44
44
  if (part.text.trim().length > 0) hasText = true;
45
45
  continue;
46
46
  }
47
- if (part.type === "thinking" || part.type === "redactedThinking" || part.type === "fallback") continue;
47
+ if (
48
+ part.type === "thinking" ||
49
+ part.type === "redactedThinking" ||
50
+ part.type === "fallback" ||
51
+ part.type === "anthropicServerTool"
52
+ )
53
+ continue;
48
54
  return false;
49
55
  }
50
56
  return hasText;
@@ -185,7 +185,7 @@ export class StreamingEditGuard {
185
185
  #abortForAutoGeneratedPath(toolCall: ToolCall, filePath: string, resolvedPath: string): void {
186
186
  if (this.#lastToolCallId === toolCall.id) return;
187
187
  this.#lastToolCallId = toolCall.id;
188
- void assertEditableFile(resolvedPath, filePath).catch(error => {
188
+ void assertEditableFile(resolvedPath, filePath, this.#host.settings).catch(error => {
189
189
  if (!(error instanceof ToolError) || this.#lastToolCallId !== toolCall.id) return;
190
190
  if (!this.#abortTriggered) {
191
191
  this.#abortTriggered = true;
@@ -46,7 +46,7 @@ import type { AuthStorage } from "../session/auth-storage";
46
46
  import { SKILL_PROMPT_MESSAGE_TYPE, USER_INTERRUPT_LABEL } from "../session/messages";
47
47
  import { SessionManager } from "../session/session-manager";
48
48
  import { truncateTail } from "../session/streaming-output";
49
- import type { ConfiguredThinkingLevel } from "../thinking";
49
+ import { type ConfiguredThinkingLevel, resolveTaskEffortLevel, type TaskEffort } from "../thinking";
50
50
  import type { ContextFileEntry, ToolSession } from "../tools";
51
51
  import { resolveEvalBackends } from "../tools/eval-backends";
52
52
  import { isIrcEnabled } from "../tools/hub";
@@ -323,6 +323,8 @@ export interface ExecutorOptions {
323
323
  */
324
324
  parentActiveModelPattern?: string;
325
325
  thinkingLevel?: ConfiguredThinkingLevel;
326
+ /** Caller-requested coarse effort (`lo`/`med`/`hi`); maps onto the resolved model's supported thinking range and wins over {@link thinkingLevel}. */
327
+ effort?: TaskEffort;
326
328
  /** Schema used to validate the final structured completion. */
327
329
  outputSchema?: unknown;
328
330
  /** Enforcement policy for {@link outputSchema}; defaults to legacy permissive behavior. */
@@ -2644,16 +2646,22 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2644
2646
  if (model?.contextWindow && model.contextWindow > 0) {
2645
2647
  progress.contextWindow = model.contextWindow;
2646
2648
  }
2649
+ // Caller-requested coarse effort maps onto the resolved model's
2650
+ // supported range; undefined (no effort, or no controllable effort
2651
+ // surface) falls through to the normal selectors below.
2652
+ const effortLevel = options.effort !== undefined ? resolveTaskEffortLevel(model, options.effort) : undefined;
2647
2653
  if (model) {
2648
- progress.resolvedModel = explicitThinkingLevel
2649
- ? formatModelSelectorValue(formatModelStringWithRouting(model), resolvedThinkingLevel)
2650
- : formatModelStringWithRouting(model);
2654
+ const displayLevel = effortLevel ?? (explicitThinkingLevel ? resolvedThinkingLevel : undefined);
2655
+ progress.resolvedModel =
2656
+ displayLevel !== undefined
2657
+ ? formatModelSelectorValue(formatModelStringWithRouting(model), displayLevel)
2658
+ : formatModelStringWithRouting(model);
2651
2659
  }
2652
- // Precedence: explicit `:level` suffix on the resolved model pattern >
2653
- // agent-definition default (e.g. task's `auto`) > pattern-derived level.
2654
- const effectiveThinkingLevel = explicitThinkingLevel
2655
- ? resolvedThinkingLevel
2656
- : (thinkingLevel ?? resolvedThinkingLevel);
2660
+ // Precedence: caller `effort` > explicit `:level` suffix on the resolved
2661
+ // model pattern > agent-definition default (e.g. task's `auto`) >
2662
+ // pattern-derived level.
2663
+ const effectiveThinkingLevel =
2664
+ effortLevel ?? (explicitThinkingLevel ? resolvedThinkingLevel : (thinkingLevel ?? resolvedThinkingLevel));
2657
2665
  resolvedAt = performance.now();
2658
2666
  // Per-agent prewalk: the agent definition's `prewalk` frontmatter or the
2659
2667
  // `task.agentPrewalk` settings override hands the subagent off to a