@oh-my-pi/pi-coding-agent 16.2.12 → 16.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/CHANGELOG.md +115 -7
  2. package/dist/cli.js +5996 -5945
  3. package/dist/types/advisor/config.d.ts +4 -2
  4. package/dist/types/collab/host.d.ts +16 -0
  5. package/dist/types/collab/protocol.d.ts +9 -3
  6. package/dist/types/commit/model-selection.d.ts +5 -0
  7. package/dist/types/config/model-resolver.d.ts +12 -10
  8. package/dist/types/config/models-config-schema.d.ts +12 -0
  9. package/dist/types/config/models-config.d.ts +9 -0
  10. package/dist/types/config/settings-schema.d.ts +28 -5
  11. package/dist/types/edit/modes/patch.d.ts +11 -0
  12. package/dist/types/eval/agent-bridge.d.ts +5 -1
  13. package/dist/types/exec/bash-executor.d.ts +1 -0
  14. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +86 -2
  15. package/dist/types/extensibility/skills.d.ts +2 -1
  16. package/dist/types/mnemopi/state.d.ts +12 -0
  17. package/dist/types/modes/components/agent-hub.d.ts +9 -5
  18. package/dist/types/modes/components/status-line/component.test.d.ts +1 -0
  19. package/dist/types/modes/components/usage-row.d.ts +1 -1
  20. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  21. package/dist/types/modes/controllers/extension-ui-controller.d.ts +6 -0
  22. package/dist/types/modes/controllers/streaming-reveal.d.ts +17 -1
  23. package/dist/types/modes/controllers/tool-args-reveal.d.ts +30 -3
  24. package/dist/types/modes/interactive-mode.d.ts +12 -7
  25. package/dist/types/modes/rpc/rpc-client.d.ts +5 -0
  26. package/dist/types/modes/rpc/rpc-mode.d.ts +64 -1
  27. package/dist/types/modes/session-teardown.d.ts +63 -0
  28. package/dist/types/modes/session-teardown.test.d.ts +1 -0
  29. package/dist/types/modes/theme/theme.d.ts +9 -4
  30. package/dist/types/modes/types.d.ts +2 -3
  31. package/dist/types/modes/utils/copy-targets.d.ts +2 -0
  32. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +1 -0
  33. package/dist/types/sdk.d.ts +9 -0
  34. package/dist/types/session/agent-session.d.ts +46 -13
  35. package/dist/types/session/exit-diagnostics.d.ts +48 -0
  36. package/dist/types/session/session-loader.d.ts +5 -0
  37. package/dist/types/task/executor.d.ts +12 -5
  38. package/dist/types/task/parallel.d.ts +1 -0
  39. package/dist/types/task/render.test.d.ts +1 -0
  40. package/dist/types/thinking.d.ts +2 -0
  41. package/dist/types/tools/checkpoint.d.ts +8 -0
  42. package/dist/types/tools/eval-render.d.ts +1 -0
  43. package/dist/types/tools/fetch.d.ts +11 -1
  44. package/dist/types/tools/index.d.ts +3 -1
  45. package/dist/types/tools/irc.d.ts +1 -0
  46. package/dist/types/tools/output-meta.d.ts +7 -0
  47. package/dist/types/tools/output-schema-validator.d.ts +7 -4
  48. package/dist/types/tools/path-utils.d.ts +16 -0
  49. package/dist/types/tools/render-utils.d.ts +4 -1
  50. package/dist/types/utils/git.d.ts +47 -2
  51. package/dist/types/web/search/provider.d.ts +7 -0
  52. package/dist/types/web/search/types.d.ts +1 -1
  53. package/package.json +12 -12
  54. package/src/advisor/config.ts +4 -2
  55. package/src/cli/bench-cli.ts +7 -2
  56. package/src/collab/guest.ts +94 -5
  57. package/src/collab/host.ts +80 -3
  58. package/src/collab/protocol.ts +9 -2
  59. package/src/commit/model-selection.ts +8 -2
  60. package/src/config/keybindings.ts +3 -1
  61. package/src/config/model-discovery.ts +66 -2
  62. package/src/config/model-registry.ts +7 -3
  63. package/src/config/model-resolver.ts +51 -23
  64. package/src/config/models-config-schema.ts +13 -0
  65. package/src/config/settings-schema.ts +44 -14
  66. package/src/edit/hashline/diff.ts +13 -2
  67. package/src/edit/index.ts +58 -8
  68. package/src/edit/modes/patch.ts +53 -18
  69. package/src/edit/streaming.ts +7 -6
  70. package/src/eval/__tests__/agent-bridge.test.ts +57 -1
  71. package/src/eval/agent-bridge.ts +15 -5
  72. package/src/exec/bash-executor.ts +7 -12
  73. package/src/extensibility/legacy-pi-coding-agent-shim.ts +534 -16
  74. package/src/extensibility/skills.ts +29 -6
  75. package/src/goals/guided-setup.ts +4 -3
  76. package/src/internal-urls/docs-index.generated.txt +1 -1
  77. package/src/lsp/client.ts +11 -14
  78. package/src/main.ts +38 -10
  79. package/src/mnemopi/state.ts +43 -7
  80. package/src/modes/acp/acp-agent.ts +1 -1
  81. package/src/modes/components/agent-hub.ts +10 -2
  82. package/src/modes/components/agent-transcript-viewer.ts +39 -7
  83. package/src/modes/components/assistant-message.ts +16 -10
  84. package/src/modes/components/chat-transcript-builder.ts +11 -1
  85. package/src/modes/components/custom-editor.test.ts +20 -0
  86. package/src/modes/components/hook-selector.ts +44 -25
  87. package/src/modes/components/model-selector.ts +1 -1
  88. package/src/modes/components/status-line/component.test.ts +44 -0
  89. package/src/modes/components/status-line/component.ts +9 -1
  90. package/src/modes/components/status-line/segments.ts +3 -3
  91. package/src/modes/components/usage-row.ts +16 -2
  92. package/src/modes/controllers/command-controller.ts +6 -21
  93. package/src/modes/controllers/event-controller.ts +11 -9
  94. package/src/modes/controllers/extension-ui-controller.ts +84 -3
  95. package/src/modes/controllers/input-controller.ts +16 -13
  96. package/src/modes/controllers/selector-controller.ts +3 -8
  97. package/src/modes/controllers/streaming-reveal.ts +75 -53
  98. package/src/modes/controllers/tool-args-reveal.ts +340 -10
  99. package/src/modes/interactive-mode.ts +122 -79
  100. package/src/modes/rpc/rpc-client.ts +21 -0
  101. package/src/modes/rpc/rpc-mode.ts +197 -46
  102. package/src/modes/session-teardown.test.ts +219 -0
  103. package/src/modes/session-teardown.ts +82 -0
  104. package/src/modes/setup-wizard/scenes/theme.ts +2 -2
  105. package/src/modes/skill-command.ts +7 -20
  106. package/src/modes/theme/theme.ts +29 -15
  107. package/src/modes/types.ts +2 -3
  108. package/src/modes/utils/copy-targets.ts +12 -0
  109. package/src/modes/utils/ui-helpers.ts +19 -2
  110. package/src/plan-mode/approved-plan-prompt.test.ts +36 -0
  111. package/src/prompts/advisor/system.md +1 -1
  112. package/src/prompts/agents/tester.md +6 -2
  113. package/src/prompts/skills/autoload.md +8 -0
  114. package/src/prompts/skills/user-invocation.md +11 -0
  115. package/src/prompts/steering/parent-irc.md +5 -0
  116. package/src/prompts/system/irc-incoming.md +2 -0
  117. package/src/prompts/system/mid-run-todo-nudge.md +3 -0
  118. package/src/prompts/system/plan-mode-approved.md +7 -10
  119. package/src/prompts/system/plan-mode-compact-instructions.md +2 -1
  120. package/src/prompts/system/plan-mode-reference.md +3 -4
  121. package/src/prompts/system/rewind-report.md +6 -0
  122. package/src/prompts/system/subagent-system-prompt.md +3 -0
  123. package/src/prompts/tools/irc.md +2 -1
  124. package/src/prompts/tools/job.md +2 -2
  125. package/src/prompts/tools/rewind.md +3 -2
  126. package/src/prompts/tools/ssh.md +1 -0
  127. package/src/prompts/tools/task.md +4 -0
  128. package/src/sdk.ts +18 -4
  129. package/src/session/agent-session.ts +660 -114
  130. package/src/session/exit-diagnostics.ts +202 -0
  131. package/src/session/session-context.ts +25 -13
  132. package/src/session/session-loader.ts +58 -25
  133. package/src/session/session-manager.ts +0 -1
  134. package/src/session/session-persistence.ts +30 -4
  135. package/src/session/settings-stream-fn.ts +14 -0
  136. package/src/slash-commands/builtin-registry.ts +35 -3
  137. package/src/system-prompt.ts +15 -1
  138. package/src/task/executor.ts +31 -8
  139. package/src/task/index.ts +31 -9
  140. package/src/task/isolation-runner.ts +18 -2
  141. package/src/task/parallel.ts +7 -2
  142. package/src/task/render.test.ts +121 -0
  143. package/src/task/render.ts +48 -2
  144. package/src/task/worktree.ts +12 -13
  145. package/src/task/yield-assembly.ts +8 -5
  146. package/src/thinking.ts +5 -0
  147. package/src/tools/ask.ts +188 -9
  148. package/src/tools/ast-edit.ts +7 -0
  149. package/src/tools/ast-grep.ts +11 -0
  150. package/src/tools/bash.ts +6 -30
  151. package/src/tools/checkpoint.ts +15 -1
  152. package/src/tools/eval-render.ts +15 -0
  153. package/src/tools/fetch.ts +82 -18
  154. package/src/tools/gh.ts +1 -1
  155. package/src/tools/grep.ts +45 -27
  156. package/src/tools/index.ts +3 -1
  157. package/src/tools/irc.ts +24 -9
  158. package/src/tools/output-meta.ts +50 -0
  159. package/src/tools/output-schema-validator.ts +152 -15
  160. package/src/tools/path-utils.ts +55 -10
  161. package/src/tools/read.ts +1 -1
  162. package/src/tools/render-utils.ts +5 -3
  163. package/src/tools/ssh.ts +8 -1
  164. package/src/tools/yield.ts +41 -1
  165. package/src/utils/commit-message-generator.ts +2 -2
  166. package/src/utils/git.ts +271 -29
  167. package/src/utils/thinking-display.ts +13 -0
  168. package/src/web/search/index.ts +9 -28
  169. package/src/web/search/provider.ts +26 -1
  170. package/src/web/search/providers/duckduckgo.ts +1 -1
  171. package/src/web/search/types.ts +5 -1
@@ -0,0 +1,202 @@
1
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
+ import type { SessionEntry } from "./session-entries";
3
+
4
+ export const TOOL_EXECUTION_START_CUSTOM_TYPE = "tool_execution_start";
5
+ export const SESSION_EXIT_CUSTOM_TYPE = "session_exit";
6
+
7
+ /**
8
+ * Compact projection of tool-call arguments persisted with the start marker.
9
+ * The assistant message already carries the full arguments; this exists only
10
+ * so `appendArgumentSummary` can name the command/path in resume warnings
11
+ * without duplicating whole argument payloads into the session JSONL.
12
+ */
13
+ export interface ToolArgumentSummary {
14
+ command?: string;
15
+ path?: string;
16
+ }
17
+
18
+ /** Persisted marker written before a tool implementation starts running. */
19
+ export interface ToolExecutionStartData {
20
+ toolCallId: string;
21
+ toolName: string;
22
+ args?: ToolArgumentSummary;
23
+ intent?: string;
24
+ startedAt: string;
25
+ }
26
+
27
+ /** Tool call left without a matching toolResult at the end of a branch. */
28
+ export interface PendingToolCallDiagnostic {
29
+ toolCallId?: string;
30
+ toolName: string;
31
+ args?: unknown;
32
+ intent?: string;
33
+ assistantTimestamp?: number;
34
+ startedAt?: string;
35
+ }
36
+
37
+ /** Session shutdown marker written during normal and fatal process teardown. */
38
+ export interface SessionExitData {
39
+ reason: string;
40
+ kind: "normal" | "signal" | "fatal" | "process_exit";
41
+ recordedAt: string;
42
+ pendingToolCalls?: PendingToolCallDiagnostic[];
43
+ }
44
+
45
+ interface PendingToolCallRecord extends PendingToolCallDiagnostic {
46
+ key: string;
47
+ }
48
+
49
+ interface ToolCallContent {
50
+ type: "toolCall";
51
+ id?: string;
52
+ name?: string;
53
+ arguments?: unknown;
54
+ }
55
+
56
+ function isObject(value: unknown): value is Record<string, unknown> {
57
+ if (typeof value !== "object") return false;
58
+ return value !== null;
59
+ }
60
+
61
+ function isToolCallContent(value: unknown): value is ToolCallContent {
62
+ if (!isObject(value)) return false;
63
+ return value.type === "toolCall" && (typeof value.name === "string" || typeof value.id === "string");
64
+ }
65
+
66
+ /** Character cap for each summarized argument field. */
67
+ const ARGUMENT_SUMMARY_MAX_CHARS = 200;
68
+
69
+ function truncateSummaryField(value: string): string {
70
+ return value.length > ARGUMENT_SUMMARY_MAX_CHARS ? `${value.slice(0, ARGUMENT_SUMMARY_MAX_CHARS)}…` : value;
71
+ }
72
+
73
+ /**
74
+ * Project full tool-call arguments down to the fields the pending-tool-call
75
+ * resume warning actually renders (`command`/`path`), truncated. Returns
76
+ * `undefined` when the arguments carry neither, so callers can omit `args`
77
+ * entirely instead of persisting an empty object.
78
+ */
79
+ export function summarizeToolArguments(args: unknown): ToolArgumentSummary | undefined {
80
+ if (!isObject(args)) return undefined;
81
+ const summary: ToolArgumentSummary = {};
82
+ if (typeof args.command === "string" && args.command.length > 0) {
83
+ summary.command = truncateSummaryField(args.command);
84
+ }
85
+ if (typeof args.path === "string" && args.path.length > 0) {
86
+ summary.path = truncateSummaryField(args.path);
87
+ }
88
+ return summary.command !== undefined || summary.path !== undefined ? summary : undefined;
89
+ }
90
+
91
+ function readToolExecutionStart(entry: SessionEntry): ToolExecutionStartData | undefined {
92
+ if (entry.type !== "custom" || entry.customType !== TOOL_EXECUTION_START_CUSTOM_TYPE) return undefined;
93
+ const data = entry.data;
94
+ if (!isObject(data)) return undefined;
95
+ if (typeof data.toolCallId !== "string" || typeof data.toolName !== "string") return undefined;
96
+ const startedAt = typeof data.startedAt === "string" ? data.startedAt : entry.timestamp;
97
+ const result: ToolExecutionStartData = {
98
+ toolCallId: data.toolCallId,
99
+ toolName: data.toolName,
100
+ startedAt,
101
+ };
102
+ // Legacy sessions persisted full argument objects; project them down.
103
+ if ("args" in data) {
104
+ const args = summarizeToolArguments(data.args);
105
+ if (args) result.args = args;
106
+ }
107
+ if (typeof data.intent === "string") result.intent = data.intent;
108
+ return result;
109
+ }
110
+
111
+ function appendAssistantToolCalls(pending: Map<string, PendingToolCallRecord>, message: AgentMessage): void {
112
+ if (message.role !== "assistant") return;
113
+ const content = Array.isArray(message.content) ? message.content : [];
114
+ const toolCalls: PendingToolCallRecord[] = [];
115
+ for (let index = 0; index < content.length; index++) {
116
+ const part = content[index];
117
+ if (!isToolCallContent(part)) continue;
118
+ const toolName = part.name ?? "unknown";
119
+ const key = part.id ?? `assistant:${message.timestamp ?? "unknown"}:${index}:${toolName}`;
120
+ const record: PendingToolCallRecord = {
121
+ key,
122
+ toolName,
123
+ };
124
+ if (typeof message.timestamp === "number") record.assistantTimestamp = message.timestamp;
125
+ if (part.id) record.toolCallId = part.id;
126
+ if ("arguments" in part) record.args = part.arguments;
127
+ toolCalls.push(record);
128
+ }
129
+ pending.clear();
130
+ for (const toolCall of toolCalls) pending.set(toolCall.key, toolCall);
131
+ }
132
+
133
+ function applyToolExecutionStart(pending: Map<string, PendingToolCallRecord>, marker: ToolExecutionStartData): void {
134
+ const existing = pending.get(marker.toolCallId);
135
+ if (existing) {
136
+ existing.startedAt = marker.startedAt;
137
+ // The assistant message carries the full arguments; the marker only has
138
+ // the command/path projection. Keep the richer copy when present.
139
+ existing.args ??= marker.args;
140
+ if (marker.intent) existing.intent = marker.intent;
141
+ return;
142
+ }
143
+ const record: PendingToolCallRecord = {
144
+ key: marker.toolCallId,
145
+ toolCallId: marker.toolCallId,
146
+ toolName: marker.toolName,
147
+ args: marker.args,
148
+ startedAt: marker.startedAt,
149
+ };
150
+ if (marker.intent) record.intent = marker.intent;
151
+ pending.set(marker.toolCallId, record);
152
+ }
153
+
154
+ function applyMessageEntry(pending: Map<string, PendingToolCallRecord>, message: AgentMessage): void {
155
+ if (message.role === "toolResult") {
156
+ const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : undefined;
157
+ if (toolCallId) pending.delete(toolCallId);
158
+ return;
159
+ }
160
+ appendAssistantToolCalls(pending, message);
161
+ }
162
+
163
+ /** Finds tool calls left pending at the end of a session branch. */
164
+ export function collectPendingToolCalls(entries: readonly SessionEntry[]): PendingToolCallDiagnostic[] {
165
+ const pending = new Map<string, PendingToolCallRecord>();
166
+ for (const entry of entries) {
167
+ if (entry.type === "message") {
168
+ applyMessageEntry(pending, entry.message);
169
+ continue;
170
+ }
171
+ const marker = readToolExecutionStart(entry);
172
+ if (marker) applyToolExecutionStart(pending, marker);
173
+ }
174
+ return [...pending.values()].map(({ key: _key, ...toolCall }) => toolCall);
175
+ }
176
+
177
+ function appendArgumentSummary(parts: string[], args: unknown): void {
178
+ if (!isObject(args)) return;
179
+ const command = args.command;
180
+ if (typeof command === "string" && command.length > 0) {
181
+ parts.push(`command \`${command}\``);
182
+ return;
183
+ }
184
+ const path = args.path;
185
+ if (typeof path === "string" && path.length > 0) parts.push(`path \`${path}\``);
186
+ }
187
+
188
+ function formatPendingToolCall(call: PendingToolCallDiagnostic): string {
189
+ const parts = [call.toolName];
190
+ if (call.toolCallId) parts.push(call.toolCallId);
191
+ appendArgumentSummary(parts, call.args);
192
+ return parts.join(" ");
193
+ }
194
+
195
+ /** Builds the resume warning shown when a prior branch ended mid-tool-call. */
196
+ export function describePendingToolCalls(entries: readonly SessionEntry[]): string | undefined {
197
+ const pending = collectPendingToolCalls(entries);
198
+ if (pending.length === 0) return undefined;
199
+ const formatted = pending.map(formatPendingToolCall).join(", ");
200
+ const noun = pending.length === 1 ? "tool call" : "tool calls";
201
+ return `Previous session ended while ${pending.length} ${noun} remained pending: ${formatted}. The prior OMP process exited before recording tool result(s).`;
202
+ }
@@ -307,21 +307,23 @@ export function buildSessionContext(
307
307
  })();
308
308
  const remoteReplacementHistory = providerPayload?.items;
309
309
 
310
- if (options?.transcript) handleEntryResetTracking(compaction);
311
- // Emit summary first; re-attach any archived snapcompact frames so the
312
- // model can keep reading the archived history after every context rebuild.
310
+ // Re-attach any archived snapcompact frames so the model can keep
311
+ // reading the archived history after every context rebuild.
313
312
  const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData);
314
- pushMessage(
315
- createCompactionSummaryMessage(
316
- compaction.summary,
317
- compaction.tokensBefore,
318
- compaction.timestamp,
319
- compaction.shortSummary,
320
- providerPayload,
321
- undefined,
322
- snapcompactHistoryBlocksForContext(snapcompactArchive, options),
323
- ),
313
+ const compactionSummaryMsg = createCompactionSummaryMessage(
314
+ compaction.summary,
315
+ compaction.tokensBefore,
316
+ compaction.timestamp,
317
+ compaction.shortSummary,
318
+ providerPayload,
319
+ undefined,
320
+ snapcompactHistoryBlocksForContext(snapcompactArchive, options),
324
321
  );
322
+ // Agent context (non-transcript): summary first so the LLM sees the
323
+ // compacted context before recent messages.
324
+ if (!options?.transcript) {
325
+ pushMessage(compactionSummaryMsg);
326
+ }
325
327
 
326
328
  // Find compaction index in path
327
329
  const compactionIdx = path.findIndex(e => e.type === "compaction" && e.id === compaction.id);
@@ -345,6 +347,16 @@ export function buildSessionContext(
345
347
  }
346
348
  }
347
349
 
350
+ // Display transcript: emit the summary at the chronological compaction
351
+ // point (after kept messages, before post-compaction) so it stays in
352
+ // the live region where Ctrl+O can expand it. Reset tracking fires
353
+ // here so the first post-compaction assistant turn — not a kept
354
+ // pre-compaction one — is marked as a cache miss.
355
+ if (options?.transcript) handleEntryResetTracking(compaction);
356
+ if (options?.transcript) {
357
+ pushMessage(compactionSummaryMsg);
358
+ }
359
+
348
360
  // Emit messages after compaction
349
361
  for (let i = compactionIdx + 1; i < path.length; i++) {
350
362
  const entry = path[i];
@@ -1,5 +1,3 @@
1
- import * as fs from "node:fs";
2
- import * as readline from "node:readline";
3
1
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
4
2
  import { getBlobsDir, isEnoent, parseJsonlLenient } from "@oh-my-pi/pi-utils";
5
3
  import { BlobStore, isBlobRef, resolveImageData, resolveImageDataUrl } from "./blob-store";
@@ -103,40 +101,75 @@ function elideSupersededCompactionEntries(entries: FileEntry[]): void {
103
101
  }
104
102
  }
105
103
 
106
- async function loadEntriesFromFileStream(filePath: string): Promise<{
104
+ /** Exported for testing — the ≥8MiB streaming path (works on any file size). */
105
+ export async function loadEntriesFromFileStream(filePath: string): Promise<{
107
106
  entries: FileEntry[];
108
107
  titleSlot: SessionTitleUpdate | undefined;
109
108
  }> {
110
109
  const entries: FileEntry[] = [];
111
110
  let titleSlot: SessionTitleUpdate | undefined;
112
- let sawBodyLine = false;
113
- const input = fs.createReadStream(filePath, { encoding: "utf8" });
114
- const lines = readline.createInterface({ input, crlfDelay: Infinity });
111
+ let sawFirstLine = false;
112
+ // Byte buffer (NOT a decoded string): multibyte UTF-8 sequences that straddle
113
+ // a stream-chunk boundary stay intact, and Bun.JSONL.parseChunk accepts typed
114
+ // arrays directly. Only the unconsumed remainder is held (≤ one record + a
115
+ // chunk), so the ≥8MiB memory guard is preserved (the file is never fully
116
+ // loaded into memory).
117
+ let buffer: Uint8Array = new Uint8Array();
118
+ const decoder = new TextDecoder();
115
119
 
116
- try {
117
- for await (const rawLine of lines) {
118
- const line = rawLine.trim();
119
- if (!line) continue;
120
- if (!sawBodyLine) {
121
- const slot = parseTitleSlotLine(line);
122
- if (slot) {
123
- titleSlot = titleUpdateFromSlot(slot);
124
- sawBodyLine = true;
125
- continue;
126
- }
127
- sawBodyLine = true;
120
+ const drain = () => {
121
+ while (buffer.length > 0) {
122
+ const { values, error, read, done } = Bun.JSONL.parseChunk(buffer);
123
+ if (values.length > 0) {
124
+ for (const value of values) entries.push(value as FileEntry);
128
125
  }
129
-
130
- let entry: FileEntry;
131
- try {
132
- entry = JSON.parse(line) as FileEntry;
133
- } catch {
126
+ if (error) {
127
+ // Malformed record: skip past the next newline and continue.
128
+ const nextNewline = buffer.indexOf(0x0a, read);
129
+ if (nextNewline === -1) break; // rest of the bad line not yet received
130
+ buffer = buffer.subarray(nextNewline + 1);
134
131
  continue;
135
132
  }
136
- entries.push(entry);
133
+ if (read === 0) break; // incomplete record awaiting more data
134
+ buffer = buffer.subarray(read);
135
+ if (done) {
136
+ buffer = new Uint8Array();
137
+ break;
138
+ }
139
+ }
140
+ };
141
+
142
+ try {
143
+ for await (const chunk of Bun.file(filePath).stream()) {
144
+ buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]);
145
+ // The optional fixed-width title slot is a physical first line that is
146
+ // NOT JSON; peel it before the parser would (correctly) reject it. The
147
+ // first line ends at a '\n' byte, so it is a complete UTF-8 sequence and
148
+ // safe to decode. A non-slot first line is a real entry and is left for
149
+ // the parser; a blank first line is left for the parser to skip.
150
+ if (!sawFirstLine) {
151
+ const newline = buffer.indexOf(0x0a);
152
+ if (newline !== -1) {
153
+ sawFirstLine = true;
154
+ const firstLine = decoder.decode(buffer.subarray(0, newline)).trim();
155
+ if (firstLine) {
156
+ const slot = parseTitleSlotLine(firstLine);
157
+ if (slot) {
158
+ titleSlot = titleUpdateFromSlot(slot);
159
+ buffer = buffer.subarray(newline + 1);
160
+ }
161
+ }
162
+ }
163
+ }
164
+ drain();
165
+ }
166
+ // A trailing record without a final newline: terminate it so the parser
167
+ // can complete it (readline yielded it; parseChunk needs the delimiter).
168
+ if (buffer.length > 0 && buffer[buffer.length - 1] !== 0x0a) {
169
+ buffer = Buffer.concat([buffer, new Uint8Array([0x0a])]);
137
170
  }
171
+ drain();
138
172
  } catch (err) {
139
- input.destroy();
140
173
  if (isEnoent(err)) return { entries: [], titleSlot: undefined };
141
174
  throw err;
142
175
  }
@@ -246,7 +246,6 @@ class SessionEntryIndex {
246
246
  cursor = cursor.parentId ? this.#entriesById.get(cursor.parentId) : undefined;
247
247
  }
248
248
  branch.reverse();
249
-
250
249
  return branch;
251
250
  }
252
251
 
@@ -59,9 +59,15 @@ function shouldExternalizeImagePayload(
59
59
  return (key === TEXT_CONTENT_KEY && isImageBlock(value)) || key === "images";
60
60
  }
61
61
 
62
+ /** True for a non-empty string — marks signature/encrypted fields whose block must persist verbatim. */
63
+ function isNonEmptyString(value: unknown): value is string {
64
+ return typeof value === "string" && value.length > 0;
65
+ }
66
+
62
67
  /**
63
68
  * Recursively truncate large strings in an object for session persistence.
64
- * - Truncates any oversized string fields (key-agnostic)
69
+ * - Truncates oversized string fields (key-agnostic), except signed/encrypted
70
+ * blocks and signature keys, which persist verbatim
65
71
  * - Externalizes oversized image payloads to blob refs
66
72
  * - Updates lineCount when content is truncated
67
73
  * - Returns original object if no changes needed (structural sharing)
@@ -76,16 +82,36 @@ function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string
76
82
  if (shouldExternalizeImagePayload(obj, key)) {
77
83
  return { ...obj, data: externalizeImageDataSync(blobStore, obj.data, obj.mimeType) };
78
84
  }
85
+ // Signed content is bound to its exact bytes: a truncated `thinking`/`text`/
86
+ // `arguments` no longer matches its signature and a truncated
87
+ // `redacted_thinking` blob is undecryptable, so the provider 400s the replay.
88
+ // Persist signed blocks verbatim — never truncate, externalize, or descend.
89
+ // Unsigned blocks (e.g. an interrupted stream) have no such binding and stay
90
+ // truncatable for size control.
91
+ if (typeof obj === "object" && "type" in obj) {
92
+ const signed =
93
+ (obj.type === "thinking" && "thinkingSignature" in obj && isNonEmptyString(obj.thinkingSignature)) ||
94
+ (obj.type === "text" && "textSignature" in obj && isNonEmptyString(obj.textSignature)) ||
95
+ (obj.type === "toolCall" && "thoughtSignature" in obj && isNonEmptyString(obj.thoughtSignature));
96
+ const redacted = obj.type === "redactedThinking" && "data" in obj && isNonEmptyString(obj.data);
97
+ // OpenAI Responses reasoning items (providerPayload.items) carry
98
+ // `encrypted_content`, server-validated on replay — atomic like signed blocks.
99
+ const encryptedReasoning =
100
+ obj.type === "reasoning" && "encrypted_content" in obj && isNonEmptyString(obj.encrypted_content);
101
+ if (signed || redacted || encryptedReasoning) return obj;
102
+ }
79
103
 
80
104
  if (typeof obj === "string") {
81
105
  if (key === "image_url" && isImageDataUrl(obj)) {
82
106
  return externalizeImageDataUrlSync(blobStore, obj);
83
107
  }
84
108
  if (obj.length > MAX_PERSIST_CHARS) {
85
- // Cryptographic signatures must be preserved exactly or cleared entirely never truncated.
86
- // Truncation would produce an invalid signature that the API rejects.
109
+ // Defensive: signature keys normally sit on blocks the guard above returns
110
+ // verbatim, but if one is reached here (unknown carrier shape), preserve it —
111
+ // truncation produces an invalid signature the API rejects, and clearing
112
+ // drops reasoning context the provider needs on replay.
87
113
  if (key === "thinkingSignature" || key === "thoughtSignature" || key === "textSignature") {
88
- return "";
114
+ return obj;
89
115
  }
90
116
  const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length);
91
117
  return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}`;
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import type { StreamFn } from "@oh-my-pi/pi-agent-core";
14
14
  import { type SimpleStreamOptions, streamSimple } from "@oh-my-pi/pi-ai";
15
+ import { isAnthropicFableOrMythosModel } from "@oh-my-pi/pi-catalog/identity";
15
16
  import { type Settings, validateProviderMaxInFlightRequests } from "../config/settings";
16
17
 
17
18
  function timeoutSecondsToMs(value: number): number | undefined {
@@ -38,6 +39,18 @@ export function createSettingsAwareStreamFn(settings: Settings, base: StreamFn =
38
39
  : undefined;
39
40
  const streamFirstEventTimeoutMs = timeoutSecondsToMs(settings.get("providers.streamFirstEventTimeoutSeconds"));
40
41
  const streamIdleTimeoutMs = timeoutSecondsToMs(settings.get("providers.streamIdleTimeoutSeconds"));
42
+ // Server-side fallback (opt-in): when the user enables it AND the
43
+ // resolved model is a Claude Fable/Mythos on Anthropic's messages
44
+ // API, inject the `fallbacks: [{ model: "claude-opus-4-8" }]` chain.
45
+ // The provider layer picks it up, sends the beta header, and honors
46
+ // the response signals. Every other model / API is untouched.
47
+ const serverSideFallbackEnabled =
48
+ settings.get("providers.anthropic.serverSideFallback") &&
49
+ model.api === "anthropic-messages" &&
50
+ model.provider === "anthropic" &&
51
+ isAnthropicFableOrMythosModel(model.id);
52
+ const fallbacks =
53
+ streamOptions?.fallbacks ?? (serverSideFallbackEnabled ? [{ model: "claude-opus-4-8" }] : undefined);
41
54
  const merged: SimpleStreamOptions = {
42
55
  ...streamOptions,
43
56
  openrouterVariant: streamOptions?.openrouterVariant ?? openrouterVariant,
@@ -53,6 +66,7 @@ export function createSettingsAwareStreamFn(settings: Settings, base: StreamFn =
53
66
  checkAssistantContent: settings.get("model.loopGuard.checkAssistantContent"),
54
67
  ...streamOptions?.loopGuard,
55
68
  },
69
+ ...(fallbacks !== undefined ? { fallbacks } : {}),
56
70
  };
57
71
  return base(model, context, merged);
58
72
  };
@@ -27,6 +27,7 @@ import { resolveMemoryBackend } from "../memory-backend";
27
27
  import { describeLoopLimitRuntime } from "../modes/loop-limit";
28
28
  import { theme } from "../modes/theme/theme";
29
29
  import type { InteractiveModeContext } from "../modes/types";
30
+ import { extractLastCodeBlock, extractLastCommand } from "../modes/utils/copy-targets";
30
31
  import type { AgentSession, FreshSessionResult } from "../session/agent-session";
31
32
  import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes";
32
33
  import { resolveResumableSession } from "../session/session-listing";
@@ -34,6 +35,7 @@ import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
34
35
  import { expandTilde, resolveToCwd } from "../tools/path-utils";
35
36
  import { urlHyperlinkAlways } from "../tui";
36
37
  import { getChangelogPath, parseChangelog } from "../utils/changelog";
38
+ import { copyToClipboard } from "../utils/clipboard";
37
39
  import { CollabQrCodeComponent } from "./helpers/collab-qrcode";
38
40
  import { buildContextReportText } from "./helpers/context-report";
39
41
  import { formatDuration } from "./helpers/format";
@@ -69,7 +71,6 @@ export interface TuiBuiltinSlashCommand extends BuiltinSlashCommand {
69
71
 
70
72
  function refreshStatusLine(ctx: InteractiveModeContext): void {
71
73
  ctx.statusLine.invalidate();
72
- ctx.updateEditorTopBorder();
73
74
  ctx.ui.requestRender();
74
75
  }
75
76
 
@@ -841,8 +842,39 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
841
842
  {
842
843
  name: "copy",
843
844
  description: "Pick text or code from the conversation to copy",
844
- handleTui: (_command, runtime) => {
845
- runtime.ctx.showCopySelector();
845
+ allowArgs: true,
846
+ handleTui: async (command, runtime) => {
847
+ const arg = command.args.trim().toLowerCase();
848
+ if (!arg) {
849
+ runtime.ctx.showCopySelector();
850
+ runtime.ctx.editor.setText("");
851
+ return;
852
+ }
853
+ if (arg === "code") {
854
+ const block = extractLastCodeBlock(runtime.ctx.session.messages);
855
+ if (!block) {
856
+ runtime.ctx.showStatus("No code block to copy.");
857
+ runtime.ctx.editor.setText("");
858
+ return;
859
+ }
860
+ await copyToClipboard(block.code);
861
+ runtime.ctx.showStatus("Copied code block to clipboard");
862
+ runtime.ctx.editor.setText("");
863
+ return;
864
+ }
865
+ if (arg === "cmd" || arg === "command") {
866
+ const lastCommand = extractLastCommand(runtime.ctx.session.messages);
867
+ if (!lastCommand) {
868
+ runtime.ctx.showStatus("No command to copy.");
869
+ runtime.ctx.editor.setText("");
870
+ return;
871
+ }
872
+ await copyToClipboard(lastCommand.code);
873
+ runtime.ctx.showStatus(`Copied ${lastCommand.kind === "bash" ? "bash command" : "eval code"} to clipboard`);
874
+ runtime.ctx.editor.setText("");
875
+ return;
876
+ }
877
+ runtime.ctx.showStatus("Usage: /copy [code|cmd]");
846
878
  runtime.ctx.editor.setText("");
847
879
  },
848
880
  },
@@ -249,6 +249,20 @@ async function getCachedGpu(): Promise<string | undefined> {
249
249
  await logger.time("getCachedGpu:saveGpuCache", saveGpuCache, { gpu });
250
250
  return gpu ?? undefined;
251
251
  }
252
+ /**
253
+ * Kernel identity for the workstation block. Prefers the uname build string
254
+ * from `os.version()`, but Bun on macOS 15+ (Darwin 24/25) returns the literal
255
+ * `"unknown"` when `uv_os_uname()`'s `version` field is empty — which surfaces
256
+ * `Kernel: unknown` in the system prompt and makes the model misidentify the
257
+ * host as Windows (#4141). Fall back to `<type> <release>` (uname -s + -r) so
258
+ * macOS is always tagged as `Darwin <release>` and Linux keeps its build info.
259
+ */
260
+ function getKernelIdentity(): string {
261
+ const version = os.version()?.trim();
262
+ if (version && version.toLowerCase() !== "unknown") return version;
263
+ return `${os.type()} ${os.release()}`.trim();
264
+ }
265
+
252
266
  function getEnvironmentInfo(gpu: string | undefined): Array<{ label: string; value: string }> {
253
267
  let cpuModel: string | undefined;
254
268
  try {
@@ -259,7 +273,7 @@ function getEnvironmentInfo(gpu: string | undefined): Array<{ label: string; val
259
273
  const entries: Array<{ label: string; value: string | undefined }> = [
260
274
  { label: "OS", value: `${os.platform()} ${os.release()}` },
261
275
  { label: "Distro", value: os.type() },
262
- { label: "Kernel", value: os.version() },
276
+ { label: "Kernel", value: getKernelIdentity() },
263
277
  { label: "Arch", value: os.arch() },
264
278
  { label: "CPU", value: cpuModel },
265
279
  { label: "GPU", value: gpu },
@@ -80,10 +80,12 @@ const MCP_CALL_TIMEOUT_MS = 60_000;
80
80
 
81
81
  /**
82
82
  * Soft per-agent request budgets (assistant requests per run). When a subagent
83
- * crosses its budget it receives ONE steering notice asking it to wrap up; at
84
- * 1.5x the budget the run is aborted gracefully so partial output is salvaged.
85
- * The `default` key applies to agents without an explicit entry and can be
86
- * overridden via the `task.softRequestBudget` setting (0 disables the guard).
83
+ * crosses its budget it can receive an optional steering notice asking it to
84
+ * wrap up; at 1.5x the budget the run is aborted gracefully so partial output is
85
+ * salvaged. The `default` key applies to agents without an explicit entry and
86
+ * can be overridden via the `task.softRequestBudget` setting (0 disables the
87
+ * guard). The notice is off by default and controlled separately by
88
+ * `task.softRequestBudgetNotice`.
87
89
  */
88
90
  export const SOFT_REQUEST_BUDGET: Record<string, number> = {
89
91
  explore: 40,
@@ -91,7 +93,7 @@ export const SOFT_REQUEST_BUDGET: Record<string, number> = {
91
93
  default: 90,
92
94
  };
93
95
 
94
- /** Steering notice injected once when a subagent crosses its soft request budget. */
96
+ /** Optional steering notice injected when a subagent crosses its soft request budget. */
95
97
  export function buildBudgetNotice(requests: number): string {
96
98
  return `[budget notice] You have used ${requests} requests in this run. Wrap up now: finish the current step and yield your final report.`;
97
99
  }
@@ -295,6 +297,11 @@ export interface ExecutorOptions {
295
297
  parentActiveModelPattern?: string;
296
298
  thinkingLevel?: ThinkingLevel;
297
299
  outputSchema?: unknown;
300
+ /**
301
+ * Caller supplied a schema that supersedes the agent's native output prompt.
302
+ * Eval `agent(..., schema=...)` sets this so built-in agents ignore stale yield labels.
303
+ */
304
+ outputSchemaOverridesAgent?: boolean;
298
305
  /** Parent task recursion depth (0 = top-level, 1 = first child, etc.) */
299
306
  taskDepth?: number;
300
307
  /**
@@ -793,6 +800,8 @@ interface RunMonitorArgs {
793
800
  sessionFile?: string;
794
801
  /** Soft assistant-request budget; 0 disables the guard. */
795
802
  softRequestBudget: number;
803
+ /** Whether crossing the soft budget injects a wrap-up steering notice. */
804
+ softRequestBudgetNotice: boolean;
796
805
  /** Wall-clock cap in ms; 0 disables the timer. */
797
806
  maxRuntimeMs: number;
798
807
  }
@@ -835,7 +844,18 @@ interface SubagentRunMonitor {
835
844
  }
836
845
 
837
846
  function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
838
- const { index, id, agent, task, assignment, signal, onProgress, softRequestBudget, maxRuntimeMs } = args;
847
+ const {
848
+ index,
849
+ id,
850
+ agent,
851
+ task,
852
+ assignment,
853
+ signal,
854
+ onProgress,
855
+ softRequestBudget,
856
+ softRequestBudgetNotice,
857
+ maxRuntimeMs,
858
+ } = args;
839
859
  const startTime = Date.now();
840
860
 
841
861
  const progress: AgentProgress = {
@@ -1232,7 +1252,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1232
1252
  if (softRequestBudget > 0 && !abortSent) {
1233
1253
  if (progress.requests >= softRequestBudget * 1.5) {
1234
1254
  requestAbort("budget");
1235
- } else if (!budgetSteerSent && progress.requests >= softRequestBudget) {
1255
+ } else if (softRequestBudgetNotice && !budgetSteerSent && progress.requests >= softRequestBudget) {
1236
1256
  budgetSteerSent = true;
1237
1257
  const steerSession = activeSession;
1238
1258
  if (steerSession) {
@@ -1868,6 +1888,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1868
1888
  );
1869
1889
  const softRequestBudget =
1870
1890
  configuredDefaultBudget === 0 ? 0 : (SOFT_REQUEST_BUDGET[agent.name] ?? configuredDefaultBudget);
1891
+ const softRequestBudgetNotice = settings.get("task.softRequestBudgetNotice") ?? false;
1871
1892
  const parentDepth = options.taskDepth ?? 0;
1872
1893
  const childDepth = parentDepth + 1;
1873
1894
  const atMaxDepth = maxRecursionDepth >= 0 && childDepth >= maxRecursionDepth;
@@ -1927,6 +1948,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1927
1948
  detached: options.detached,
1928
1949
  sessionFile: subtaskSessionFile,
1929
1950
  softRequestBudget,
1951
+ softRequestBudgetNotice,
1930
1952
  maxRuntimeMs,
1931
1953
  });
1932
1954
  const progress = monitor.progress;
@@ -2139,6 +2161,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2139
2161
  planReferencePath: options.planReference?.path ?? "",
2140
2162
  worktree: worktree ?? "",
2141
2163
  outputSchema: normalizedOutputSchema,
2164
+ outputSchemaOverridesAgent: options.outputSchemaOverridesAgent === true,
2142
2165
  ircPeers: ircEnabled ? renderIrcPeerRoster(id) : "",
2143
2166
  ircSelfId: ircEnabled ? id : "",
2144
2167
  });
@@ -2313,7 +2336,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2313
2336
  // Autoload skills via sendCustomMessage (same mechanic as /skill:<name>)
2314
2337
  if (options.autoloadSkills?.length) {
2315
2338
  for (const skill of options.autoloadSkills) {
2316
- const { message } = await buildSkillPromptMessage(skill, "");
2339
+ const { message } = await buildSkillPromptMessage(skill, "", "autoload");
2317
2340
  await session.sendCustomMessage(
2318
2341
  {
2319
2342
  customType: SKILL_PROMPT_MESSAGE_TYPE,