@oh-my-pi/pi-coding-agent 17.0.5 → 17.0.6

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 (46) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/dist/cli.js +2838 -2917
  3. package/dist/types/exec/bash-executor.d.ts +1 -0
  4. package/dist/types/extensibility/extensions/load-errors.d.ts +3 -0
  5. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
  6. package/dist/types/modes/components/status-line/component.d.ts +8 -0
  7. package/dist/types/modes/components/usage-row.d.ts +1 -1
  8. package/dist/types/modes/rpc/rpc-client.d.ts +1 -1
  9. package/dist/types/modes/rpc/rpc-frame.d.ts +9 -0
  10. package/dist/types/modes/setup-wizard/index.d.ts +1 -1
  11. package/dist/types/modes/setup-wizard/scenes/model.d.ts +3 -0
  12. package/dist/types/session/agent-session.d.ts +23 -5
  13. package/dist/types/session/session-paths.d.ts +21 -4
  14. package/dist/types/tools/xdev.d.ts +5 -3
  15. package/dist/types/vibe/__tests__/token-rate.test.d.ts +1 -0
  16. package/dist/types/vibe/runtime.d.ts +23 -0
  17. package/package.json +12 -12
  18. package/src/exec/bash-executor.ts +68 -8
  19. package/src/extensibility/extensions/load-errors.ts +13 -0
  20. package/src/extensibility/plugins/legacy-pi-compat.ts +41 -6
  21. package/src/main.ts +8 -0
  22. package/src/modes/components/chat-transcript-builder.ts +10 -1
  23. package/src/modes/components/status-line/component.ts +55 -1
  24. package/src/modes/components/usage-row.ts +17 -1
  25. package/src/modes/controllers/command-controller.ts +41 -1
  26. package/src/modes/controllers/event-controller.ts +6 -1
  27. package/src/modes/interactive-mode.ts +62 -52
  28. package/src/modes/noninteractive-dispose.test.ts +2 -0
  29. package/src/modes/print-mode.ts +60 -0
  30. package/src/modes/rpc/rpc-client.ts +76 -35
  31. package/src/modes/rpc/rpc-frame.ts +156 -0
  32. package/src/modes/rpc/rpc-mode.ts +4 -2
  33. package/src/modes/setup-wizard/index.ts +2 -0
  34. package/src/modes/setup-wizard/scenes/model.ts +134 -0
  35. package/src/modes/utils/ui-helpers.ts +6 -1
  36. package/src/prompts/system/workflow-notice.md +89 -74
  37. package/src/session/agent-session.ts +89 -26
  38. package/src/session/session-manager.ts +34 -3
  39. package/src/session/session-paths.ts +38 -9
  40. package/src/task/executor.ts +45 -0
  41. package/src/tools/xdev.ts +11 -4
  42. package/src/tools/yield.ts +4 -1
  43. package/src/{modes/components/status-line → utils}/token-rate.ts +6 -0
  44. package/src/vibe/__tests__/token-rate.test.ts +96 -0
  45. package/src/vibe/runtime.ts +50 -0
  46. /package/dist/types/{modes/components/status-line → utils}/token-rate.d.ts +0 -0
@@ -264,7 +264,7 @@ import {
264
264
  estimateToolSchemaTokens,
265
265
  } from "../modes/utils/context-usage";
266
266
  import { containsWorkflow, renderWorkflowNotice } from "../modes/workflow";
267
- import { resolveApprovedPlan } from "../plan-mode/approved-plan";
267
+ import { type PlanApprovalDetails, resolveApprovedPlan } from "../plan-mode/approved-plan";
268
268
  import { createPlanReadMatcher } from "../plan-mode/plan-protection";
269
269
  import type { PlanModeState } from "../plan-mode/state";
270
270
  import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" };
@@ -2567,6 +2567,24 @@ export class AgentSession {
2567
2567
  this.setPlanProposalHandler(title => this.#approvePlanYoloProposal(title));
2568
2568
  }
2569
2569
 
2570
+ /** Validate the active plan artifact and shape an `xd://propose` result for review-mode hosts. */
2571
+ async preparePlanForReview(title: string): Promise<AgentToolResult<PlanApprovalDetails>> {
2572
+ const state = this.getPlanModeState();
2573
+ if (!state?.enabled) {
2574
+ throw new ToolError("Plan mode is not active.");
2575
+ }
2576
+ const { planFilePath, title: resolvedTitle } = await resolveApprovedPlan({
2577
+ suppliedTitle: title,
2578
+ statePlanFilePath: state.planFilePath,
2579
+ readPlan: url => this.#readPlanFile(url),
2580
+ listPlanFiles: () => this.#listPlanFiles(),
2581
+ });
2582
+ return {
2583
+ content: [{ type: "text", text: "Plan ready for review." }],
2584
+ details: { planFilePath, title: resolvedTitle, planExists: true },
2585
+ };
2586
+ }
2587
+
2570
2588
  /**
2571
2589
  * Plan-proposal handler while PlanYolo's plan phase is active. Auto-approves
2572
2590
  * the instant the model writes the plan slug/title to `xd://propose` — no
@@ -2587,8 +2605,8 @@ export class AgentSession {
2587
2605
  const { planFilePath, title: resolvedTitle } = await resolveApprovedPlan({
2588
2606
  suppliedTitle: title,
2589
2607
  statePlanFilePath: state.planFilePath,
2590
- readPlan: url => this.#readPlanYoloFile(url),
2591
- listPlanFiles: () => this.#listPlanYoloFiles(),
2608
+ readPlan: url => this.#readPlanFile(url),
2609
+ listPlanFiles: () => this.#listPlanFiles(),
2592
2610
  });
2593
2611
  this.setPlanModeState(undefined);
2594
2612
  const previousTools = this.#planYoloPreviousTools;
@@ -2623,7 +2641,7 @@ export class AgentSession {
2623
2641
  };
2624
2642
  }
2625
2643
 
2626
- async #readPlanYoloFile(planFilePath: string): Promise<string | null> {
2644
+ async #readPlanFile(planFilePath: string): Promise<string | null> {
2627
2645
  const resolvedPath = planFilePath.startsWith("local:")
2628
2646
  ? resolveLocalUrlToPath(normalizeLocalScheme(planFilePath), this.#localProtocolOptions())
2629
2647
  : resolveToCwd(planFilePath, this.sessionManager.getCwd());
@@ -2637,7 +2655,7 @@ export class AgentSession {
2637
2655
 
2638
2656
  /** `local://` URLs of plan files in the session-local root, newest first —
2639
2657
  * a fallback for `resolveApprovedPlan` when the agent dropped `extra.title`. */
2640
- async #listPlanYoloFiles(): Promise<string[]> {
2658
+ async #listPlanFiles(): Promise<string[]> {
2641
2659
  const localRoot = resolveLocalUrlToPath("local://", this.#localProtocolOptions());
2642
2660
  try {
2643
2661
  const entries = await fs.promises.readdir(localRoot, { withFileTypes: true });
@@ -7536,11 +7554,54 @@ export class AgentSession {
7536
7554
  * Changes take effect before the next model call.
7537
7555
  */
7538
7556
  async setActiveToolsByName(toolNames: string[]): Promise<void> {
7539
- const mounted = this.#mountedXdevToolNames;
7540
7557
  const normalized = normalizeToolNames(toolNames);
7558
+ // Transport-write eligibility keys off the *current* active set: an ordinary
7559
+ // selection change should not demote `write` unless it is already active.
7560
+ await this.#applyToolPresentation(
7561
+ normalized,
7562
+ this.#mountedXdevToolNames,
7563
+ this.getActiveToolNames().includes("write"),
7564
+ );
7565
+ }
7566
+
7567
+ /**
7568
+ * Restore an enabled tool set with its exact top-level versus `xd://` partition.
7569
+ *
7570
+ * Both inputs are required because {@link setActiveToolsByName} only receives the
7571
+ * enabled name list and classifies mounts from the *current* `#mountedXdevToolNames`.
7572
+ * Rollback/restore callers must pass the snapshotted mounted subset so names that
7573
+ * were top-level stay pinned (`#runtimeSelectedToolNames`) and names that were under
7574
+ * `xd://` remain mount-eligible, even when the live mount set has drifted.
7575
+ *
7576
+ * Names outside `mountedToolNames` are pinned top-level for this application;
7577
+ * names in the mounted subset remain eligible for xdev mounting. Delegates the
7578
+ * actual apply through `#applyActiveToolsByName` and restores the prior runtime
7579
+ * selection if that apply throws.
7580
+ */
7581
+ async setActiveToolPresentation(toolNames: string[], mountedToolNames: string[]): Promise<void> {
7582
+ const normalized = normalizeToolNames(toolNames);
7583
+ // Restoration targets a snapshot, so write eligibility comes from the
7584
+ // *target* set rather than whatever happens to be active mid-rollback.
7585
+ await this.#applyToolPresentation(
7586
+ normalized,
7587
+ new Set(normalizeToolNames(mountedToolNames)),
7588
+ normalized.includes("write"),
7589
+ );
7590
+ }
7591
+
7592
+ /**
7593
+ * Shared body for {@link setActiveToolsByName} and {@link setActiveToolPresentation}:
7594
+ * pins non-mounted names as the runtime selection (holding `write` back when it is
7595
+ * transport-only) and applies the set, rolling the selection back if apply throws.
7596
+ */
7597
+ async #applyToolPresentation(
7598
+ normalized: string[],
7599
+ mounted: ReadonlySet<string>,
7600
+ writeSelected: boolean,
7601
+ ): Promise<void> {
7541
7602
  const transportWriteActive =
7603
+ writeSelected &&
7542
7604
  this.#builtInToolNames.has("write") &&
7543
- this.getActiveToolNames().includes("write") &&
7544
7605
  this.#presentationPinnedToolNames?.has("write") !== true &&
7545
7606
  this.#runtimeSelectedToolNames?.has("write") !== true &&
7546
7607
  (mounted.size > 0 || this.#planModeState?.enabled === true);
@@ -8569,19 +8630,18 @@ export class AgentSession {
8569
8630
  timestamp,
8570
8631
  });
8571
8632
  }
8572
- if (
8573
- this.#magicKeywordEnabled("workflow") &&
8574
- containsWorkflow(text) &&
8575
- this.getActiveToolNames().includes("task")
8576
- ) {
8577
- keywordNotices.push({
8578
- role: "custom",
8579
- customType: "workflow-notice",
8580
- content: renderWorkflowNotice({ taskBatch: this.settings.get("task.batch") }),
8581
- display: false,
8582
- attribution: "user",
8583
- timestamp,
8584
- });
8633
+ if (this.#magicKeywordEnabled("workflow") && containsWorkflow(text)) {
8634
+ const activeToolNames = this.getActiveToolNames();
8635
+ if (activeToolNames.includes("task") && activeToolNames.includes("eval")) {
8636
+ keywordNotices.push({
8637
+ role: "custom",
8638
+ customType: "workflow-notice",
8639
+ content: renderWorkflowNotice({ taskBatch: this.settings.get("task.batch") }),
8640
+ display: false,
8641
+ attribution: "user",
8642
+ timestamp,
8643
+ });
8644
+ }
8585
8645
  }
8586
8646
  return keywordNotices;
8587
8647
  }
@@ -10345,15 +10405,16 @@ export class AgentSession {
10345
10405
  }
10346
10406
 
10347
10407
  /**
10348
- * Set the thinking level. `auto` enables per-turn classification; the selector
10349
- * itself is never written to the session log, but resolved concrete levels are
10350
- * persisted when real user turns are classified so resumed sessions keep the
10351
- * last resolved effort instead of reverting to pending auto.
10408
+ * Set the thinking level. `auto` enables per-turn classification. Entering
10409
+ * auto writes its provisional level plus `configured: "auto"` immediately,
10410
+ * giving external readers an authoritative selection receipt before the next
10411
+ * user turn. Later classifications persist only changed concrete resolutions.
10352
10412
  */
10353
10413
  setThinkingLevel(level: ConfiguredThinkingLevel | undefined, persist: boolean = false): void {
10354
10414
  if (level === AUTO_THINKING) {
10355
10415
  const provisional = resolveProvisionalAutoLevel(this.model);
10356
10416
  const wasAuto = this.#autoThinking;
10417
+ const previousLevel = this.#thinkingLevel;
10357
10418
  this.#autoThinking = true;
10358
10419
  this.#autoResolvedLevel = undefined;
10359
10420
  this.#thinkingLevel = provisional;
@@ -10364,7 +10425,9 @@ export class AgentSession {
10364
10425
  if (persist) {
10365
10426
  this.settings.set("defaultThinkingLevel", AUTO_THINKING);
10366
10427
  }
10367
- if (!wasAuto || this.#thinkingLevel !== provisional) {
10428
+ const isChanging = !wasAuto || previousLevel !== provisional;
10429
+ if (isChanging) {
10430
+ this.sessionManager.appendThinkingLevelChange(provisional, AUTO_THINKING);
10368
10431
  this.#emit({ type: "thinking_level_changed", thinkingLevel: provisional, configured: AUTO_THINKING });
10369
10432
  }
10370
10433
  return;
@@ -10473,7 +10536,7 @@ export class AgentSession {
10473
10536
 
10474
10537
  const effort = resolved ?? resolveProvisionalAutoLevel(model);
10475
10538
  if (effort === undefined) return;
10476
- const shouldPersistResolution = this.#autoResolvedLevel !== effort;
10539
+ const shouldPersistResolution = this.#thinkingLevel !== effort;
10477
10540
  this.#autoResolvedLevel = effort;
10478
10541
  this.#thinkingLevel = effort;
10479
10542
  this.#applyThinkingLevelToAgent(effort);
@@ -446,6 +446,13 @@ export class SessionManager {
446
446
  #inMemoryArtifactCounter = 0;
447
447
 
448
448
  #suppressBreadcrumb = false;
449
+ /**
450
+ * The last breadcrumb this manager wrote marked a lazy `/new` boundary whose
451
+ * JSONL is not yet on disk. Cleared (and the crumb re-stamped non-fresh) once
452
+ * the session materializes, so a materialized-then-deleted session still falls
453
+ * back to the most-recent session instead of being treated as a fresh crumb.
454
+ */
455
+ #breadcrumbFresh = false;
449
456
  #sessionNameChangedCallbacks = new Set<() => void>();
450
457
 
451
458
  private constructor(cwd: string, sessionDir: string, persist: boolean, storage: SessionStorage) {
@@ -458,8 +465,18 @@ export class SessionManager {
458
465
  if (persist && sessionDir) this.#storage.ensureDirSync(sessionDir);
459
466
  }
460
467
 
461
- #rememberBreadcrumb(cwd: string, sessionFile: string): void {
462
- if (!this.#suppressBreadcrumb) writeTerminalBreadcrumb(cwd, sessionFile);
468
+ #rememberBreadcrumb(cwd: string, sessionFile: string, fresh = false): void {
469
+ this.#breadcrumbFresh = fresh;
470
+ if (!this.#suppressBreadcrumb) writeTerminalBreadcrumb(cwd, sessionFile, fresh);
471
+ }
472
+
473
+ /**
474
+ * Re-stamp a fresh `/new` breadcrumb as non-fresh once the session has
475
+ * materialized on disk. A no-op unless the current breadcrumb is still fresh.
476
+ */
477
+ #materializeBreadcrumb(): void {
478
+ if (!this.#breadcrumbFresh || !this.#sessionFile) return;
479
+ this.#rememberBreadcrumb(this.#cwd, this.#sessionFile, false);
463
480
  }
464
481
 
465
482
  #clearDiskError(): void {
@@ -601,6 +618,7 @@ export class SessionManager {
601
618
  this.#closeWriterEventually();
602
619
  this.#storage.writeTextSync(this.#sessionFile, body);
603
620
  this.#fileIsCurrent = true;
621
+ this.#materializeBreadcrumb();
604
622
  this.#rewriteRequired = false;
605
623
  this.#hasTitleSlot = true;
606
624
  } catch (err) {
@@ -626,6 +644,7 @@ export class SessionManager {
626
644
  async () => {
627
645
  if (await this.#runFencedAtomicRewrite(startEpoch)) {
628
646
  this.#fileIsCurrent = true;
647
+ this.#materializeBreadcrumb();
629
648
  this.#rewriteRequired = false;
630
649
  this.#hasTitleSlot = true;
631
650
  }
@@ -808,7 +827,7 @@ export class SessionManager {
808
827
  this.#sessionFile =
809
828
  forcedSessionFile ??
810
829
  path.join(this.#sessionDir, `${fileSafeTimestamp(timestamp)}_${this.#sessionId}.jsonl`);
811
- this.#rememberBreadcrumb(this.#cwd, this.#sessionFile);
830
+ this.#rememberBreadcrumb(this.#cwd, this.#sessionFile, true);
812
831
  } else {
813
832
  this.#sessionFile = undefined;
814
833
  }
@@ -2055,6 +2074,18 @@ export class SessionManager {
2055
2074
  let chosenSession: string | null | undefined;
2056
2075
 
2057
2076
  if (breadcrumb) {
2077
+ // A fresh `/new` boundary whose JSONL was never materialized (lazy
2078
+ // new-session persistence, then a process exit before any assistant
2079
+ // output). Honor the boundary: start fresh rather than falling back to
2080
+ // findMostRecentSession(), which would resurrect the pre-`/new`
2081
+ // transcript. A materialized (or genuinely stale/deleted) crumb reports
2082
+ // exists=false only when fresh, so this never masks a real stale crumb.
2083
+ if (breadcrumb.fresh && !breadcrumb.exists) {
2084
+ const manager = new SessionManager(cwd, dir, true, storage);
2085
+ manager.#resetToNewSession();
2086
+ return manager;
2087
+ }
2088
+
2058
2089
  // Recover stale crumbs: a subagent open (pre-fix) may have pointed this
2059
2090
  // terminal's breadcrumb at an artifact child; resume the parent instead.
2060
2091
  breadcrumb.sessionFile = resolveBreadcrumbToInteractiveRoot(breadcrumb.sessionFile);
@@ -146,28 +146,54 @@ export function computeDefaultSessionDir(
146
146
  * Write a breadcrumb linking the current terminal to a session file.
147
147
  * The breadcrumb contains the cwd and session path so --continue can
148
148
  * find "this terminal's last session" even when running concurrent instances.
149
+ *
150
+ * `fresh` marks a `/new` (or freshly-minted) session boundary whose JSONL is
151
+ * not yet materialized (new-session persistence is lazy until assistant output
152
+ * exists). A fresh breadcrumb is honored by {@link readTerminalBreadcrumbEntry}
153
+ * even when its target file is still absent, so relaunch/auto-resume reopens the
154
+ * post-`/new` session instead of falling back to the pre-`/new` transcript. Once
155
+ * the session materializes the caller rewrites the breadcrumb with `fresh:false`
156
+ * so a later external delete is still treated as a genuinely stale crumb.
149
157
  */
150
- export function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void {
158
+ export function writeTerminalBreadcrumb(cwd: string, sessionFile: string, fresh = false): void {
151
159
  const terminalId = getTerminalId();
152
160
  if (!terminalId) return;
153
161
 
154
162
  const breadcrumbDir = getTerminalSessionsDir();
155
163
  const breadcrumbFile = path.join(breadcrumbDir, terminalId);
156
- const content = `${cwd}\n${sessionFile}\n`;
157
- // Best-effort don't break session creation if breadcrumb fails
158
- Bun.write(breadcrumbFile, content).catch(() => {});
164
+ const content = fresh ? `${cwd}\n${sessionFile}\nfresh\n` : `${cwd}\n${sessionFile}\n`;
165
+ // Synchronous + best-effort. Infrequent (session create/switch/reset, never
166
+ // per-append), and writing in order matters: a lazy `/new` fresh crumb is
167
+ // re-stamped non-fresh the instant the session materializes, so an async
168
+ // fire-and-forget could land the two writes out of order and leave a
169
+ // materialized session marked fresh.
170
+ try {
171
+ fs.mkdirSync(breadcrumbDir, { recursive: true });
172
+ fs.writeFileSync(breadcrumbFile, content);
173
+ } catch (err) {
174
+ if (!isEnoent(err)) logger.debug("Terminal breadcrumb write failed", { err });
175
+ }
159
176
  }
160
177
 
161
178
  export interface TerminalBreadcrumb {
162
179
  cwd: string;
163
180
  sessionFile: string;
181
+ /** The recorded session file exists on disk right now. */
182
+ exists: boolean;
183
+ /** Recorded as a `/new` fresh-session boundary whose JSONL may not exist yet. */
184
+ fresh: boolean;
164
185
  }
165
186
 
166
187
  /**
167
188
  * Read the raw terminal breadcrumb for the current terminal.
168
- * Returns the recorded cwd + session file (verified to exist) regardless of
169
- * whether the recorded cwd still matches the current one. Callers decide how
170
- * to interpret a cwd mismatch (e.g. a moved/renamed worktree).
189
+ * Returns the recorded cwd + session file regardless of whether the recorded
190
+ * cwd still matches the current one. Callers decide how to interpret a cwd
191
+ * mismatch (e.g. a moved/renamed worktree).
192
+ *
193
+ * A missing target file yields `null` UNLESS the breadcrumb is a `fresh`
194
+ * boundary — a lazy `/new` session whose JSONL was never written — in which case
195
+ * the entry is returned with `exists:false` so the caller can distinguish it
196
+ * from a genuinely stale/deleted breadcrumb.
171
197
  */
172
198
  export async function readTerminalBreadcrumbEntry(): Promise<TerminalBreadcrumb | null> {
173
199
  const terminalId = getTerminalId();
@@ -181,10 +207,13 @@ export async function readTerminalBreadcrumbEntry(): Promise<TerminalBreadcrumb
181
207
 
182
208
  const breadcrumbCwd = lines[0];
183
209
  const sessionFile = lines[1];
210
+ const fresh = lines[2] === "fresh";
184
211
 
185
- // Verify the session file still exists
186
212
  const stat = fs.statSync(sessionFile, { throwIfNoEntry: false });
187
- if (stat?.isFile()) return { cwd: breadcrumbCwd, sessionFile };
213
+ const exists = stat?.isFile() === true;
214
+ // A materialized target resumes normally; a missing target is honored only
215
+ // for a fresh `/new` boundary (never-written lazy session).
216
+ if (exists || fresh) return { cwd: breadcrumbCwd, sessionFile, exists, fresh };
188
217
  } catch (err) {
189
218
  if (!isEnoent(err)) logger.debug("Terminal breadcrumb read failed", { err });
190
219
  // Breadcrumb doesn't exist or is corrupt — fall through
@@ -828,6 +828,8 @@ export function createSubagentSettings(
828
828
 
829
829
  export type AbortReason = "signal" | "terminate" | "timeout" | "budget";
830
830
 
831
+ const MAX_YIELD_TOOL_ERRORS = 6;
832
+
831
833
  /** Inputs for the run monitor driving one subagent assignment. */
832
834
  interface RunMonitorArgs {
833
835
  index: number;
@@ -874,11 +876,13 @@ interface SubagentRunMonitor {
874
876
  waitForBudgetStop(): Promise<void>;
875
877
  /** The abort kind for this run, when an abort was requested. */
876
878
  abortKind(): AbortReason | undefined;
879
+ terminalError(): string | undefined;
877
880
  /** True when the abort carries a precise external reason (signal / wall-clock / budget). */
878
881
  hasExplicitAbortReason(): boolean;
879
882
  /** Whether the (attempted) abort counts as a cancelled run rather than an internal failure. */
880
883
  isAbortedRun(): boolean;
881
884
  requestAbort(reason: AbortReason): void;
885
+ failWithError(message: string): void;
882
886
  abortActiveSession(): Promise<void>;
883
887
  waitForActiveSessionAbort(): Promise<void>;
884
888
  resolveSignalAbortReason(): string;
@@ -965,6 +969,8 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
965
969
  let budgetLimitExceeded = false;
966
970
  let budgetStopRequested = false;
967
971
  let budgetStopAbortPromise: Promise<void> | undefined;
972
+ let terminalError: string | undefined;
973
+ let consecutiveYieldToolErrors = 0;
968
974
  let lastAssistantSalvageText: string | undefined;
969
975
  let activeSessionAbortPromise: Promise<void> | undefined;
970
976
 
@@ -1021,6 +1027,11 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1021
1027
  : Promise.resolve();
1022
1028
  };
1023
1029
 
1030
+ const failWithError = (message: string) => {
1031
+ terminalError ??= message;
1032
+ requestAbort("terminate");
1033
+ };
1034
+
1024
1035
  // Handle abort signal
1025
1036
  if (signal) {
1026
1037
  signal.addEventListener(
@@ -1323,6 +1334,37 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1323
1334
  requestAbort("terminate");
1324
1335
  }
1325
1336
  }
1337
+ if (event.toolName === "yield") {
1338
+ if (event.isError && !abortSent) {
1339
+ consecutiveYieldToolErrors++;
1340
+ let yieldErrorText = "";
1341
+ const resultContent = event.result?.content;
1342
+ if (Array.isArray(resultContent)) {
1343
+ const textParts: string[] = [];
1344
+ for (const block of resultContent) {
1345
+ if (
1346
+ block &&
1347
+ typeof block === "object" &&
1348
+ "type" in block &&
1349
+ block.type === "text" &&
1350
+ "text" in block &&
1351
+ typeof block.text === "string"
1352
+ ) {
1353
+ textParts.push(block.text);
1354
+ }
1355
+ }
1356
+ yieldErrorText = textParts.join("\n").trim();
1357
+ }
1358
+ if (consecutiveYieldToolErrors >= MAX_YIELD_TOOL_ERRORS) {
1359
+ const suffix = yieldErrorText ? ` Last yield error: ${yieldErrorText}` : "";
1360
+ failWithError(
1361
+ `Subagent submitted invalid yield results ${consecutiveYieldToolErrors} times; stopping to avoid an infinite submit loop.${suffix}`,
1362
+ );
1363
+ }
1364
+ } else if (!event.isError) {
1365
+ consecutiveYieldToolErrors = 0;
1366
+ }
1367
+ }
1326
1368
  flushProgress = true;
1327
1369
  break;
1328
1370
  }
@@ -1558,6 +1600,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1558
1600
  hasUsage: () => hasUsage,
1559
1601
  yieldCalled: () => yieldCalled,
1560
1602
  runtimeLimitExceeded: () => runtimeLimitExceeded,
1603
+ terminalError: () => terminalError,
1561
1604
  hasExplicitAbortReason: () =>
1562
1605
  abortReason === "signal" || runtimeLimitExceeded || budgetLimitExceeded || budgetStopRequested,
1563
1606
  budgetStopRequested: () => budgetStopRequested,
@@ -1568,6 +1611,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1568
1611
  isAbortedRun: () =>
1569
1612
  abortReason === "signal" || runtimeLimitExceeded || budgetLimitExceeded || abortReason === undefined,
1570
1613
  requestAbort,
1614
+ failWithError,
1571
1615
  abortActiveSession,
1572
1616
  waitForActiveSessionAbort,
1573
1617
  resolveSignalAbortReason,
@@ -1763,6 +1807,7 @@ async function driveSessionToYield(
1763
1807
  }
1764
1808
  }
1765
1809
  } finally {
1810
+ error ??= monitor.terminalError();
1766
1811
  if (abortSignal.aborted && (!monitor.yieldCalled() || monitor.runtimeLimitExceeded())) {
1767
1812
  aborted = monitor.isAbortedRun();
1768
1813
  if (aborted) {
package/src/tools/xdev.ts CHANGED
@@ -38,11 +38,18 @@ import { ToolError } from "./tool-errors";
38
38
  /**
39
39
  * Discoverable built-ins that must stay top-level even when xdev mounting is
40
40
  * active: `todo` feeds the todo prelude/prewalk machinery, `ask` is the
41
- * model's user-interaction affordance, and `grep` is the redirect target of
42
- * the bash interceptor rules each loses its harness integration if hidden
43
- * behind dispatch.
41
+ * model's user-interaction affordance, `grep` is the redirect target of the
42
+ * bash interceptor rules, and `web_search` is invoked directly by most models
43
+ * (which have no notion of the `xd://` protocol) so hiding it behind dispatch
44
+ * makes it unreachable in practice (issue #5973) — each loses its harness
45
+ * integration or usability if hidden behind dispatch.
44
46
  */
45
- export const XDEV_KEEP_TOP_LEVEL: Record<string, true> = { todo: true, ask: true, grep: true };
47
+ export const XDEV_KEEP_TOP_LEVEL: Record<string, true> = {
48
+ todo: true,
49
+ ask: true,
50
+ grep: true,
51
+ web_search: true,
52
+ };
46
53
 
47
54
  /**
48
55
  * Tools that carry the `xd://` transport itself and therefore can never be
@@ -16,6 +16,9 @@ import { subprocessToolRegistry } from "../task/subprocess-tool-registry";
16
16
  import type { ToolSession } from ".";
17
17
  import { buildOutputValidator, formatAllValidationIssues } from "./output-schema-validator";
18
18
 
19
+ const YIELD_RESULT_FORMAT_HINT =
20
+ 'Submit success as {"result":{"data":<your output>}} or failure as {"result":{"error":"message"}}.';
21
+
19
22
  export interface YieldDetails {
20
23
  /** Successful result payload, or omitted when `useLastTurn` requests last-turn extraction. */
21
24
  data?: unknown;
@@ -314,7 +317,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
314
317
  const raw = params as Record<string, unknown>;
315
318
  const rawResult = raw.result;
316
319
  if (!rawResult || typeof rawResult !== "object" || Array.isArray(rawResult)) {
317
- throw new Error("result must be an object containing either data or error");
320
+ throw new Error(`result must be an object containing either data or error. ${YIELD_RESULT_FORMAT_HINT}`);
318
321
  }
319
322
  const resultRecord = rawResult as Record<string, unknown>;
320
323
  const errorMessage = typeof resultRecord.error === "string" ? resultRecord.error : undefined;
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Token-throughput calculator shared by the status line (main session tok/s
3
+ * badge) and the vibe worker aggregation ({@link aggregateVibeWorkerTokensPerSecond}).
4
+ * Lives in `utils/` so neither the render layer nor the vibe runtime has to
5
+ * depend on the other for a pure arithmetic helper.
6
+ */
1
7
  const MIN_DURATION_MS = 100;
2
8
 
3
9
  type AssistantUsage = {
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Contracts: vibe worker tok/s aggregation.
3
+ *
4
+ * 1. Returns null when the owner has no vibe worker sessions.
5
+ * 2. Sums the tok/s of every live worker the owner has registered, reading
6
+ * each worker's last assistant message through the same calculator the
7
+ * main status line uses.
8
+ * 3. Returns null when workers exist but none are streaming (no live rate to
9
+ * aggregate) — so the caller's own rate shines through unchanged.
10
+ * 4. Skips workers whose AgentRegistry session is detached (parked/reviving),
11
+ * so a stale roster entry can't contribute a phantom zero.
12
+ */
13
+ import { afterEach, describe, expect, it } from "bun:test";
14
+ import { AgentRegistry, MAIN_AGENT_ID } from "../../registry/agent-registry";
15
+ import type { AgentSession } from "../../session/agent-session";
16
+ import { aggregateVibeWorkerTokensPerSecond, VibeSessionRegistry } from "../runtime";
17
+
18
+ const OWNER = "test-owner";
19
+
20
+ /** Minimal fake AgentSession: just the messages + isStreaming the aggregator reads. */
21
+ function fakeSession(messages: unknown[], isStreaming: boolean): AgentSession {
22
+ return { state: { messages }, isStreaming } as unknown as AgentSession;
23
+ }
24
+
25
+ /** A finalized assistant message with a known duration → deterministic tok/s. */
26
+ function assistantMessage(output: number, durationMs: number, timestamp = 1000) {
27
+ return { role: "assistant", timestamp, duration: durationMs, usage: { output } };
28
+ }
29
+
30
+ function registerWorker(id: string, session: AgentSession | null, ownerId = OWNER) {
31
+ VibeSessionRegistry.global().registerRecordForTests({ id, ownerId });
32
+ if (session) {
33
+ AgentRegistry.global().register({
34
+ id,
35
+ displayName: id,
36
+ kind: "sub",
37
+ session,
38
+ status: "running",
39
+ });
40
+ }
41
+ }
42
+
43
+ describe("aggregateVibeWorkerTokensPerSecond", () => {
44
+ afterEach(() => {
45
+ AgentRegistry.resetGlobalForTests();
46
+ VibeSessionRegistry.resetGlobalForTests();
47
+ });
48
+
49
+ it("returns null when the owner has no worker sessions", () => {
50
+ expect(aggregateVibeWorkerTokensPerSecond(OWNER)).toBeNull();
51
+ });
52
+
53
+ it("sums tok/s across every live streaming worker", () => {
54
+ // 100 tokens in 1000ms → 100 tok/s.
55
+ registerWorker("w1", fakeSession([assistantMessage(100, 1000)], true));
56
+ // 50 tokens in 500ms → 100 tok/s.
57
+ registerWorker("w2", fakeSession([assistantMessage(50, 500)], true));
58
+ expect(aggregateVibeWorkerTokensPerSecond(OWNER)).toBe(200);
59
+ });
60
+
61
+ it("returns null when workers exist but none have a live rate", () => {
62
+ // Not streaming, no duration → calculateTokensPerSecond returns null.
63
+ registerWorker("w1", fakeSession([assistantMessage(100, 0)], false));
64
+ expect(aggregateVibeWorkerTokensPerSecond(OWNER)).toBeNull();
65
+ });
66
+
67
+ it("ignores idle workers whose last turn finished — a finalized duration must not contribute a stale rate", () => {
68
+ // Finalized message (duration set) but the worker is no longer
69
+ // streaming: its completed tok/s must not stick to the badge forever.
70
+ registerWorker("w1", fakeSession([assistantMessage(100, 1000)], false));
71
+ expect(aggregateVibeWorkerTokensPerSecond(OWNER)).toBeNull();
72
+ });
73
+
74
+ it("streaming workers still count while an idle sibling is skipped", () => {
75
+ registerWorker("w1", fakeSession([assistantMessage(100, 1000)], true));
76
+ registerWorker("w2", fakeSession([assistantMessage(50, 500)], false));
77
+ expect(aggregateVibeWorkerTokensPerSecond(OWNER)).toBe(100);
78
+ });
79
+
80
+ it("ignores workers whose AgentRegistry session is detached", () => {
81
+ registerWorker("w1", fakeSession([assistantMessage(100, 1000)], true));
82
+ // w2 is in the vibe roster but has no live AgentRegistry session.
83
+ registerWorker("w2", null);
84
+ expect(aggregateVibeWorkerTokensPerSecond(OWNER)).toBe(100);
85
+ });
86
+
87
+ it("scopes to the requesting owner — other owners' workers don't count", () => {
88
+ registerWorker("w1", fakeSession([assistantMessage(100, 1000)], true), OWNER);
89
+ registerWorker("w2", fakeSession([assistantMessage(50, 500)], true), "other-owner");
90
+ expect(aggregateVibeWorkerTokensPerSecond(OWNER)).toBe(100);
91
+ });
92
+
93
+ it("returns null for the main-agent owner id when no workers are registered", () => {
94
+ expect(aggregateVibeWorkerTokensPerSecond(MAIN_AGENT_ID)).toBeNull();
95
+ });
96
+ });
@@ -33,6 +33,7 @@ import { type AgentDefinition, type AgentProgress, oneLineLabel, type SingleResu
33
33
  import type { ToolSession } from "../tools";
34
34
  import { formatDuration } from "../tools/render-utils";
35
35
  import { ToolError } from "../tools/tool-errors";
36
+ import { calculateTokensPerSecond } from "../utils/token-rate";
36
37
 
37
38
  /** The two worker CLI flavors the director drives. */
38
39
  export type VibeCli = "fast" | "good";
@@ -207,6 +208,26 @@ export class VibeSessionRegistry {
207
208
  VibeSessionRegistry.#global = undefined;
208
209
  }
209
210
 
211
+ /**
212
+ * Insert a bare worker record without the spawn/job machinery. Test-only —
213
+ * lets {@link aggregateVibeWorkerTokensPerSecond} be exercised against a
214
+ * fake roster + AgentRegistry session without driving a real turn.
215
+ */
216
+ registerRecordForTests(record: { id: string; cli?: VibeCli; ownerId: string; state?: VibeSessionState }): void {
217
+ this.#records.set(record.id, {
218
+ id: record.id,
219
+ cli: record.cli ?? "fast",
220
+ ownerId: record.ownerId,
221
+ agent: getBundledAgent("sonic")!,
222
+ state: record.state ?? "running",
223
+ createdAt: Date.now(),
224
+ lastActivityAt: Date.now(),
225
+ queue: [],
226
+ turnCount: 0,
227
+ killed: false,
228
+ });
229
+ }
230
+
210
231
  readonly #records = new Map<string, VibeRecord>();
211
232
 
212
233
  #manager(session: ToolSession): AsyncJobManager {
@@ -708,3 +729,32 @@ export class VibeSessionRegistry {
708
729
  return text;
709
730
  }
710
731
  }
732
+
733
+ /**
734
+ * Aggregate tok/s across every live vibe worker session owned by `ownerId`.
735
+ * Returns null when no workers are streaming (so callers can fall back to
736
+ * their own rate unchanged). The director is often idle while workers stream,
737
+ * so without this aggregation the status-line tok/s badge would show a stale
738
+ * value while parallel work is actively generating tokens.
739
+ *
740
+ * Reads each worker's last assistant message via {@link calculateTokensPerSecond}
741
+ * — the same leaf calculator the main status line uses — so worker rates are
742
+ * computed identically to the main session's rate.
743
+ */
744
+ export function aggregateVibeWorkerTokensPerSecond(ownerId: string): number | null {
745
+ const ids = VibeSessionRegistry.global().listIds(ownerId);
746
+ if (ids.length === 0) return null;
747
+ let total = 0;
748
+ let any = false;
749
+ const registry = AgentRegistry.global();
750
+ for (const id of ids) {
751
+ const workerSession = registry.get(id)?.session;
752
+ if (!workerSession?.isStreaming) continue;
753
+ const rate = calculateTokensPerSecond(workerSession.state.messages, true);
754
+ if (rate !== null) {
755
+ total += rate;
756
+ any = true;
757
+ }
758
+ }
759
+ return any ? total : null;
760
+ }