@oh-my-pi/pi-coding-agent 16.5.0 → 16.5.2

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 (198) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +3556 -3504
  3. package/dist/types/advisor/advise-tool.d.ts +12 -1
  4. package/dist/types/advisor/runtime.d.ts +41 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/update-cli.d.ts +4 -1
  7. package/dist/types/cli/usage-cli.d.ts +3 -0
  8. package/dist/types/cli/usage-error.d.ts +4 -0
  9. package/dist/types/config/api-key-resolver.d.ts +2 -2
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/model-resolver.d.ts +8 -1
  12. package/dist/types/config/models-config.d.ts +1 -1
  13. package/dist/types/config/settings-schema.d.ts +10 -0
  14. package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
  15. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +1 -0
  16. package/dist/types/eval/backend.d.ts +3 -3
  17. package/dist/types/eval/bridge-timeout.d.ts +9 -1
  18. package/dist/types/eval/js/context-manager.d.ts +5 -3
  19. package/dist/types/eval/js/process-entry.d.ts +6 -0
  20. package/dist/types/eval/js/worker-core.d.ts +15 -1
  21. package/dist/types/eval/py/spawn-options.d.ts +10 -0
  22. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  23. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  24. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  25. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  26. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
  27. package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
  28. package/dist/types/goals/guided-setup.d.ts +12 -0
  29. package/dist/types/internal-urls/history-protocol.d.ts +3 -2
  30. package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
  31. package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
  32. package/dist/types/main.d.ts +1 -0
  33. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  34. package/dist/types/mcp/oauth-flow.d.ts +2 -0
  35. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
  36. package/dist/types/modes/components/agent-hub.d.ts +10 -0
  37. package/dist/types/modes/components/dynamic-border.d.ts +5 -3
  38. package/dist/types/modes/components/login-dialog.d.ts +2 -0
  39. package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
  40. package/dist/types/modes/components/read-tool-group.d.ts +0 -2
  41. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  42. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  43. package/dist/types/modes/interactive-mode.d.ts +1 -0
  44. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  45. package/dist/types/modes/orchestrate.d.ts +1 -1
  46. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  47. package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
  48. package/dist/types/modes/types.d.ts +1 -0
  49. package/dist/types/modes/ultrathink.d.ts +1 -1
  50. package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
  51. package/dist/types/modes/workflow.d.ts +1 -1
  52. package/dist/types/session/agent-session.d.ts +6 -0
  53. package/dist/types/session/exit-diagnostics.d.ts +11 -0
  54. package/dist/types/session/messages.d.ts +15 -0
  55. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
  56. package/dist/types/subprocess/worker-client.d.ts +6 -0
  57. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  58. package/dist/types/tools/grep.d.ts +0 -2
  59. package/dist/types/tools/read.d.ts +0 -4
  60. package/dist/types/web/search/provider.d.ts +10 -3
  61. package/dist/types/web/search/providers/codex.d.ts +5 -4
  62. package/package.json +12 -12
  63. package/src/advisor/__tests__/advisor.test.ts +917 -42
  64. package/src/advisor/advise-tool.ts +17 -1
  65. package/src/advisor/runtime.ts +270 -67
  66. package/src/autolearn/controller.ts +15 -3
  67. package/src/autoresearch/dashboard.ts +2 -2
  68. package/src/cli/args.ts +12 -0
  69. package/src/cli/auth-broker-cli.ts +30 -11
  70. package/src/cli/auth-gateway-cli.ts +5 -1
  71. package/src/cli/config-cli.ts +15 -3
  72. package/src/cli/dry-balance-cli.ts +14 -4
  73. package/src/cli/flag-tables.ts +21 -7
  74. package/src/cli/update-cli.ts +62 -11
  75. package/src/cli/usage-cli.ts +58 -5
  76. package/src/cli/usage-error.ts +7 -0
  77. package/src/cli.ts +23 -1
  78. package/src/commands/acp.ts +11 -2
  79. package/src/commands/launch.ts +12 -3
  80. package/src/commands/token.ts +3 -1
  81. package/src/config/api-key-resolver.ts +12 -3
  82. package/src/config/config-file.ts +30 -12
  83. package/src/config/model-registry.ts +7 -7
  84. package/src/config/model-resolver.ts +21 -7
  85. package/src/config/models-config.ts +1 -1
  86. package/src/config/settings-schema.ts +10 -0
  87. package/src/cursor.ts +2 -0
  88. package/src/discovery/claude-plugins.ts +9 -3
  89. package/src/discovery/omp-plugins.ts +6 -2
  90. package/src/discovery/substitute-plugin-root.ts +32 -0
  91. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  92. package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
  93. package/src/eval/__tests__/js-context-manager.test.ts +158 -1
  94. package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
  95. package/src/eval/__tests__/prelude-agent.test.ts +20 -0
  96. package/src/eval/__tests__/process-entry-import.test.ts +27 -0
  97. package/src/eval/agent-bridge.ts +121 -116
  98. package/src/eval/backend.ts +3 -3
  99. package/src/eval/bridge-timeout.ts +20 -2
  100. package/src/eval/executor-base.ts +85 -7
  101. package/src/eval/jl/kernel.ts +2 -1
  102. package/src/eval/js/context-manager.ts +109 -32
  103. package/src/eval/js/process-entry.ts +27 -0
  104. package/src/eval/js/shared/runtime.ts +1 -1
  105. package/src/eval/js/worker-core.ts +70 -9
  106. package/src/eval/js/worker-entry.ts +1 -1
  107. package/src/eval/py/__tests__/prelude.test.ts +72 -0
  108. package/src/eval/py/kernel.ts +2 -1
  109. package/src/eval/py/prelude.py +28 -1
  110. package/src/eval/py/spawn-options.ts +13 -0
  111. package/src/eval/py/tool-bridge.ts +13 -14
  112. package/src/eval/rb/kernel.ts +2 -1
  113. package/src/exec/bash-executor.ts +30 -43
  114. package/src/extensibility/custom-tools/types.ts +3 -0
  115. package/src/extensibility/extensions/runner.ts +3 -0
  116. package/src/extensibility/extensions/types.ts +3 -0
  117. package/src/extensibility/extensions/wrapper.ts +18 -18
  118. package/src/extensibility/plugins/bun-git-cache.ts +91 -0
  119. package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
  120. package/src/extensibility/plugins/manager.ts +28 -7
  121. package/src/goals/guided-setup.ts +29 -1
  122. package/src/internal-urls/history-protocol.ts +95 -15
  123. package/src/internal-urls/memory-protocol.ts +13 -9
  124. package/src/internal-urls/registry-helpers.ts +50 -1
  125. package/src/launch/broker.ts +38 -25
  126. package/src/lsp/client.ts +7 -1
  127. package/src/main.ts +29 -0
  128. package/src/mcp/oauth-discovery.ts +20 -1
  129. package/src/mcp/oauth-flow.ts +3 -1
  130. package/src/mcp/tool-bridge.ts +57 -6
  131. package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
  132. package/src/modes/components/agent-dashboard.ts +2 -2
  133. package/src/modes/components/agent-hub.ts +15 -2
  134. package/src/modes/components/agent-transcript-viewer.ts +2 -2
  135. package/src/modes/components/chat-transcript-builder.ts +26 -4
  136. package/src/modes/components/dynamic-border.ts +9 -6
  137. package/src/modes/components/extensions/extension-list.ts +2 -2
  138. package/src/modes/components/hook-selector.ts +10 -4
  139. package/src/modes/components/login-dialog.ts +5 -0
  140. package/src/modes/components/mcp-add-wizard.ts +5 -0
  141. package/src/modes/components/plan-review-overlay.ts +11 -11
  142. package/src/modes/components/read-tool-group.ts +1 -8
  143. package/src/modes/components/status-line/component.ts +10 -1
  144. package/src/modes/components/transcript-container.ts +110 -7
  145. package/src/modes/controllers/command-controller.ts +12 -4
  146. package/src/modes/controllers/event-controller.ts +80 -15
  147. package/src/modes/controllers/input-controller.ts +4 -2
  148. package/src/modes/controllers/mcp-command-controller.ts +6 -7
  149. package/src/modes/controllers/selector-controller.ts +24 -5
  150. package/src/modes/controllers/todo-command-controller.ts +18 -14
  151. package/src/modes/interactive-mode.ts +7 -3
  152. package/src/modes/magic-keyword-boundary.ts +23 -0
  153. package/src/modes/orchestrate.ts +6 -5
  154. package/src/modes/print-mode.ts +9 -0
  155. package/src/modes/prompt-action-autocomplete.ts +6 -1
  156. package/src/modes/rpc/host-tools.ts +15 -0
  157. package/src/modes/rpc/rpc-mode.ts +123 -48
  158. package/src/modes/types.ts +1 -1
  159. package/src/modes/ultrathink.ts +6 -5
  160. package/src/modes/utils/transcript-render-helpers.ts +54 -0
  161. package/src/modes/utils/ui-helpers.ts +27 -1
  162. package/src/modes/workflow.ts +6 -5
  163. package/src/prompts/advisor/system.md +1 -0
  164. package/src/prompts/system/system-prompt.md +1 -0
  165. package/src/prompts/tools/eval.md +2 -2
  166. package/src/prompts/tools/grep.md +1 -2
  167. package/src/prompts/tools/read.md +2 -4
  168. package/src/sdk.ts +61 -34
  169. package/src/session/agent-session.ts +283 -39
  170. package/src/session/exit-diagnostics.ts +108 -0
  171. package/src/session/messages.test.ts +66 -0
  172. package/src/session/messages.ts +37 -0
  173. package/src/session/streaming-output.ts +40 -12
  174. package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
  175. package/src/slash-commands/helpers/logout.ts +23 -3
  176. package/src/slash-commands/helpers/usage-report.ts +14 -2
  177. package/src/subprocess/worker-client.ts +9 -2
  178. package/src/system-prompt.test.ts +36 -0
  179. package/src/system-prompt.ts +1 -1
  180. package/src/task/executor.ts +8 -0
  181. package/src/task/render.test.ts +36 -0
  182. package/src/task/render.ts +55 -43
  183. package/src/tools/bash-skill-urls.ts +4 -1
  184. package/src/tools/bash.ts +1 -0
  185. package/src/tools/browser/registry.ts +17 -3
  186. package/src/tools/eval.ts +14 -9
  187. package/src/tools/gh.ts +3 -1
  188. package/src/tools/grep.ts +5 -45
  189. package/src/tools/path-utils.ts +7 -1
  190. package/src/tools/read.ts +23 -74
  191. package/src/tools/write.ts +82 -9
  192. package/src/tools/yield.ts +29 -1
  193. package/src/utils/title-generator.ts +10 -6
  194. package/src/web/search/index.ts +39 -22
  195. package/src/web/search/provider.ts +33 -16
  196. package/src/web/search/providers/codex.ts +68 -21
  197. package/src/web/search/providers/perplexity-auth.ts +20 -11
  198. package/src/web/search/providers/perplexity.ts +14 -2
@@ -1,4 +1,5 @@
1
1
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
2
3
  import type { SessionEntry } from "./session-entries";
3
4
 
4
5
  export const TOOL_EXECUTION_START_CUSTOM_TYPE = "tool_execution_start";
@@ -53,10 +54,117 @@ interface ToolCallContent {
53
54
  arguments?: unknown;
54
55
  }
55
56
 
57
+ export interface AssistantModelMetadata {
58
+ api: AssistantMessage["api"];
59
+ provider: string;
60
+ model: string;
61
+ }
62
+
56
63
  function isObject(value: unknown): value is Record<string, unknown> {
57
64
  if (typeof value !== "object") return false;
58
65
  return value !== null;
59
66
  }
67
+ function isPendingToolCallDiagnostic(value: unknown): value is PendingToolCallDiagnostic {
68
+ if (!isObject(value) || typeof value.toolName !== "string") return false;
69
+ if ("toolCallId" in value && typeof value.toolCallId !== "string") return false;
70
+ if ("intent" in value && typeof value.intent !== "string") return false;
71
+ if ("assistantTimestamp" in value && typeof value.assistantTimestamp !== "number") return false;
72
+ if ("startedAt" in value && typeof value.startedAt !== "string") return false;
73
+ return true;
74
+ }
75
+
76
+ function readPendingToolCalls(value: unknown): PendingToolCallDiagnostic[] | undefined {
77
+ if (!Array.isArray(value) || !value.every(isPendingToolCallDiagnostic)) return undefined;
78
+ return value;
79
+ }
80
+
81
+ function readSessionExit(entry: SessionEntry): SessionExitData | undefined {
82
+ if (entry.type !== "custom" || entry.customType !== SESSION_EXIT_CUSTOM_TYPE || !isObject(entry.data)) {
83
+ return undefined;
84
+ }
85
+ const { reason, kind, recordedAt } = entry.data;
86
+ if (
87
+ typeof reason !== "string" ||
88
+ (kind !== "normal" && kind !== "signal" && kind !== "fatal" && kind !== "process_exit") ||
89
+ typeof recordedAt !== "string"
90
+ ) {
91
+ return undefined;
92
+ }
93
+ return {
94
+ reason,
95
+ kind,
96
+ recordedAt,
97
+ pendingToolCalls: readPendingToolCalls(entry.data.pendingToolCalls),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * createInterruptedTurnAbortMessage returns a terminal assistant record when
103
+ * the latest persisted process exit follows a non-terminal conversation tail.
104
+ */
105
+ export function createInterruptedTurnAbortMessage(
106
+ entries: readonly SessionEntry[],
107
+ fallbackModel?: AssistantModelMetadata,
108
+ ): AssistantMessage | undefined {
109
+ let exitIndex = -1;
110
+ let exit: SessionExitData | undefined;
111
+ for (let index = entries.length - 1; index >= 0; index--) {
112
+ const candidate = readSessionExit(entries[index]!);
113
+ if (!candidate) continue;
114
+ exitIndex = index;
115
+ exit = candidate;
116
+ break;
117
+ }
118
+ if (!exit || (exit.kind === "normal" && !exit.pendingToolCalls?.length)) return undefined;
119
+
120
+ let tailIndex = -1;
121
+ let tail: AgentMessage | undefined;
122
+ for (let index = entries.length - 1; index >= 0; index--) {
123
+ const entry = entries[index]!;
124
+ if (entry.type !== "message") continue;
125
+ tailIndex = index;
126
+ tail = entry.message;
127
+ break;
128
+ }
129
+ if (!tail || tailIndex > exitIndex) return undefined;
130
+ if (tail.role === "assistant" && !tail.content.some(isToolCallContent)) return undefined;
131
+
132
+ let previousAssistant: AssistantMessage | undefined;
133
+ for (let index = tailIndex; index >= 0; index--) {
134
+ const entry = entries[index]!;
135
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
136
+ previousAssistant = entry.message;
137
+ break;
138
+ }
139
+ if (
140
+ tail.role === "toolResult" &&
141
+ (previousAssistant?.stopReason === "error" || previousAssistant?.stopReason === "aborted")
142
+ ) {
143
+ return undefined;
144
+ }
145
+ const model = previousAssistant ?? fallbackModel;
146
+ if (!model) return undefined;
147
+
148
+ const recordedAt = Date.parse(exit.recordedAt);
149
+ return {
150
+ role: "assistant",
151
+ content: [],
152
+ api: model.api,
153
+ provider: model.provider,
154
+ model: model.model,
155
+ usage: {
156
+ input: 0,
157
+ output: 0,
158
+ cacheRead: 0,
159
+ cacheWrite: 0,
160
+ totalTokens: 0,
161
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
162
+ },
163
+ stopReason: "aborted",
164
+ errorMessage: "Previous OMP process exited before completing the turn.",
165
+ timestamp: Number.isFinite(recordedAt) ? recordedAt : Date.now(),
166
+ };
167
+ }
60
168
 
61
169
  function isToolCallContent(value: unknown): value is ToolCallContent {
62
170
  if (!isObject(value)) return false;
@@ -5,6 +5,7 @@ import {
5
5
  type CustomMessage,
6
6
  convertToLlm,
7
7
  INTERRUPTED_THINKING_MESSAGE_TYPE,
8
+ replaceLlmImagesWithText,
8
9
  SKILL_PROMPT_MESSAGE_TYPE,
9
10
  type SkillPromptDetails,
10
11
  } from "./messages";
@@ -123,3 +124,68 @@ describe("convertToLlm", () => {
123
124
  ]);
124
125
  });
125
126
  });
127
+
128
+ describe("replaceLlmImagesWithText", () => {
129
+ it("replaces image blocks in user, developer, and tool-result messages with the placeholder", () => {
130
+ const converted = convertToLlm([
131
+ {
132
+ role: "user",
133
+ content: [
134
+ { type: "text", text: "look" },
135
+ { type: "image", data: "aaaa", mimeType: "image/png" },
136
+ ],
137
+ attribution: "user",
138
+ timestamp: 1,
139
+ },
140
+ {
141
+ role: "toolResult",
142
+ toolCallId: "c1",
143
+ toolName: "inspect_image",
144
+ content: [{ type: "image", data: "bbbb", mimeType: "image/png" }],
145
+ isError: false,
146
+ timestamp: 2,
147
+ },
148
+ ]);
149
+
150
+ const scrubbed = replaceLlmImagesWithText(converted, "[image omitted]");
151
+
152
+ expect(scrubbed).not.toBe(converted);
153
+ const types = scrubbed.flatMap(m => (Array.isArray(m.content) ? m.content.map(b => b.type) : []));
154
+ expect(types).not.toContain("image");
155
+ const user = scrubbed.find(m => m.role === "user");
156
+ expect(Array.isArray(user?.content) && user.content.map(b => (b.type === "text" ? b.text : b.type))).toEqual([
157
+ "look",
158
+ "[image omitted]",
159
+ ]);
160
+ const toolResult = scrubbed.find(m => m.role === "toolResult");
161
+ expect(Array.isArray(toolResult?.content) && toolResult.content).toEqual([
162
+ { type: "text", text: "[image omitted]" },
163
+ ]);
164
+ });
165
+
166
+ it("collapses consecutive image blocks into a single placeholder", () => {
167
+ const converted = convertToLlm([
168
+ {
169
+ role: "user",
170
+ content: [
171
+ { type: "image", data: "aaaa", mimeType: "image/png" },
172
+ { type: "image", data: "bbbb", mimeType: "image/png" },
173
+ ],
174
+ attribution: "user",
175
+ timestamp: 1,
176
+ },
177
+ ]);
178
+
179
+ const scrubbed = replaceLlmImagesWithText(converted, "[image omitted]");
180
+ const user = scrubbed.find(m => m.role === "user");
181
+ expect(Array.isArray(user?.content) && user.content).toEqual([{ type: "text", text: "[image omitted]" }]);
182
+ });
183
+
184
+ it("returns the same array reference when there are no image blocks", () => {
185
+ const converted = convertToLlm([
186
+ { role: "user", content: [{ type: "text", text: "hi" }], attribution: "user", timestamp: 1 },
187
+ ]);
188
+
189
+ expect(replaceLlmImagesWithText(converted, "[image omitted]")).toBe(converted);
190
+ });
191
+ });
@@ -512,6 +512,43 @@ export function stripImagesFromMessage(message: AgentMessage): number {
512
512
  }
513
513
  }
514
514
 
515
+ /**
516
+ * Replace every `ImageContent` block in already-converted LLM {@link Message}s
517
+ * with a text placeholder, returning a new array only when something changed.
518
+ *
519
+ * Unlike {@link stripImagesFromMessage} (which mutates persisted `AgentMessage`s
520
+ * in place), this operates on the ephemeral provider-request view produced by
521
+ * {@link convertToLlm}, so history on disk keeps its images while the outbound
522
+ * request is scrubbed. Used to keep image blocks off the wire when the active
523
+ * model has no vision support (or `images.blockImages` is set) — e.g. after
524
+ * switching from a vision model to a text-only one mid-session (#5400).
525
+ *
526
+ * Consecutive placeholder texts collapse into one so a message that was nothing
527
+ * but images does not balloon into a run of identical notes.
528
+ */
529
+ export function replaceLlmImagesWithText(messages: Message[], placeholder: string): Message[] {
530
+ let out: Message[] | undefined;
531
+ for (let i = 0; i < messages.length; i++) {
532
+ const msg = messages[i];
533
+ if (msg.role !== "user" && msg.role !== "developer" && msg.role !== "toolResult") continue;
534
+ const content = msg.content;
535
+ if (!Array.isArray(content) || !content.some(part => part.type === "image")) continue;
536
+ const replaced: (TextContent | ImageContent)[] = [];
537
+ for (const part of content) {
538
+ if (part.type !== "image") {
539
+ replaced.push(part);
540
+ continue;
541
+ }
542
+ const prev = replaced[replaced.length - 1];
543
+ if (prev?.type === "text" && prev.text === placeholder) continue;
544
+ replaced.push({ type: "text", text: placeholder });
545
+ }
546
+ if (out === undefined) out = messages.slice();
547
+ out[i] = { ...msg, content: replaced } as Message;
548
+ }
549
+ return out ?? messages;
550
+ }
551
+
515
552
  /**
516
553
  * Message type for bash executions via the ! command.
517
554
  */
@@ -735,6 +735,7 @@ export class OutputSink {
735
735
  #truncated = false;
736
736
  #lastChunkTime = 0;
737
737
  #pendingChunk = "";
738
+ #pendingChunkTimer: Timer | undefined;
738
739
 
739
740
  // Per-line column cap streaming state (persists across `push` calls so a
740
741
  // long line split across chunks still trips the same trigger).
@@ -806,20 +807,18 @@ export class OutputSink {
806
807
  push(chunk: string): void {
807
808
  chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
808
809
 
809
- // Throttled onChunk: coalesce chunks arriving inside the throttle window
810
- // and flush the buffered concatenation on the next eligible tick (plus a
811
- // final flush in dump()) so the preview never has silent gaps.
810
+ // Throttled onChunk: coalesce chunks arriving inside the throttle window.
811
+ // A timer flushes quiet tails at the throttle boundary; dump() catches a
812
+ // final pending chunk when the process exits before that timer fires.
812
813
  // Live preview gets the raw (pre-cap) chunk so the TUI never lags behind
813
814
  // what reached the sink — the column cap is for the persisted LLM view.
814
815
  if (this.#onChunk) {
815
816
  const now = Date.now();
816
817
  if (now - this.#lastChunkTime >= this.#chunkThrottleMs) {
817
- this.#lastChunkTime = now;
818
- const merged = this.#pendingChunk + chunk;
819
- this.#pendingChunk = "";
820
- this.#onChunk(merged);
818
+ this.#emitPendingChunkWith(chunk, now);
821
819
  } else {
822
820
  this.#pendingChunk += chunk;
821
+ this.#schedulePendingChunkFlush();
823
822
  }
824
823
  }
825
824
 
@@ -1121,6 +1120,7 @@ export class OutputSink {
1121
1120
  * branch in `dump()` against stale totals.
1122
1121
  */
1123
1122
  replace(text: string): void {
1123
+ this.#clearPendingChunkTimer();
1124
1124
  this.#buffer = text;
1125
1125
  this.#bufferBytes = Buffer.byteLength(text, "utf-8");
1126
1126
  this.#head = "";
@@ -1138,6 +1138,38 @@ export class OutputSink {
1138
1138
  this.#pendingChunk = "";
1139
1139
  }
1140
1140
 
1141
+ #clearPendingChunkTimer(): void {
1142
+ if (!this.#pendingChunkTimer) return;
1143
+ clearTimeout(this.#pendingChunkTimer);
1144
+ this.#pendingChunkTimer = undefined;
1145
+ }
1146
+
1147
+ #emitPendingChunkWith(chunk: string, now: number): void {
1148
+ this.#clearPendingChunkTimer();
1149
+ this.#lastChunkTime = now;
1150
+ const merged = this.#pendingChunk + chunk;
1151
+ this.#pendingChunk = "";
1152
+ this.#onChunk?.(merged);
1153
+ }
1154
+
1155
+ #flushPendingChunk(): void {
1156
+ if (this.#pendingChunk.length === 0) {
1157
+ this.#clearPendingChunkTimer();
1158
+ return;
1159
+ }
1160
+ this.#emitPendingChunkWith("", Date.now());
1161
+ }
1162
+
1163
+ #schedulePendingChunkFlush(): void {
1164
+ if (this.#chunkThrottleMs <= 0 || this.#pendingChunkTimer) return;
1165
+ const elapsed = Date.now() - this.#lastChunkTime;
1166
+ const delay = Math.max(0, this.#chunkThrottleMs - elapsed);
1167
+ this.#pendingChunkTimer = setTimeout(() => {
1168
+ this.#pendingChunkTimer = undefined;
1169
+ this.#flushPendingChunk();
1170
+ }, delay);
1171
+ }
1172
+
1141
1173
  /**
1142
1174
  * Replay the rolling tail ring back into the artifact sink. When bytes
1143
1175
  * were actually dropped from the middle (the head budget was exhausted
@@ -1179,11 +1211,7 @@ export class OutputSink {
1179
1211
 
1180
1212
  // Flush any chunk still held back by the throttle so the live preview
1181
1213
  // ends with the complete stream.
1182
- if (this.#onChunk && this.#pendingChunk.length > 0) {
1183
- const pending = this.#pendingChunk;
1184
- this.#pendingChunk = "";
1185
- this.#onChunk(pending);
1186
- }
1214
+ this.#flushPendingChunk();
1187
1215
  const totalLines = this.#sawData ? this.#totalLines + 1 : 0;
1188
1216
 
1189
1217
  if (this.#file) {
@@ -9,6 +9,17 @@ function normalizeIdentityValue(value: unknown): string | undefined {
9
9
  * True when a single usage-limit column belongs to the given OAuth identity.
10
10
  *
11
11
  * Single definition of the matching rules for both `/usage` renderers:
12
+ * - `orgId` ↔ report metadata `orgId` — a GATE that QUALIFIES the base
13
+ * identity, never a replacement for it. Mismatched org presence or
14
+ * different orgs never match: two subscriptions (orgs) can share one
15
+ * email, so an org-scoped identity matches only its own org's reports and
16
+ * an org-less legacy identity never claims an org-attributed report via
17
+ * the shared email. A SHARED org still requires the base-identity match
18
+ * below — Anthropic Team seats have per-user pools yet share the org id
19
+ * in report metadata. Only an org-only identity (no base identifiers
20
+ * recovered at all) matches on the org alone. When neither side carries
21
+ * an org, the base fallback applies unchanged (providers without orgs
22
+ * keep their former behavior).
12
23
  * - `accountId` ↔ report metadata `accountId`/`account_id` or `limit.scope.accountId`
13
24
  * - `email` ↔ report metadata `email`
14
25
  * - `projectId` ↔ report metadata `projectId` or `limit.scope.projectId`
@@ -22,14 +33,23 @@ export function limitMatchesActiveAccount(
22
33
  if (!identity) return false;
23
34
  const metadata = report.metadata ?? {};
24
35
  const activeAccountId = normalizeIdentityValue(identity.accountId);
36
+ const activeEmail = normalizeIdentityValue(identity.email);
37
+ const activeProjectId = normalizeIdentityValue(identity.projectId);
38
+ const activeOrgId = normalizeIdentityValue(identity.orgId);
39
+ const reportOrgId = normalizeIdentityValue(metadata.orgId);
40
+ // Org gate (see doc comment above): different/mismatched-presence orgs
41
+ // never match; a shared org falls through to the base checks unless the
42
+ // identity is org-only.
43
+ if (activeOrgId || reportOrgId) {
44
+ if (activeOrgId !== reportOrgId) return false;
45
+ if (!activeAccountId && !activeEmail && !activeProjectId) return true;
46
+ }
25
47
  if (activeAccountId) {
26
48
  const reportAccountId = normalizeIdentityValue(metadata.accountId) ?? normalizeIdentityValue(metadata.account_id);
27
49
  if (reportAccountId === activeAccountId) return true;
28
50
  if (normalizeIdentityValue(limit.scope.accountId) === activeAccountId) return true;
29
51
  }
30
- const activeEmail = normalizeIdentityValue(identity.email);
31
52
  if (activeEmail && normalizeIdentityValue(metadata.email) === activeEmail) return true;
32
- const activeProjectId = normalizeIdentityValue(identity.projectId);
33
53
  if (activeProjectId) {
34
54
  if (normalizeIdentityValue(metadata.projectId) === activeProjectId) return true;
35
55
  if (normalizeIdentityValue(limit.scope.projectId) === activeProjectId) return true;
@@ -22,13 +22,16 @@ function nonEmpty(value: string | undefined): string | undefined {
22
22
  function oauthLabel(row: StoredAuthCredential): string {
23
23
  const credential = row.credential;
24
24
  if (credential.type !== "oauth") return `API key #${row.id}`;
25
- return (
25
+ const base =
26
26
  nonEmpty(credential.email) ??
27
27
  nonEmpty(credential.accountId) ??
28
28
  nonEmpty(credential.projectId) ??
29
29
  nonEmpty(credential.enterpriseUrl) ??
30
- `OAuth credential #${row.id}`
31
- );
30
+ `OAuth credential #${row.id}`;
31
+ // Two subscriptions (orgs) can share one email — the org is the only
32
+ // user-visible way to tell which row a logout will remove.
33
+ const org = nonEmpty(credential.orgName) ?? nonEmpty(credential.orgId);
34
+ return org && org !== base ? `${base} (${org})` : base;
32
35
  }
33
36
 
34
37
  function oauthDetail(row: StoredAuthCredential, label: string): string {
@@ -53,6 +56,23 @@ function oauthMatchesActiveIdentity(
53
56
  ): boolean {
54
57
  if (!activeIdentity || row.credential.type !== "oauth") return false;
55
58
  const credential = row.credential;
59
+ // The org GATES the base identity rather than replacing it: mismatched org
60
+ // presence or different orgs never match — an org-scoped active session
61
+ // must not preselect the bare-email legacy row, and a bare-email active
62
+ // row must not mark org-scoped siblings active via the shared email. A
63
+ // SHARED org still requires the base-identity match below: two Team seats
64
+ // share one orgId yet own distinct rows. Only an org-only active identity
65
+ // (no base identifiers recovered at all) matches on the org alone.
66
+ if (activeIdentity.orgId !== undefined || credential.orgId !== undefined) {
67
+ if (credential.orgId !== activeIdentity.orgId) return false;
68
+ if (
69
+ activeIdentity.accountId === undefined &&
70
+ activeIdentity.email === undefined &&
71
+ activeIdentity.projectId === undefined
72
+ ) {
73
+ return true;
74
+ }
75
+ }
56
76
  return (
57
77
  (activeIdentity.accountId !== undefined && credential.accountId === activeIdentity.accountId) ||
58
78
  (activeIdentity.email !== undefined && credential.email === activeIdentity.email) ||
@@ -25,14 +25,26 @@ function formatUsageAmount(limit: UsageLimit): string {
25
25
  }
26
26
 
27
27
  function formatUsageReportAccount(report: UsageReport, limit: UsageLimit, index: number): string {
28
+ const metaOrgName = report.metadata?.orgName;
29
+ const metaOrgId = report.metadata?.orgId;
30
+ const org =
31
+ typeof metaOrgName === "string" && metaOrgName
32
+ ? metaOrgName
33
+ : typeof metaOrgId === "string" && metaOrgId
34
+ ? metaOrgId
35
+ : undefined;
36
+ // Two subscriptions (orgs) can share one email — suffix the org so the rows
37
+ // are tellable apart.
28
38
  const email = report.metadata?.email;
29
- if (typeof email === "string" && email) return email;
39
+ if (typeof email === "string" && email) return org ? `${email} (${org})` : email;
30
40
  // Guard metadata values for truthiness before using, then fall back to scope.
31
41
  // ?? won't help here: empty string is not null/undefined, so it would suppress
32
42
  // a valid scoped fallback (e.g. metadata.accountId="" hides limit.scope.accountId).
33
43
  const metaAccountId = report.metadata?.accountId;
34
44
  const accountId = typeof metaAccountId === "string" && metaAccountId ? metaAccountId : limit.scope.accountId;
35
- if (typeof accountId === "string" && accountId) return accountId;
45
+ if (typeof accountId === "string" && accountId) {
46
+ return org && org !== accountId ? `${accountId} (${org})` : accountId;
47
+ }
36
48
  const metaProjectId = report.metadata?.projectId;
37
49
  const projectId = typeof metaProjectId === "string" && metaProjectId ? metaProjectId : limit.scope.projectId;
38
50
  if (typeof projectId === "string" && projectId) return projectId;
@@ -159,6 +159,12 @@ export function createWorkerSubprocess<Outbound>(options: {
159
159
  spawnCommand: WorkerSpawnCommand;
160
160
  env: Record<string, string>;
161
161
  exitLabel: string;
162
+ /** Start the child as a new process-group/session leader where Bun supports it. */
163
+ detached?: boolean;
164
+ /** Treat exit code 0 as unexpected; eval cells can call process.exit(0). */
165
+ reportCleanExit?: boolean;
166
+ /** Whether an idle worker should stop keeping the parent event loop alive. */
167
+ unref?: boolean;
162
168
  }): SpawnedSubprocess<Outbound> {
163
169
  const inbound = new Set<(message: Outbound) => void>();
164
170
  const errors = new Set<(error: Error) => void>();
@@ -175,6 +181,7 @@ export function createWorkerSubprocess<Outbound>(options: {
175
181
  const proc = Bun.spawn({
176
182
  cmd: options.spawnCommand.cmd,
177
183
  cwd: options.spawnCommand.cwd,
184
+ detached: options.detached,
178
185
  env: options.env,
179
186
  stdin: "ignore",
180
187
  stdout: "ignore",
@@ -186,7 +193,7 @@ export function createWorkerSubprocess<Outbound>(options: {
186
193
  },
187
194
  onExit(_proc, exitCode, signalCode) {
188
195
  startStderrDrain();
189
- if (exitCode === 0) return;
196
+ if (exitCode === 0 && !options.reportCleanExit) return;
190
197
  // Swallow only the expected SIGKILL from `terminate()`; every other
191
198
  // signal exit (SIGSEGV from a native fault, OOM SIGKILL, operator
192
199
  // `kill -9`) is a real worker death that must fault in-flight
@@ -206,7 +213,7 @@ export function createWorkerSubprocess<Outbound>(options: {
206
213
  // Don't keep the parent event loop alive on an idle worker; the dispose
207
214
  // path calls `terminate()` explicitly. Bun's test runner starves IPC for
208
215
  // unref'd subprocesses, so keep it referenced only under tests.
209
- if (!isBunTestRuntime()) proc.unref();
216
+ if (!isBunTestRuntime() && options.unref !== false) proc.unref();
210
217
  return { proc, inbound, errors, intentionalExit, stderrDrained: stderrDrained.promise };
211
218
  }
212
219
 
@@ -198,3 +198,39 @@ describe.skipIf(process.platform !== "linux")("system prompt CPU model", () => {
198
198
  }
199
199
  });
200
200
  });
201
+
202
+ describe("non-Linux system prompt CPU model", () => {
203
+ it("includes the model returned by os.cpus", async () => {
204
+ const originalPlatform = process.platform;
205
+ Object.defineProperty(process, "platform", { value: "darwin" });
206
+ const cpus = spyOn(os, "cpus").mockImplementation(() => [
207
+ {
208
+ model: "Synthetic Non-Linux CPU",
209
+ speed: 0,
210
+ times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 },
211
+ },
212
+ ]);
213
+ try {
214
+ const systemPrompt = await buildSystemPrompt({
215
+ resolvedCustomPrompt: "Base prompt",
216
+ contextFiles: [],
217
+ skills: [],
218
+ rules: [],
219
+ workspaceTree: {
220
+ rootPath: import.meta.dir,
221
+ rendered: "",
222
+ truncated: false,
223
+ totalLines: 0,
224
+ agentsMdFiles: [],
225
+ },
226
+ activeRepoContext: null,
227
+ });
228
+
229
+ expect(cpus).toHaveBeenCalledTimes(1);
230
+ expect(systemPrompt.systemPrompt.join("\n")).toContain("- CPU: Synthetic Non-Linux CPU");
231
+ } finally {
232
+ cpus.mockRestore();
233
+ Object.defineProperty(process, "platform", { value: originalPlatform });
234
+ }
235
+ });
236
+ });
@@ -254,7 +254,7 @@ async function getCachedGpu(): Promise<string | undefined> {
254
254
  }
255
255
 
256
256
  async function getCpuModel(): Promise<string | undefined> {
257
- if (process.platform !== "linux") return undefined;
257
+ if (process.platform !== "linux") return os.cpus()[0]?.model;
258
258
  try {
259
259
  const cpuInfo = await Bun.file("/proc/cpuinfo").text();
260
260
  const match = /^model name\s*:\s*(.+)$/m.exec(cpuInfo);
@@ -2325,14 +2325,22 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2325
2325
  thinkingLevel: resolvedThinkingLevel,
2326
2326
  explicitThinkingLevel,
2327
2327
  authFallbackUsed,
2328
+ warning: modelResolutionWarning,
2328
2329
  } = await awaitAbortable(
2329
2330
  resolveModelOverrideWithAuthFallback(
2330
2331
  modelPatterns,
2331
2332
  options.parentActiveModelPattern,
2332
2333
  modelRegistry,
2333
2334
  settings,
2335
+ id,
2334
2336
  ),
2335
2337
  );
2338
+ if (modelResolutionWarning) {
2339
+ logger.warn("Subagent model resolution warning", {
2340
+ warning: modelResolutionWarning,
2341
+ requested: modelPatterns,
2342
+ });
2343
+ }
2336
2344
  if (authFallbackUsed && model) {
2337
2345
  logger.warn("Subagent model has no working credentials; falling back to parent session model", {
2338
2346
  requested: modelPatterns,
@@ -184,6 +184,42 @@ describe("task live progress rendering", () => {
184
184
  expect(collapsedText).not.toContain("line 1");
185
185
  expect(collapsedText).not.toContain("raw output:");
186
186
  });
187
+ it("sanitizes control sequences from expanded subagent recent output", () => {
188
+ setViewportRows(40);
189
+ const progress = makeProgress(["safe after \x1b[2Kclear", "raw\rprompt"]);
190
+
191
+ const text = renderProgressText(progress, true, uiTheme);
192
+
193
+ expect(text).toContain("safe after clear");
194
+ expect(text).toContain("rawprompt");
195
+ expect(text).not.toContain("\x1b[2K");
196
+ expect(text).not.toContain("\r");
197
+ });
198
+ it("sanitizes control sequences from finalized subagent results", () => {
199
+ const output = JSON.stringify({ "\x1b[2Kkey": "safe value" });
200
+ const details: TaskToolDetails = {
201
+ projectAgentsDir: null,
202
+ results: [
203
+ makeSingleResult(0, {
204
+ id: "Final\x1b[2KAgent",
205
+ description: "description\rtext",
206
+ aborted: true,
207
+ abortReason: "aborted \x1b[2Kreason",
208
+ output,
209
+ }),
210
+ ],
211
+ totalDurationMs: 1,
212
+ };
213
+
214
+ const text = renderResultText(details, true, uiTheme);
215
+
216
+ expect(text).toContain("FinalAgent");
217
+ expect(text).toContain("descriptiontext");
218
+ expect(text).toContain("aborted reason");
219
+ expect(text).toContain("key");
220
+ expect(text).not.toContain("\x1b[2K");
221
+ expect(text).not.toContain("\r");
222
+ });
187
223
 
188
224
  it("caps collapsed nested task progress at four rows plus an elision line", () => {
189
225
  setViewportRows(40);