@bubblebrain-ai/bubble 0.0.55 → 0.0.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +35 -0
  2. package/dist/agent/child-runner.d.ts +7 -0
  3. package/dist/agent/child-runner.js +67 -3
  4. package/dist/agent/subagent/runtime.d.ts +3 -0
  5. package/dist/agent/subagent/runtime.js +38 -8
  6. package/dist/agent/transcript-helpers.d.ts +1 -1
  7. package/dist/agent/transcript-helpers.js +4 -3
  8. package/dist/agent.d.ts +6 -0
  9. package/dist/agent.js +36 -0
  10. package/dist/cli.d.ts +2 -1
  11. package/dist/cli.js +22 -3
  12. package/dist/context/budget.js +7 -2
  13. package/dist/context/compact.js +8 -2
  14. package/dist/feishu/agent-host/run-driver.js +3 -0
  15. package/dist/main.js +16 -1
  16. package/dist/mcp/client.js +15 -1
  17. package/dist/mcp/transports.d.ts +2 -2
  18. package/dist/mcp/types.d.ts +1 -1
  19. package/dist/memory/phase1.js +4 -0
  20. package/dist/model-catalog.d.ts +1 -1
  21. package/dist/model-catalog.js +7 -0
  22. package/dist/model-pricing.js +0 -0
  23. package/dist/provider-ark-responses.d.ts +12 -2
  24. package/dist/provider-ark-responses.js +68 -13
  25. package/dist/provider-error-record.d.ts +34 -0
  26. package/dist/provider-error-record.js +185 -0
  27. package/dist/provider-transform.js +1 -0
  28. package/dist/provider.js +7 -1
  29. package/dist/sdk/index.js +5 -0
  30. package/dist/session-log.d.ts +3 -1
  31. package/dist/session-log.js +11 -0
  32. package/dist/session-types.d.ts +6 -1
  33. package/dist/session.d.ts +2 -0
  34. package/dist/session.js +4 -0
  35. package/dist/stream-json.d.ts +13 -0
  36. package/dist/stream-json.js +74 -0
  37. package/dist/tui/controller/agent-event-reducer.js +2 -1
  38. package/dist/tui/controller/controller.js +6 -5
  39. package/dist/tui/model/image-paste.js +9 -10
  40. package/dist/types.d.ts +3 -1
  41. package/package.json +1 -1
package/README.md CHANGED
@@ -67,6 +67,41 @@ Resume your last conversation:
67
67
  bubble --resume
68
68
  ```
69
69
 
70
+ ## Persistent headless sessions
71
+
72
+ To keep one agent session and its MCP connections alive across multiple prompts:
73
+
74
+ ```bash
75
+ bubble -p --input-format stream-json --output-format stream-json \
76
+ --model zhipuai-coding-plan:glm-5.3 --session my-session
77
+ ```
78
+
79
+ Write one JSON message per line to stdin (keep stdin open between turns):
80
+
81
+ ```json
82
+ {"type":"user","request_id":"turn-1","message":{"role":"user","content":"Inspect the page."}}
83
+ {"type":"user","request_id":"turn-2","message":{"role":"user","content":"Continue with the next action."}}
84
+ ```
85
+
86
+ Stdout contains only newline-delimited JSON: one `system/init` event with the model
87
+ and `session_id`, `stream_event` messages wrapping native agent events (including
88
+ `text_delta`, `tool_start`, and `tool_end`), and one `result` per input. Results
89
+ include `is_error`, text in `result`, token usage, and the input `request_id` when
90
+ provided. This is Bubble's protocol; it does not claim full Claude Code protocol
91
+ compatibility. Input content may also be an array of text blocks.
92
+
93
+ Turns run sequentially in arrival order and retain the same agent history, MCP
94
+ connections, and tool discovery state. Malformed input and failed turns produce an
95
+ error result; the process remains available for the next input. An optional
96
+ `session_id` must match the initialized session. EOF drains submitted turns, then
97
+ releases resources. SIGINT/SIGTERM stop the process. Input lines are limited to
98
+ 1 MiB. Permission settings remain unchanged unless explicitly overridden.
99
+
100
+ Both stream format flags require `-p`; positional prompts are not accepted in this
101
+ mode. To resume a saved session, pass both `--resume` and `--session <name>` so no
102
+ interactive picker is needed. Existing plain and single-result JSON print modes
103
+ retain their behavior.
104
+
70
105
  ## Embed with the SDK
71
106
 
72
107
  The npm package exports `BubbleSdk` from both `@bubblebrain-ai/bubble` and
@@ -11,6 +11,9 @@
11
11
  import type { SubagentRunOutcome } from "./subagent-scheduler.js";
12
12
  import type { SubagentFinalReason, SubagentThreadRecord } from "./subagent-control.js";
13
13
  import type { AgentEvent, Message, ToolRegistryEntry, ToolResult, ToolUpdate } from "../types.js";
14
+ /** Bubble-authored configuration failure whose message contains no provider data. */
15
+ export declare class SafeSubagentConfigurationError extends Error {
16
+ }
14
17
  export interface ChildRunOptions {
15
18
  approval: "fail" | "disabled";
16
19
  abortSignal?: AbortSignal;
@@ -27,6 +30,10 @@ export interface ChildRunnerHost {
27
30
  runLifecycleHook(record: SubagentThreadRecord, cwd: string, eventName: "SubagentStart" | "SubagentStop", status?: string, error?: string, abortSignal?: AbortSignal): Promise<void>;
28
31
  finalizeBlocked(record: SubagentThreadRecord, error: string, options: ChildRunOptions): void;
29
32
  createInstance(record: SubagentThreadRecord, tools: ToolRegistryEntry[], cwd: string, forkContext?: boolean): Promise<NonNullable<SubagentThreadRecord["agent"]>>;
33
+ recordProviderError(record: SubagentThreadRecord, error: unknown, request: {
34
+ messageCount: number;
35
+ toolCount: number;
36
+ }): void;
30
37
  notifyWaiters(record: SubagentThreadRecord): void;
31
38
  /** Called on every final state so background results can be ingested (§5). */
32
39
  onFinal(record: SubagentThreadRecord, options: ChildRunOptions): void;
@@ -13,8 +13,12 @@ import { composeAbortSignals } from "./budget-ledger.js";
13
13
  import { isOnlyProviderProtocolArtifacts, stripProviderProtocolArtifacts } from "../provider-artifacts.js";
14
14
  import { isRateLimitError } from "../network/errors.js";
15
15
  import { isProviderTransportError } from "../network/provider-transport.js";
16
+ import { sanitizeProviderErrorText } from "../provider-error-record.js";
16
17
  import { mergeUsage, selectToolsForAgentProfile, validateAgentProfileTools } from "./profiles.js";
17
18
  import { estimateHandoffTokens, HANDOFF_TOKEN_FLOOR, isIntermediateHandoff, stripInternalTagFragments, } from "./subagent-summary.js";
19
+ /** Bubble-authored configuration failure whose message contains no provider data. */
20
+ export class SafeSubagentConfigurationError extends Error {
21
+ }
18
22
  export class ChildRunner {
19
23
  host;
20
24
  constructor(host) {
@@ -58,7 +62,13 @@ export class ChildRunner {
58
62
  catch (error) {
59
63
  // Instance creation failed before the run started: no SubagentStart
60
64
  // fired, so no SubagentStop follows (§9 — hooks pair per started run).
61
- this.host.finalizeBlocked(record, error?.message || String(error), options);
65
+ const isSafeConfigurationError = error instanceof SafeSubagentConfigurationError;
66
+ if (!isSafeConfigurationError) {
67
+ this.host.recordProviderError(record, error, { messageCount: 0, toolCount: tools.length });
68
+ }
69
+ this.host.finalizeBlocked(record, isSafeConfigurationError
70
+ ? error.message
71
+ : sanitizeProviderErrorText(error?.message || String(error)), options);
62
72
  record.finalReason = "failed_fatal";
63
73
  this.host.onFinal(record, options);
64
74
  return { kind: "final" };
@@ -168,7 +178,15 @@ export class ChildRunner {
168
178
  ? classifySubagentAbortReason(record.abortController.signal.aborted ? record.abortController.signal.reason : error, options.abortSignal)
169
179
  : "failed_transient";
170
180
  record.summary = sanitizeSubagentSummary(record.summary);
171
- record.error = error?.message || String(error);
181
+ if (!cancelled) {
182
+ this.host.recordProviderError(record, error, {
183
+ messageCount: messagesBeforeInterruptedBoundary(subAgent.messages),
184
+ toolCount: tools.length,
185
+ });
186
+ }
187
+ record.error = cancelled
188
+ ? "Subagent was cancelled."
189
+ : sanitizeProviderErrorText(error?.message || String(error));
172
190
  record.updatedAt = Date.now();
173
191
  await this.host.runLifecycleHook(record, cwd, "SubagentStop", record.status, record.error, options.abortSignal);
174
192
  emit(record.status, undefined, record.error);
@@ -178,7 +196,49 @@ export class ChildRunner {
178
196
  }
179
197
  record.summary = sanitizeSubagentSummary(record.summary);
180
198
  if (needsExplicitFinalSummary(record, executedAnyTool)) {
181
- await this.runFinalSummaryTurn(record, subAgent, runCwd, options.abortSignal, emit);
199
+ try {
200
+ await this.runFinalSummaryTurn(record, subAgent, runCwd, options.abortSignal, emit);
201
+ }
202
+ catch (error) {
203
+ const abortedNow = record.abortController.signal.aborted
204
+ || options.abortSignal?.aborted
205
+ || error instanceof AgentAbortError
206
+ || error?.name === "AbortError";
207
+ if (isRateLimitError(error) && !abortedNow) {
208
+ record.status = "queued";
209
+ record.updatedAt = Date.now();
210
+ stripTrailingModelInterruptedBoundary(subAgent.messages);
211
+ emit("queued", undefined, `Rate limited; ${record.nickname} will retry with its context intact.`);
212
+ return { kind: "rate_limited", retryAfterMs: error.retryAfterMs };
213
+ }
214
+ if (isProviderTransportError(error) && !abortedNow) {
215
+ record.status = "queued";
216
+ record.updatedAt = Date.now();
217
+ stripTrailingModelInterruptedBoundary(subAgent.messages);
218
+ emit("queued", undefined, `Connection error; ${record.nickname} will retry with its context intact.`);
219
+ return { kind: "transport_retry" };
220
+ }
221
+ const cancelled = error instanceof AgentAbortError || error?.name === "AbortError";
222
+ record.status = cancelled ? "cancelled" : "failed";
223
+ record.finalReason = cancelled
224
+ ? classifySubagentAbortReason(record.abortController.signal.aborted ? record.abortController.signal.reason : error, options.abortSignal)
225
+ : "failed_transient";
226
+ if (!cancelled) {
227
+ this.host.recordProviderError(record, error, {
228
+ messageCount: messagesBeforeInterruptedBoundary(subAgent.messages),
229
+ toolCount: tools.length,
230
+ });
231
+ }
232
+ record.error = cancelled
233
+ ? "Subagent was cancelled."
234
+ : sanitizeProviderErrorText(error?.message || String(error));
235
+ record.updatedAt = Date.now();
236
+ await this.host.runLifecycleHook(record, cwd, "SubagentStop", record.status, record.error, options.abortSignal);
237
+ emit(record.status, undefined, record.error);
238
+ this.host.notifyWaiters(record);
239
+ this.host.onFinal(record, options);
240
+ return { kind: "final" };
241
+ }
182
242
  }
183
243
  record.status = "completed";
184
244
  record.finalReason = "completed";
@@ -289,6 +349,10 @@ export function stripTrailingModelInterruptedBoundary(messages) {
289
349
  break;
290
350
  }
291
351
  }
352
+ function messagesBeforeInterruptedBoundary(messages) {
353
+ const last = messages.at(-1);
354
+ return Math.max(0, messages.length - (last?.role === "assistant" && last.content.startsWith("[model request interrupted") ? 1 : 0));
355
+ }
292
356
  export function summarizeSubagentToolEnd(event) {
293
357
  const metadata = (event.result.metadata ?? {});
294
358
  const reason = readString(metadata.reason);
@@ -4,6 +4,7 @@ import { SubagentStore } from "../subagent-store.js";
4
4
  import { type AgentCategoriesConfig, type ResolvedSubagentRoute } from "../categories.js";
5
5
  import type { SubagentRouter } from "./router.js";
6
6
  import type { AgentEvent, ContentPart, Message, PermissionMode, Provider, ThinkingLevel, ToolRegistryEntry, ToolUpdate } from "../../types.js";
7
+ import type { ProviderErrorContext } from "../../provider-error-record.js";
7
8
  import type { AgentSubagentRuntimeConfig } from "../../agent.js";
8
9
  import type { HookCombinedResult } from "../../hooks/index.js";
9
10
  /** The child agent surface the runtime drives; structurally satisfied by Agent. */
@@ -44,6 +45,7 @@ export interface SubagentRuntimeParent {
44
45
  */
45
46
  allTools(): ToolRegistryEntry[];
46
47
  createChild(spec: ChildAgentSpec): ChildAgentLike;
48
+ recordProviderError(error: unknown, context: ProviderErrorContext): void;
47
49
  runExternalHook(input: {
48
50
  eventName: string;
49
51
  cwd: string;
@@ -185,6 +187,7 @@ export declare class SubagentRuntime {
185
187
  private maybeEnqueueIngestion;
186
188
  private finalizeSubagentBlocked;
187
189
  private dispatchSubagentRun;
190
+ private recordSubagentProviderError;
188
191
  private emitSubagentLifecycle;
189
192
  /** Lets a blocked tool-execution loop drain freshly queued subagent updates. */
190
193
  private wakeSubagentUpdateWaiters;
@@ -11,10 +11,10 @@
11
11
  * `SubagentRuntimeParent` as a LIVE accessor, never a construction-time
12
12
  * snapshot: `/model` reassigns the parent's provider/model/thinking
13
13
  * mid-session and children must inherit the current values.
14
- * - Nothing is pushed back into the parent. Tool updates and ingestion
15
- * notices are PULLED (`drainToolUpdates` / `drainIngestionNotices`) at the
16
- * points in Agent.run() that previously called the private flush methods,
17
- * matching ResultIntegrator.drainNotices.
14
+ * - Conversation state is never pushed back into the parent. Tool updates
15
+ * and ingestion notices are PULLED (`drainToolUpdates` /
16
+ * `drainIngestionNotices`). The only direct sink is a terminal,
17
+ * fail-closed provider diagnostic that contains no child transcript.
18
18
  *
19
19
  * `new Agent(...)` stays on Agent, reached through `parent.createChild`. That
20
20
  * is the only genuine call back into the parent, and keeping it there is what
@@ -31,7 +31,7 @@ import { assignAgentNickname, discoverAgentProfiles, findAgentProfile, validateA
31
31
  import { snapshotSubagentThread, subagentResultFromThread } from "../subagent-control.js";
32
32
  import { SubagentStore } from "../subagent-store.js";
33
33
  import { SubagentScheduler } from "../subagent-scheduler.js";
34
- import { ChildRunner, classifySubagentAbortReason } from "../child-runner.js";
34
+ import { ChildRunner, classifySubagentAbortReason, SafeSubagentConfigurationError, } from "../child-runner.js";
35
35
  import { ResultIntegrator } from "../result-integrator.js";
36
36
  import { SubagentAbortError } from "../abort-errors.js";
37
37
  import { createSubagentWorktree, finalizeSubagentWorktree } from "../worktree.js";
@@ -75,6 +75,7 @@ export class SubagentRuntime {
75
75
  runLifecycleHook: (record, cwd, eventName, status, error, abortSignal) => this.runSubagentLifecycleHookFor(record, cwd, eventName, status, error, abortSignal),
76
76
  finalizeBlocked: (record, error, options) => this.finalizeSubagentBlocked(record, error, options),
77
77
  createInstance: (record, tools, cwd, forkContext) => this.createSubAgentInstance(record, tools, cwd, forkContext),
78
+ recordProviderError: (record, error, request) => this.recordSubagentProviderError(record, error, request),
78
79
  notifyWaiters: (record) => this.store.notifyWaiters(record),
79
80
  onFinal: (record, options) => {
80
81
  this.reclaimWorktree(record);
@@ -506,7 +507,7 @@ export class SubagentRuntime {
506
507
  onCancelledWhileQueued: (reason) => {
507
508
  record.status = "cancelled";
508
509
  record.finalReason = classifySubagentAbortReason(reason, options.abortSignal);
509
- record.error = reason instanceof Error ? reason.message : reason ? String(reason) : "Cancelled while queued.";
510
+ record.error = "Subagent was cancelled.";
510
511
  record.updatedAt = Date.now();
511
512
  // This path is NOT only "run never started" — a 429/transport
512
513
  // failure re-queues the entry with the abort listener re-armed, so
@@ -527,6 +528,7 @@ export class SubagentRuntime {
527
528
  record.status = "failed";
528
529
  record.finalReason = "rate_limited_exhausted";
529
530
  record.error = `Provider rate limit persisted after ${attempts} attempts.`;
531
+ this.recordSubagentProviderError(record, Object.assign(new Error(record.error), { status: 429, code: "rate_limit_exhausted" }), { messageCount: record.agent?.messages.length ?? 0, toolCount: 0 });
530
532
  record.updatedAt = Date.now();
531
533
  // Reclaim before persist so the handoff note lands in the
532
534
  // persisted toolNotes.
@@ -543,6 +545,10 @@ export class SubagentRuntime {
543
545
  // to recover the child with its context intact.
544
546
  record.finalReason = "failed_transient";
545
547
  record.error = `Provider transport error persisted after ${attempts} attempts.`;
548
+ this.recordSubagentProviderError(record, new Error(record.error), {
549
+ messageCount: record.agent?.messages.length ?? 0,
550
+ toolCount: 0,
551
+ });
546
552
  record.updatedAt = Date.now();
547
553
  this.reclaimWorktree(record);
548
554
  void this.runSubagentLifecycleHookFor(record, cwd, "SubagentStop", record.status, record.error);
@@ -553,6 +559,22 @@ export class SubagentRuntime {
553
559
  },
554
560
  });
555
561
  }
562
+ recordSubagentProviderError(record, error, request) {
563
+ const route = record.route ?? {
564
+ providerId: this.parent.providerId,
565
+ model: this.parent.apiModel,
566
+ thinkingLevel: this.parent.thinkingLevel,
567
+ inherited: true,
568
+ };
569
+ this.parent.recordProviderError(error, {
570
+ providerId: route.providerId,
571
+ modelId: route.model,
572
+ model: route.providerId ? `${route.providerId}:${route.model}` : route.model,
573
+ thinkingLevel: route.thinkingLevel,
574
+ messageCount: request.messageCount,
575
+ toolCount: request.toolCount,
576
+ });
577
+ }
556
578
  emitSubagentLifecycle(record, options, status, event, message) {
557
579
  const update = this.buildSubagentUpdate(record, status, event, message);
558
580
  options.directEmit?.(update);
@@ -633,7 +655,15 @@ export class SubagentRuntime {
633
655
  // NOTE: record.worktree must be assigned before anything below can
634
656
  // throw — ChildRunner's failure path reclaims it off the record.
635
657
  if (!record.worktree) {
636
- record.worktree = createSubagentWorktree(cwd, record.agentId);
658
+ try {
659
+ record.worktree = createSubagentWorktree(cwd, record.agentId);
660
+ }
661
+ catch (error) {
662
+ const message = error instanceof Error && error.message.includes("need a git repository")
663
+ ? "write_worktree subagents need a git repository: the working directory is not inside one."
664
+ : "Failed to create subagent worktree.";
665
+ throw new SafeSubagentConfigurationError(message);
666
+ }
637
667
  }
638
668
  childCwd = record.worktree.path;
639
669
  childMode = "default";
@@ -705,7 +735,7 @@ export class SubagentRuntime {
705
735
  return this.parent.provider;
706
736
  }
707
737
  if (!this.parent.providerFactory) {
708
- throw new Error([
738
+ throw new SafeSubagentConfigurationError([
709
739
  `Subagent route requires provider "${route.providerId}" for model "${route.model}",`,
710
740
  `but the parent agent only has provider "${this.parent.providerId || "none"}" and no provider factory is configured.`,
711
741
  ].join(" "));
@@ -8,7 +8,7 @@ export declare function findMissingRequiredArgs(schema: {
8
8
  required?: string[];
9
9
  } | undefined, args: Record<string, any> | undefined): string[];
10
10
  export declare function shouldAppendModelInterruptedBoundary(messages: Message[]): boolean;
11
- export declare function createModelInterruptedMessage(error: unknown, metadata: {
11
+ export declare function createModelInterruptedMessage(_error: unknown, metadata: {
12
12
  model: string;
13
13
  providerId: string;
14
14
  modelId: string;
@@ -1,4 +1,3 @@
1
- import { summarizeInterruptError } from "./abort-errors.js";
2
1
  export function findMissingRequiredArgs(schema, args) {
3
2
  const required = schema?.required;
4
3
  if (!required || required.length === 0)
@@ -19,10 +18,12 @@ export function findMissingRequiredArgs(schema, args) {
19
18
  export function shouldAppendModelInterruptedBoundary(messages) {
20
19
  return messages.at(-1)?.role === "tool";
21
20
  }
22
- export function createModelInterruptedMessage(error, metadata) {
21
+ export function createModelInterruptedMessage(_error, metadata) {
23
22
  return {
24
23
  role: "assistant",
25
- content: `[model request interrupted before a final answer was produced: ${summarizeInterruptError(error)}]`,
24
+ // This boundary is replayed to future providers and memory extraction.
25
+ // Never copy provider error text into conversational history.
26
+ content: "[model request interrupted before a final answer was produced]",
26
27
  model: metadata.model,
27
28
  providerId: metadata.providerId,
28
29
  modelId: metadata.modelId,
package/dist/agent.d.ts CHANGED
@@ -18,6 +18,7 @@ export { createUpdateQueue } from "./agent/update-queue.js";
18
18
  import { type RateLimitPolicy } from "./network/errors.js";
19
19
  import type { SkillSummary } from "./skills/types.js";
20
20
  import type { FileStateTracker } from "./tools/file-state.js";
21
+ import { type SanitizedProviderError } from "./provider-error-record.js";
21
22
  export { AgentAbortError, SubagentAbortError } from "./agent/abort-errors.js";
22
23
  export declare const INTERRUPTED_ASSISTANT_CONTENT = "Interrupted by user. The prior request was stopped and should not be resumed unless the user asks.";
23
24
  /** Runtime tuning for the subagent scheduler. */
@@ -51,6 +52,8 @@ export interface AgentOptions {
51
52
  };
52
53
  systemPrompt?: string;
53
54
  onMessageAppend?: (message: Message) => void;
55
+ /** Persistable, allowlisted diagnostics for terminal provider failures. */
56
+ onProviderError?: (error: SanitizedProviderError) => void;
54
57
  onToolResult?: (toolName: string, result: ToolResult) => void;
55
58
  onModeUpdate?: (mode: PermissionMode) => void;
56
59
  /**
@@ -157,6 +160,7 @@ export declare class Agent {
157
160
  private onModeUpdate?;
158
161
  private onCompactionApplied?;
159
162
  private onMessageAppend?;
163
+ private onProviderError?;
160
164
  private onToolResult?;
161
165
  private hookDefinitions;
162
166
  private externalHooks?;
@@ -440,6 +444,8 @@ export declare class Agent {
440
444
  */
441
445
  private persistCompactionSummary;
442
446
  private appendMessage;
447
+ private persistProviderError;
448
+ private recordProviderError;
443
449
  private appendInterruptedAssistantBoundary;
444
450
  private executeTool;
445
451
  }
package/dist/agent.js CHANGED
@@ -32,6 +32,7 @@ import { createStreamingInternalReminderSanitizer, sanitizeAssistantProviderMeta
32
32
  import { debugReasoningStream, summarizeDebugText } from "./reasoning-debug.js";
33
33
  import { buildToolPromptOptions } from "./tools/prompt-metadata.js";
34
34
  import { stopAutoServersForSession } from "./tools/server-manager.js";
35
+ import { createSanitizedProviderError, } from "./provider-error-record.js";
35
36
  import { summarizeAgentEventForTrace, summarizeTraceError, summarizeTraceMessage, summarizeTraceToolResult, summarizeTraceValue, traceEvent, } from "./debug-trace.js";
36
37
  const MAX_CONSECUTIVE_OVERFLOW_RECOVERIES = 2;
37
38
  const MAX_EMPTY_ASSISTANT_RECOVERIES = 1;
@@ -127,6 +128,7 @@ export class Agent {
127
128
  onModeUpdate;
128
129
  onCompactionApplied;
129
130
  onMessageAppend;
131
+ onProviderError;
130
132
  onToolResult;
131
133
  hookDefinitions;
132
134
  externalHooks;
@@ -174,6 +176,7 @@ export class Agent {
174
176
  this.thinkingLevel = options.thinkingLevel ?? "off";
175
177
  this._mode = options.mode ?? "default";
176
178
  this.onMessageAppend = options.onMessageAppend;
179
+ this.onProviderError = options.onProviderError;
177
180
  this.onToolResult = options.onToolResult;
178
181
  this.onModeUpdate = options.onModeUpdate;
179
182
  this.onCompactionApplied = options.onCompactionApplied;
@@ -228,6 +231,7 @@ export class Agent {
228
231
  // profile is the author pre-unlocking them).
229
232
  allTools: () => [...this.tools.values()],
230
233
  createChild: (spec) => this.createChildAgent(spec),
234
+ recordProviderError: (error, context) => this.recordProviderError(error, context),
231
235
  runExternalHook: (input, abortSignal) => this.runExternalHook(input, abortSignal),
232
236
  },
233
237
  router: this.router,
@@ -826,6 +830,11 @@ export class Agent {
826
830
  parameters: t.parameters,
827
831
  }));
828
832
  const toolDefinitionTokens = estimateToolDefinitionsTokens(toolDefinitions, this.providerId);
833
+ let currentRequestMessageCount = this.messages.length;
834
+ const persistTerminalProviderError = (error) => this.persistProviderError(error, {
835
+ messageCount: currentRequestMessageCount,
836
+ toolCount: toolDefinitions.length,
837
+ });
829
838
  // LLM-driven compaction runs ahead of projector's algorithmic passes. If
830
839
  // it succeeds, this.messages is replaced with [preserved system+meta] +
831
840
  // [LLM summary] + [last user msg], and the projector becomes a no-op for
@@ -845,6 +854,7 @@ export class Agent {
845
854
  // A fresh estimate also lets us account for current tool schemas.
846
855
  additionalInputTokens: toolDefinitionTokens,
847
856
  });
857
+ currentRequestMessageCount = projectedMessages.length;
848
858
  const requestBudget = getContextBudget(this.providerId, this.apiModel, projectedMessages, { additionalInputTokens: toolDefinitionTokens });
849
859
  currentRequestEstimate = requestBudget.estimatedTokens;
850
860
  const maxInputTokens = getMaxInputTokens(requestBudget.contextWindow);
@@ -1043,6 +1053,8 @@ export class Agent {
1043
1053
  error: summarizeTraceError(error),
1044
1054
  }, traceContext);
1045
1055
  if (assistantAppended) {
1056
+ if (!isAbortLikeError(error, abortSignal))
1057
+ persistTerminalProviderError(error);
1046
1058
  throw error;
1047
1059
  }
1048
1060
  const streamInterruption = isProviderStreamInterruption(error) ? error : undefined;
@@ -1077,9 +1089,12 @@ export class Agent {
1077
1089
  }));
1078
1090
  assistantAppended = true;
1079
1091
  }
1092
+ if (!isAbortLikeError(error, abortSignal))
1093
+ persistTerminalProviderError(error);
1080
1094
  throw error;
1081
1095
  }
1082
1096
  if (consecutiveOverflowRecoveries >= MAX_CONSECUTIVE_OVERFLOW_RECOVERIES) {
1097
+ persistTerminalProviderError(error);
1083
1098
  throw error;
1084
1099
  }
1085
1100
  const messagesBeforeRecovery = this.messages;
@@ -1108,6 +1123,7 @@ export class Agent {
1108
1123
  this.messages = messagesBeforeRecovery;
1109
1124
  this.lastInputTokens = inputTokensBeforeRecovery;
1110
1125
  this.lastAnchorMessageCount = anchorBeforeRecovery;
1126
+ persistTerminalProviderError(error);
1111
1127
  throw error;
1112
1128
  }
1113
1129
  consecutiveOverflowRecoveries += 1;
@@ -1983,6 +1999,26 @@ export class Agent {
1983
1999
  this.onMessageAppend?.(message);
1984
2000
  this.notifyContextChanged();
1985
2001
  }
2002
+ persistProviderError(error, request) {
2003
+ this.recordProviderError(error, {
2004
+ providerId: this.providerId,
2005
+ modelId: this.apiModel,
2006
+ model: this._model,
2007
+ thinkingLevel: this.thinkingLevel,
2008
+ messageCount: request.messageCount,
2009
+ toolCount: request.toolCount,
2010
+ });
2011
+ }
2012
+ recordProviderError(error, context) {
2013
+ if (!this.onProviderError)
2014
+ return;
2015
+ try {
2016
+ this.onProviderError(createSanitizedProviderError(error, context));
2017
+ }
2018
+ catch {
2019
+ // Diagnostics are best-effort and must never replace the provider error.
2020
+ }
2021
+ }
1986
2022
  appendInterruptedAssistantBoundary(currentAssistant, currentAssistantAppended) {
1987
2023
  const last = lastProviderMessage(this.messages);
1988
2024
  if (last?.role === "assistant" && last.error?.aborted) {
package/dist/cli.d.ts CHANGED
@@ -12,7 +12,8 @@ export interface CliArgs {
12
12
  sessionName?: string;
13
13
  print?: boolean;
14
14
  /** Print-mode output: plain (default) or a single JSON object on stdout. */
15
- outputFormat?: "plain" | "json";
15
+ outputFormat?: "plain" | "json" | "stream-json";
16
+ inputFormat?: "text" | "stream-json";
16
17
  prompt?: string;
17
18
  thinkingLevel?: ThinkingLevel;
18
19
  mode?: PermissionMode;
package/dist/cli.js CHANGED
@@ -57,13 +57,21 @@ export function parseArgs(argv) {
57
57
  break;
58
58
  case "--output-format": {
59
59
  const value = argv[++i];
60
- if (value !== "plain" && value !== "json") {
61
- console.error(`Invalid --output-format: ${value ?? "(missing)"}. Expected plain or json.`);
60
+ if (value !== "plain" && value !== "json" && value !== "stream-json") {
61
+ console.error(`Invalid --output-format: ${value ?? "(missing)"}. Expected plain, json, or stream-json.`);
62
62
  process.exit(1);
63
63
  }
64
64
  args.outputFormat = value;
65
65
  break;
66
66
  }
67
+ case "--input-format": {
68
+ const value = argv[++i];
69
+ if (value !== "text" && value !== "stream-json") {
70
+ throw new Error("--input-format expects text or stream-json");
71
+ }
72
+ args.inputFormat = value;
73
+ break;
74
+ }
67
75
  case "--plan":
68
76
  args.mode = "plan";
69
77
  break;
@@ -96,6 +104,16 @@ export function parseArgs(argv) {
96
104
  break;
97
105
  }
98
106
  }
107
+ const streamingInput = args.inputFormat === "stream-json";
108
+ const streamingOutput = args.outputFormat === "stream-json";
109
+ if (streamingInput || streamingOutput) {
110
+ if (!streamingInput || !streamingOutput || !args.print || args.prompt || args.command !== "default") {
111
+ throw new Error("Persistent mode requires -p --input-format stream-json --output-format stream-json and prompts on stdin");
112
+ }
113
+ if (args.resume && !args.sessionName) {
114
+ throw new Error("Persistent mode requires --session when using --resume (no interactive picker)");
115
+ }
116
+ }
99
117
  return args;
100
118
  }
101
119
  export function printHelp() {
@@ -117,8 +135,9 @@ Options (default):
117
135
  --dangerously-skip-permissions
118
136
  Enable bypass mode (auto-approve EVERY tool; disables all safety prompts)
119
137
  -p, --print Non-interactive mode (single prompt)
120
- --output-format <fmt> Print-mode output: plain (default) or json
138
+ --output-format <fmt> Print-mode output: plain (default), json, or stream-json
121
139
  (one JSON object on stdout: text, usage, num_turns)
140
+ --input-format <fmt> text (default) or stream-json; use both stream-json flags with -p\n for a persistent session (one user JSON message per line)
122
141
  -v, --version Print the installed version and exit
123
142
  -h, --help Show this help
124
143
 
@@ -36,8 +36,11 @@ export function estimateMessageTokens(message, providerId) {
36
36
  }
37
37
  }
38
38
  function estimateProviderMetadataOverhead(metadata, providerId) {
39
- const blocks = metadata?.anthropic?.contentBlocks;
40
- if (!blocks || blocks.length === 0)
39
+ const blocks = [
40
+ ...(metadata?.anthropic?.contentBlocks ?? []),
41
+ ...(metadata?.openai?.contentBlocks ?? []),
42
+ ];
43
+ if (blocks.length === 0)
41
44
  return 0;
42
45
  const estimate = (text) => estimateTextTokens(text, providerId);
43
46
  return blocks.reduce((sum, block) => {
@@ -46,6 +49,8 @@ function estimateProviderMetadataOverhead(metadata, providerId) {
46
49
  overhead += estimate(block.signature);
47
50
  if (block.type === "redacted_thinking" && typeof block.data === "string")
48
51
  overhead += estimate(block.data);
52
+ if (block.type === "reasoning" && typeof block.encrypted_content === "string")
53
+ overhead += estimate(block.encrypted_content);
49
54
  return sum + overhead;
50
55
  }, 0);
51
56
  }
@@ -118,8 +118,14 @@ export function buildCompactionSummaryMessage(summary) {
118
118
  }
119
119
  export function planSessionCompaction(entries, options = {}) {
120
120
  const keepRecentTurns = options.keepRecentTurns ?? 2;
121
- const metadataEntries = entries.filter((entry) => entry.type === "metadata");
122
- const nonMetadataEntries = entries.filter((entry) => entry.type !== "metadata");
121
+ // Provider errors are diagnostics, not model context. Keep the latest audit
122
+ // records across compaction without allowing repeated failures to grow the
123
+ // compacted session forever.
124
+ const metadataEntries = [
125
+ ...entries.filter((entry) => entry.type === "metadata"),
126
+ ...entries.filter((entry) => entry.type === "provider_error").slice(-20),
127
+ ];
128
+ const nonMetadataEntries = entries.filter((entry) => entry.type !== "metadata" && entry.type !== "provider_error");
123
129
  const latestSummaryIndex = findLatestSummaryIndex(nonMetadataEntries);
124
130
  const baseIndex = latestSummaryIndex >= 0 ? latestSummaryIndex + 1 : 0;
125
131
  const activeEntries = nonMetadataEntries.slice(baseIndex);
@@ -130,6 +130,9 @@ export class RunDriver {
130
130
  recordMemoryCitations(session.cwd, message.content);
131
131
  }
132
132
  },
133
+ onProviderError: (error) => {
134
+ session.manager.appendProviderError(error);
135
+ },
133
136
  onCompactionApplied: (summary) => {
134
137
  session.manager.applyLLMCompaction(summary);
135
138
  },
package/dist/main.js CHANGED
@@ -20,6 +20,7 @@ import { SkillRegistry } from "./skills/registry.js";
20
20
  import { buildToolPromptOptions, createAllTools } from "./tools/index.js";
21
21
  import { getProcessManager } from "./tasks/manager.js";
22
22
  import { PromotionChannel } from "./tasks/promotion.js";
23
+ import { runStreamJsonSession } from "./stream-json.js";
23
24
  import { PrintRunCollector, formatPrintJson, formatPrintJsonError } from "./print-output.js";
24
25
  import { FileStateTracker } from "./tools/file-state.js";
25
26
  import { GoalStore } from "./goal/store.js";
@@ -418,6 +419,9 @@ async function main() {
418
419
  recordMemoryCitations(args.cwd, message.content);
419
420
  }
420
421
  },
422
+ onProviderError: (error) => {
423
+ sessionManager?.appendProviderError(error);
424
+ },
421
425
  // Auto-compaction summaries are meta messages (dropped above); persist the
422
426
  // compacted state as a session summary entry so it survives resume.
423
427
  onCompactionApplied: (summary) => {
@@ -556,7 +560,7 @@ async function main() {
556
560
  }
557
561
  agent.injectDeferredToolsReminder();
558
562
  // JSON print mode reserves stdout for the single result object.
559
- (args.outputFormat === "json" ? console.error : console.log)(chalk.dim(`Resumed session: ${sessionManager.getSessionFile()}`));
563
+ (args.outputFormat === "json" || args.outputFormat === "stream-json" ? console.error : console.log)(chalk.dim(`Resumed session: ${sessionManager.getSessionFile()}`));
560
564
  }
561
565
  }
562
566
  try {
@@ -567,6 +571,17 @@ async function main() {
567
571
  process.exitCode = 1;
568
572
  return;
569
573
  }
574
+ if (args.inputFormat === "stream-json") {
575
+ await runStreamJsonSession({
576
+ input: process.stdin,
577
+ write: (message) => { process.stdout.write(JSON.stringify(message) + "\n"); },
578
+ sessionId: basename(sessionManager.getSessionFile()),
579
+ model: agent.model,
580
+ run: (prompt) => agent.run(prompt, args.cwd),
581
+ onEvent: (event) => traceEvent("print_agent_event", summarizeAgentEventForTrace(event)),
582
+ });
583
+ return;
584
+ }
570
585
  const prompt = args.prompt || (await readPipedStdin()) || "";
571
586
  if (!prompt) {
572
587
  console.error(chalk.red("Error: No prompt provided."));