@koda-sl/baker-bridge 0.39.2 → 0.39.6-dev.8ba3c150

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.
package/README.md CHANGED
@@ -72,10 +72,16 @@ The bridge picks a Claude model per turn before calling the Agent SDK:
72
72
  | `GET` | `/landings/:slug/definition` | None | Return landing `_definition.md` as `{ content, title }` |
73
73
  | `GET` | `/flows/:slug` | None | Read flow `_data.json` (decrypted) |
74
74
  | `PUT` | `/flows/:slug` | None | Write flow `_data.json` (encrypts at rest) |
75
- | `POST` | `/message/async` | Bearer token | Dispatch a chat turn — fire-and-forget, 202 immediately |
75
+ | `POST` | `/message/async` | Bearer token | Dispatch a chat turn — fire-and-forget, 202 immediately. Convex must include `turnId` and may include rendered `history` for deterministic rehydration |
76
76
  | `POST` | `/message/abort` | Bearer token | Abort a running query — kills the SDK iterator |
77
77
  | `POST` | `/answer-question` | Bearer token | Resolve a pending `AskUserQuestion` |
78
78
 
79
+ ## Agent scheduling
80
+
81
+ The bridge disables Claude Code's native cron tools (`CronCreate`, `CronDelete`,
82
+ `CronList`, and `ScheduleWakeup`) in Agent SDK sessions. Scheduled work must use
83
+ Baker's product surface instead: `baker scheduled-actions create/update/delete/trigger`.
84
+
79
85
  ## Chat flow
80
86
 
81
87
  ```
@@ -106,7 +112,12 @@ Convex action ──POST /message/async──▶ Bridge AgentSession
106
112
  - **Stop button** flips the thread to `cancelled` via Convex first (UI is
107
113
  freed immediately) and best-effort POSTs `/message/abort` to kill the SDK.
108
114
  - **AskUserQuestion** posts `/api/chat/input-request` and blocks until the
109
- dashboard answers via Convex action → bridge `/answer-question`.
115
+ dashboard answers via Convex action → bridge `/answer-question`. Convex
116
+ persists the answer before proxying it so a bridge restart does not erase the
117
+ user's response.
118
+ - **Context source diagnostics** go to `/api/chat/context` so support can tell
119
+ whether a turn used `local_resume`, `rehydrated_missing_session`, or
120
+ `rehydrated_invalid_resume`.
110
121
 
111
122
  ## Attachment validation
112
123
 
@@ -141,6 +152,16 @@ oversized images still block the commit.
141
152
  when no SDK events are flowing. Without it, the 2 min stale-thread cron
142
153
  would falsely timeout active threads.
143
154
  - The session registry evicts entries idle for >24h to bound memory.
155
+ - Convex dispatches include a bounded rendered current-thread history snapshot.
156
+ The bridge uses the local SDK session id when valid, but falls back to this
157
+ persisted transcript when the session file is missing or invalid; invalid
158
+ resume is retried at most once before surfacing an error. Prior attachment
159
+ metadata is rendered by Convex, capped, and counted against the same
160
+ deterministic history budget.
161
+ - Each dispatched turn carries an explicit `turnId`; bridge callbacks preserve
162
+ that run-scoped id through stream, input, heartbeat, diagnostics, completion,
163
+ and background completion retries so stale callbacks cannot be retagged as a
164
+ newer turn.
144
165
  - A new chat that arrives while a previous one is still running aborts the
145
166
  previous run with a 30s timeout cap — a hung run cannot block the next message.
146
167
 
@@ -159,8 +180,9 @@ returned in the dispatch response besides 202.
159
180
  ## Architecture
160
181
 
161
182
  - **One session per thread** — `getOrCreateSession(threadId)` ensures every
162
- message on the same thread shares an `AgentSession`. Multi-turn context is
163
- preserved across bridge restarts via `~/.baker/sessions/<threadId>.json`.
183
+ message on the same thread shares an `AgentSession`. Multi-turn context uses
184
+ `~/.baker/sessions/<threadId>.json` as the fast path and Convex persisted
185
+ current-thread history as the fallback when local continuity is unavailable.
164
186
  - **Run-token cancellation** — each call to `sendAndStream` mints a Symbol; if
165
187
  the symbol changes (abort, replace) the run cleans up and exits without
166
188
  writing further. No globally-mutable abort flag.
package/dist/cli.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  declare const args: string[];
3
3
  declare const command: string;
4
- declare const VERSION = "0.39.1";
4
+ declare const VERSION = "0.39.6";
5
5
  declare function printUsage(): void;
6
6
  declare function main(): Promise<void>;
7
7
  //# sourceMappingURL=cli.d.ts.map
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
  const args = process.argv.slice(2);
4
4
  const command = args[0];
5
- const VERSION = "0.39.1";
5
+ const VERSION = "0.39.6";
6
6
  function printUsage() {
7
7
  console.error("Usage: baker-bridge <command>");
8
8
  console.error("");
@@ -1,11 +1,11 @@
1
1
  import type { SlashCommand } from "@anthropic-ai/claude-agent-sdk";
2
- import type { ChatAttachment } from "./types.ts";
2
+ import type { ChatAttachment, DispatchHistorySnapshot } from "./types.ts";
3
+ export declare function buildRehydratedPrompt(content: string, history?: DispatchHistorySnapshot): string;
4
+ export declare function isResumeSessionError(error: unknown): boolean;
3
5
  declare class AgentSession {
4
6
  readonly threadId: string;
5
7
  private sessionId;
6
8
  private pendingQuestions;
7
- private handlerCtx;
8
- private options;
9
9
  /** Identity token for the currently-running stream. abort() nulls this; if a
10
10
  * later check sees a different value, the run knows it was abandoned. This
11
11
  * replaces a global `aborted` flag whose reset-on-entry was racy. */
@@ -19,18 +19,32 @@ declare class AgentSession {
19
19
  * assistant event when the SDK doesn't emit one (pure-text, no tool calls). */
20
20
  private turnText;
21
21
  private postedAssistantEvent;
22
+ private sawSdkEvent;
22
23
  lastUsedAt: number;
23
24
  constructor(threadId: string);
24
25
  /** Cancel any in-flight run. The next status check inside runStream will trip. */
25
26
  abort(): void;
26
27
  setSessionId(id: string): void;
27
28
  resolveQuestion(toolUseId: string, answers: Record<string, string>): boolean;
28
- sendAndStream(content: string, attachments?: ChatAttachment[]): Promise<void>;
29
+ sendAndStream(content: string, attachments: ChatAttachment[] | undefined, history: DispatchHistorySnapshot | undefined, turnId: string): Promise<void>;
29
30
  private runStream;
31
+ private startRun;
32
+ private streamPreparedRun;
33
+ private startWatchdog;
34
+ private runWithResumeFallback;
35
+ private recordInitialContextSource;
36
+ private canRetryWithRehydration;
37
+ private recordInvalidResumeFallback;
38
+ private runQueryOnce;
39
+ private consumeSdkMessage;
40
+ private completeSuccessfulRun;
41
+ private postSyntheticAssistantEvent;
42
+ private recordRehydrationFailure;
30
43
  private handleStreamEvent;
31
44
  private handleContentBlockStart;
32
45
  private handleMessage;
33
46
  private preparePrompt;
47
+ private selectPreparedPrompt;
34
48
  private completeWithCancel;
35
49
  private completeWithError;
36
50
  }
@@ -1 +1 @@
1
- {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../src/hono/agent.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAuB,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAmBxF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AA+CjD,cAAM,YAAY;IAChB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,OAAO,CAAC,SAAS,CAAqB;IACtC,OAAO,CAAC,gBAAgB,CAAsC;IAC9D,OAAO,CAAC,UAAU,CAAqB;IACvC,OAAO,CAAC,OAAO,CAAU;IAEzB;;0EAEsE;IACtE,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,WAAW,CAAgC;IACnD,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,gBAAgB,CAAuB;IAC/C,OAAO,CAAC,KAAK,CAAqB;IAClC;oFACgF;IAChF,OAAO,CAAC,QAAQ,CAAM;IACtB,OAAO,CAAC,oBAAoB,CAAS;IACrC,UAAU,EAAE,MAAM,CAAc;gBAEpB,QAAQ,EAAE,MAAM;IAM5B,kFAAkF;IAClF,KAAK,IAAI,IAAI;IAgBb,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAI9B,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO;IAUtE,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,cAAc,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;YA8BrE,SAAS;IAsGvB,OAAO,CAAC,iBAAiB;IAoBzB,OAAO,CAAC,uBAAuB;YAiBjB,aAAa;YAgBb,aAAa;YAoBb,kBAAkB;YAalB,iBAAiB;CAiBhC;AAiHD,wBAAsB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAahF;AAED,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAOtD;AAWD,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC,CAuBhE"}
1
+ {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../src/hono/agent.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAuB,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAoBxF,OAAO,KAAK,EAAE,cAAc,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAsB1E,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,uBAAuB,GAAG,MAAM,CAMhG;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAa5D;AAuCD,cAAM,YAAY;IAChB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,OAAO,CAAC,SAAS,CAAqB;IACtC,OAAO,CAAC,gBAAgB,CAAsC;IAE9D;;0EAEsE;IACtE,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,WAAW,CAAgC;IACnD,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,gBAAgB,CAAuB;IAC/C,OAAO,CAAC,KAAK,CAAqB;IAClC;oFACgF;IAChF,OAAO,CAAC,QAAQ,CAAM;IACtB,OAAO,CAAC,oBAAoB,CAAS;IACrC,OAAO,CAAC,WAAW,CAAS;IAC5B,UAAU,EAAE,MAAM,CAAc;gBAEpB,QAAQ,EAAE,MAAM;IAI5B,kFAAkF;IAClF,KAAK,IAAI,IAAI;IAgBb,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAI9B,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO;IAUtE,aAAa,CACjB,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,cAAc,EAAE,GAAG,SAAS,EACzC,OAAO,EAAE,uBAAuB,GAAG,SAAS,EAC5C,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC;YAoCF,SAAS;IAgCvB,OAAO,CAAC,QAAQ;YAYF,iBAAiB;IAyB/B,OAAO,CAAC,aAAa;YAeP,qBAAqB;IA8CnC,OAAO,CAAC,0BAA0B;IAiClC,OAAO,CAAC,uBAAuB;IAI/B,OAAO,CAAC,2BAA2B;YAcrB,YAAY;YAyBZ,iBAAiB;YAuBjB,qBAAqB;YAYrB,2BAA2B;IAYzC,OAAO,CAAC,wBAAwB;IAchC,OAAO,CAAC,iBAAiB;IAoBzB,OAAO,CAAC,uBAAuB;YAiBjB,aAAa;YAgBb,aAAa;YAiBb,oBAAoB;YAQpB,kBAAkB;YAalB,iBAAiB;CAgBhC;AAkHD,wBAAsB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAahF;AAED,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAOtD;AAWD,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC,CAuBhE"}
@@ -1,9 +1,9 @@
1
1
  import { query } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { buildAttachmentPromptSuffix, downloadAttachments } from "./attachments.js";
3
- import { clearStreamBuffer, enqueueStreamingTools, enqueueStreamText, flushStream, markStreamReplace, postEvent, postHeartbeat, postInputRequest, postInputResolved, postRouterSample, signalComplete, } from "./convex.js";
3
+ import { clearStreamBuffer, enqueueStreamingTools, enqueueStreamText, flushStream, markStreamReplace, postEvent, postHeartbeat, postInputRequest, postInputResolved, postRouterSample, recordContextSource, signalComplete, } from "./convex.js";
4
4
  import { selectModel } from "./model-router.js";
5
5
  import { recoverStalledPublish } from "./publish-recovery.js";
6
- import { loadSessionId, saveSessionId } from "./session-store.js";
6
+ import { clearSessionId, loadSessionId, saveSessionId } from "./session-store.js";
7
7
  // ---------------------------------------------------------------------------
8
8
  // Tunables
9
9
  // ---------------------------------------------------------------------------
@@ -22,6 +22,24 @@ const SESSION_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
22
22
  * hangs sooner so users see a real error instead of an indefinite spinner. */
23
23
  const ASK_USER_QUESTION_TIMEOUT_MS = 15 * 60 * 1000;
24
24
  const HEARTBEAT_INTERVAL_MS = 45_000;
25
+ export function buildRehydratedPrompt(content, history) {
26
+ if (!history?.renderedContext) {
27
+ return content;
28
+ }
29
+ return `${history.renderedContext}\n\n--- Current user request\n${content}`;
30
+ }
31
+ export function isResumeSessionError(error) {
32
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
33
+ if (!message.includes("session") && !message.includes("resume")) {
34
+ return false;
35
+ }
36
+ return (message.includes("resume") &&
37
+ (message.includes("not found") ||
38
+ message.includes("invalid") ||
39
+ message.includes("expired") ||
40
+ message.includes("unknown") ||
41
+ message.includes("does not exist")));
42
+ }
25
43
  // ---------------------------------------------------------------------------
26
44
  // AgentSession — one per thread, persists across messages so SDK resume works.
27
45
  // ---------------------------------------------------------------------------
@@ -29,8 +47,6 @@ class AgentSession {
29
47
  threadId;
30
48
  sessionId;
31
49
  pendingQuestions = new Map();
32
- handlerCtx;
33
- options;
34
50
  /** Identity token for the currently-running stream. abort() nulls this; if a
35
51
  * later check sees a different value, the run knows it was abandoned. This
36
52
  * replaces a global `aborted` flag whose reset-on-entry was racy. */
@@ -44,11 +60,10 @@ class AgentSession {
44
60
  * assistant event when the SDK doesn't emit one (pure-text, no tool calls). */
45
61
  turnText = "";
46
62
  postedAssistantEvent = false;
63
+ sawSdkEvent = false;
47
64
  lastUsedAt = Date.now();
48
65
  constructor(threadId) {
49
66
  this.threadId = threadId;
50
- this.handlerCtx = { threadId, pendingQuestions: this.pendingQuestions };
51
- this.options = buildSessionOptions(this.handlerCtx);
52
67
  }
53
68
  /** Cancel any in-flight run. The next status check inside runStream will trip. */
54
69
  abort() {
@@ -79,13 +94,14 @@ class AgentSession {
79
94
  this.pendingQuestions.delete(toolUseId);
80
95
  return true;
81
96
  }
82
- async sendAndStream(content, attachments) {
97
+ async sendAndStream(content, attachments, history, turnId) {
83
98
  if (this.streamingPromise) {
99
+ const previousPromise = this.streamingPromise;
84
100
  this.replacingStream = true;
85
101
  this.abort();
86
102
  try {
87
103
  await Promise.race([
88
- this.streamingPromise,
104
+ previousPromise,
89
105
  new Promise((_, reject) => setTimeout(() => reject(new Error("previous stream timed out")), REPLACE_STREAM_TIMEOUT_MS)),
90
106
  ]);
91
107
  }
@@ -93,123 +109,222 @@ class AgentSession {
93
109
  console.error(`[AgentSession] previous stream did not drain on thread ${this.threadId} — forcing reset:`, err instanceof Error ? err.message : err);
94
110
  }
95
111
  finally {
96
- this.streamingPromise = null;
112
+ if (this.streamingPromise === previousPromise) {
113
+ this.streamingPromise = null;
114
+ }
97
115
  this.replacingStream = false;
98
116
  }
99
117
  }
100
- this.streamingPromise = this.runStream(content, attachments);
118
+ const runPromise = this.runStream(content, attachments, history, turnId);
119
+ this.streamingPromise = runPromise;
101
120
  try {
102
- await this.streamingPromise;
121
+ await runPromise;
103
122
  }
104
123
  finally {
105
- this.streamingPromise = null;
124
+ if (this.streamingPromise === runPromise) {
125
+ this.streamingPromise = null;
126
+ }
106
127
  }
107
128
  }
108
- async runStream(content, attachments) {
129
+ async runStream(content, attachments, history, turnId) {
130
+ const runToken = this.startRun();
131
+ let result = { costUsd: 0, attemptedRehydration: false };
132
+ try {
133
+ const prepared = await this.preparePrompt(content, attachments, runToken, history);
134
+ if (!prepared) {
135
+ await this.completeWithCancel(0, turnId);
136
+ return;
137
+ }
138
+ result = await this.streamPreparedRun(prepared, history, runToken, turnId);
139
+ if (this.activeRun !== runToken) {
140
+ await this.completeWithCancel(result.costUsd, turnId);
141
+ return;
142
+ }
143
+ await this.completeSuccessfulRun(result.costUsd, turnId);
144
+ }
145
+ catch (error) {
146
+ if (result.attemptedRehydration) {
147
+ this.recordRehydrationFailure(history, error, turnId);
148
+ }
149
+ await this.completeWithError(result.costUsd, error, runToken, turnId);
150
+ }
151
+ }
152
+ startRun() {
109
153
  const runToken = Symbol("agent-run");
110
154
  this.activeRun = runToken;
111
155
  this.lastUsedAt = Date.now();
112
156
  this.currentTurnTools = [];
113
157
  this.turnText = "";
114
158
  this.postedAssistantEvent = false;
115
- let costUsd = 0;
159
+ this.sawSdkEvent = false;
116
160
  this.model = undefined;
161
+ return runToken;
162
+ }
163
+ async streamPreparedRun(prepared, history, runToken, turnId) {
164
+ const abortController = new AbortController();
165
+ this.activeAbort = abortController;
166
+ let lastEventAt = Date.now();
167
+ const watchdog = this.startWatchdog(() => lastEventAt, abortController);
168
+ const heartbeat = setInterval(() => void postHeartbeat(this.threadId, turnId), HEARTBEAT_INTERVAL_MS).unref();
117
169
  try {
118
- const prepared = await this.preparePrompt(content, attachments, runToken);
119
- if (!prepared) {
120
- await this.completeWithCancel(0);
170
+ return await this.runWithResumeFallback(prepared, history, runToken, turnId, abortController, (timestamp) => {
171
+ lastEventAt = timestamp;
172
+ });
173
+ }
174
+ finally {
175
+ clearInterval(watchdog);
176
+ clearInterval(heartbeat);
177
+ if (this.activeAbort === abortController) {
178
+ this.activeAbort = null;
179
+ }
180
+ }
181
+ }
182
+ startWatchdog(getLastEventAt, abortController) {
183
+ return setInterval(() => {
184
+ if (Date.now() - getLastEventAt() <= SDK_WATCHDOG_MS) {
121
185
  return;
122
186
  }
187
+ console.error(`[AgentSession] watchdog: no SDK event for ${SDK_WATCHDOG_MS}ms — aborting thread ${this.threadId}`);
188
+ abortController.abort();
189
+ }, 30_000).unref();
190
+ }
191
+ async runWithResumeFallback(prepared, history, runToken, turnId, abortController, markEvent) {
192
+ const attemptedResumeSessionId = this.sessionId;
193
+ const result = { costUsd: 0, attemptedRehydration: false };
194
+ result.attemptedRehydration = this.recordInitialContextSource(Boolean(attemptedResumeSessionId), history, turnId);
195
+ try {
123
196
  this.model = prepared.model;
124
- void postRouterSample(this.threadId, prepared.model, prepared.routerMessages);
125
- this.activeAbort = new AbortController();
126
- const opts = {
127
- ...this.options,
128
- model: prepared.model,
129
- abortController: this.activeAbort,
130
- ...(this.sessionId && { resume: this.sessionId }),
131
- };
132
- const q = query({ prompt: prepared.prompt, options: opts });
133
- let lastEventAt = Date.now();
134
- const watchdog = setInterval(() => {
135
- if (Date.now() - lastEventAt > SDK_WATCHDOG_MS) {
136
- console.error(`[AgentSession] watchdog: no SDK event for ${SDK_WATCHDOG_MS}ms — aborting thread ${this.threadId}`);
137
- clearInterval(watchdog);
138
- this.activeAbort?.abort();
139
- }
140
- }, 30_000).unref();
141
- const heartbeat = setInterval(() => {
142
- void postHeartbeat(this.threadId);
143
- }, HEARTBEAT_INTERVAL_MS).unref();
144
- try {
145
- for await (const msg of q) {
146
- lastEventAt = Date.now();
147
- this.lastUsedAt = lastEventAt;
148
- if (this.activeRun !== runToken) {
149
- break;
150
- }
151
- if (msg.session_id) {
152
- this.sessionId = msg.session_id;
153
- void saveSessionId(this.threadId, msg.session_id);
154
- }
155
- await this.handleMessage(msg);
156
- if (msg.type === "result") {
157
- costUsd = msg.total_cost_usd;
158
- }
159
- }
197
+ result.costUsd = await this.runQueryOnce(Boolean(attemptedResumeSessionId), prepared, runToken, turnId, abortController, markEvent);
198
+ void postRouterSample(this.threadId, prepared.model, prepared.routerMessages, turnId);
199
+ return result;
200
+ }
201
+ catch (error) {
202
+ if (!this.canRetryWithRehydration(attemptedResumeSessionId, error)) {
203
+ throw error;
160
204
  }
161
- finally {
162
- clearInterval(watchdog);
163
- clearInterval(heartbeat);
205
+ this.sessionId = undefined;
206
+ await clearSessionId(this.threadId);
207
+ this.recordInvalidResumeFallback(history, turnId);
208
+ result.attemptedRehydration = true;
209
+ const fallbackPrepared = await this.selectPreparedPrompt(buildRehydratedPrompt(prepared.prompt, history), runToken);
210
+ if (!fallbackPrepared) {
211
+ return result;
164
212
  }
165
- this.activeAbort = null;
213
+ this.model = fallbackPrepared.model;
214
+ result.costUsd = await this.runQueryOnce(false, fallbackPrepared, runToken, turnId, abortController, markEvent);
215
+ void postRouterSample(this.threadId, fallbackPrepared.model, fallbackPrepared.routerMessages, turnId);
216
+ return result;
217
+ }
218
+ }
219
+ recordInitialContextSource(hasResumeSession, history, turnId) {
220
+ if (hasResumeSession) {
221
+ void recordContextSource(this.threadId, "local_resume", {
222
+ historyEntryCount: history?.entries.length ?? 0,
223
+ historyTruncated: history?.truncated ?? false,
224
+ }, turnId);
225
+ return false;
226
+ }
227
+ if (!history || (history.entries.length === 0 && history.omittedEntryCount === 0)) {
228
+ return false;
229
+ }
230
+ void recordContextSource(this.threadId, "rehydrated_missing_session", {
231
+ historyEntryCount: history.entries.length,
232
+ omittedEntryCount: history.omittedEntryCount,
233
+ historyTruncated: history.truncated,
234
+ }, turnId);
235
+ return true;
236
+ }
237
+ canRetryWithRehydration(attemptedResumeSessionId, error) {
238
+ return Boolean(attemptedResumeSessionId && isResumeSessionError(error) && !this.sawSdkEvent);
239
+ }
240
+ recordInvalidResumeFallback(history, turnId) {
241
+ console.warn(`[AgentSession] clearing invalid local session for thread ${this.threadId} and retrying once`);
242
+ void recordContextSource(this.threadId, "rehydrated_invalid_resume", {
243
+ historyEntryCount: history?.entries.length ?? 0,
244
+ omittedEntryCount: history?.omittedEntryCount ?? 0,
245
+ historyTruncated: history?.truncated ?? false,
246
+ }, turnId);
247
+ }
248
+ async runQueryOnce(useResume, prepared, runToken, turnId, abortController, markEvent) {
249
+ let costUsd = 0;
250
+ const opts = {
251
+ ...buildSessionOptions({ threadId: this.threadId, pendingQuestions: this.pendingQuestions, turnId }),
252
+ model: prepared.model,
253
+ abortController,
254
+ ...(useResume && this.sessionId && { resume: this.sessionId }),
255
+ };
256
+ const q = query({ prompt: prepared.prompt, options: opts });
257
+ for await (const msg of q) {
258
+ costUsd = await this.consumeSdkMessage(msg, costUsd, runToken, turnId, markEvent);
166
259
  if (this.activeRun !== runToken) {
167
- await this.completeWithCancel(costUsd);
168
- return;
169
- }
170
- if (this.pendingQuestions.size > 0) {
171
- console.warn(`[AgentSession] SDK completed but ${String(this.pendingQuestions.size)} question(s) still pending on thread ${this.threadId}`);
260
+ break;
172
261
  }
173
- // The Agent SDK doesn't emit a `type: "assistant"` message for pure-text
174
- // turns (no tool calls). Synthesize one from the accumulated stream deltas
175
- // so the response is persisted and visible in the conversation.
176
- if (!this.postedAssistantEvent && this.turnText.length > 0) {
177
- const syntheticAssistant = {
178
- type: "assistant",
179
- message: {
180
- role: "assistant",
181
- content: [{ type: "text", text: this.turnText }],
182
- stop_reason: "end_turn",
183
- },
184
- };
185
- await postEvent(this.threadId, syntheticAssistant, this.model);
186
- }
187
- await signalComplete(this.threadId, costUsd, false, undefined, this.model);
188
262
  }
189
- catch (error) {
190
- await this.completeWithError(costUsd, error, runToken);
263
+ return costUsd;
264
+ }
265
+ async consumeSdkMessage(msg, costUsd, runToken, turnId, markEvent) {
266
+ const timestamp = Date.now();
267
+ markEvent(timestamp);
268
+ this.lastUsedAt = timestamp;
269
+ if (this.activeRun !== runToken) {
270
+ return costUsd;
271
+ }
272
+ this.sawSdkEvent = true;
273
+ if (msg.session_id) {
274
+ this.sessionId = msg.session_id;
275
+ void saveSessionId(this.threadId, msg.session_id);
276
+ }
277
+ await this.handleMessage(msg, turnId);
278
+ return msg.type === "result" ? msg.total_cost_usd : costUsd;
279
+ }
280
+ async completeSuccessfulRun(costUsd, turnId) {
281
+ if (this.pendingQuestions.size > 0) {
282
+ console.warn(`[AgentSession] SDK completed but ${String(this.pendingQuestions.size)} question(s) still pending on thread ${this.threadId}`);
191
283
  }
284
+ if (!this.postedAssistantEvent && this.turnText.length > 0) {
285
+ await this.postSyntheticAssistantEvent(turnId);
286
+ }
287
+ await signalComplete(this.threadId, costUsd, false, undefined, this.model, turnId);
288
+ }
289
+ async postSyntheticAssistantEvent(turnId) {
290
+ const syntheticAssistant = {
291
+ type: "assistant",
292
+ message: {
293
+ role: "assistant",
294
+ content: [{ type: "text", text: this.turnText }],
295
+ stop_reason: "end_turn",
296
+ },
297
+ };
298
+ await postEvent(this.threadId, syntheticAssistant, this.model, turnId);
192
299
  }
193
- handleStreamEvent(event) {
300
+ recordRehydrationFailure(history, error, turnId) {
301
+ void recordContextSource(this.threadId, "rehydration_failed", {
302
+ historyEntryCount: history?.entries.length ?? 0,
303
+ omittedEntryCount: history?.omittedEntryCount ?? 0,
304
+ historyTruncated: history?.truncated ?? false,
305
+ reason: error instanceof Error ? error.name : typeof error,
306
+ }, turnId);
307
+ }
308
+ handleStreamEvent(event, turnId) {
194
309
  if (event.type === "message_start") {
195
310
  this.currentTurnTools = [];
196
311
  this.turnText = "";
197
- markStreamReplace(this.threadId);
312
+ markStreamReplace(this.threadId, turnId);
198
313
  return;
199
314
  }
200
315
  if (event.type === "content_block_start") {
201
- this.handleContentBlockStart(event);
316
+ this.handleContentBlockStart(event, turnId);
202
317
  return;
203
318
  }
204
319
  if (event.type === "content_block_delta") {
205
320
  const delta = event.delta;
206
321
  if (delta?.type === "text_delta" && typeof delta.text === "string") {
207
322
  this.turnText += delta.text;
208
- enqueueStreamText(this.threadId, delta.text);
323
+ enqueueStreamText(this.threadId, delta.text, turnId);
209
324
  }
210
325
  }
211
326
  }
212
- handleContentBlockStart(event) {
327
+ handleContentBlockStart(event, turnId) {
213
328
  const block = event.content_block;
214
329
  if (block?.type !== "tool_use") {
215
330
  return;
@@ -223,25 +338,25 @@ class AgentSession {
223
338
  tool.input = block.input;
224
339
  }
225
340
  this.currentTurnTools = [...this.currentTurnTools, tool];
226
- enqueueStreamingTools(this.threadId, this.currentTurnTools);
341
+ enqueueStreamingTools(this.threadId, this.currentTurnTools, turnId);
227
342
  }
228
- async handleMessage(msg) {
343
+ async handleMessage(msg, turnId) {
229
344
  if (msg.type === "stream_event") {
230
345
  const event = msg.event;
231
346
  if (event) {
232
- this.handleStreamEvent(event);
347
+ this.handleStreamEvent(event, turnId);
233
348
  }
234
349
  return;
235
350
  }
236
351
  if (msg.type === "assistant") {
237
352
  this.currentTurnTools = [];
238
353
  this.postedAssistantEvent = true;
239
- await postEvent(this.threadId, msg, this.model);
354
+ await postEvent(this.threadId, msg, this.model, turnId);
240
355
  }
241
356
  // system / result messages are not persisted; result.cost_usd is captured above.
242
357
  }
243
- async preparePrompt(content, attachments, runToken) {
244
- let prompt = content;
358
+ async preparePrompt(content, attachments, runToken, history) {
359
+ let prompt = this.sessionId ? content : buildRehydratedPrompt(content, history);
245
360
  if (attachments && attachments.length > 0) {
246
361
  const paths = await downloadAttachments(attachments);
247
362
  prompt += buildAttachmentPromptSuffix(paths);
@@ -249,28 +364,30 @@ class AgentSession {
249
364
  if (this.activeRun !== runToken) {
250
365
  return null;
251
366
  }
367
+ return this.selectPreparedPrompt(prompt, runToken);
368
+ }
369
+ async selectPreparedPrompt(prompt, runToken) {
252
370
  const routerResult = await selectModel(prompt);
253
371
  if (this.activeRun !== runToken) {
254
372
  return null;
255
373
  }
256
374
  return { prompt, model: routerResult.model, routerMessages: routerResult.messages };
257
375
  }
258
- async completeWithCancel(costUsd) {
376
+ async completeWithCancel(costUsd, turnId) {
259
377
  // Don't fire signalComplete if we're being replaced by another stream — the
260
378
  // new stream's signalComplete will run instead, and the cancelThread mutation
261
379
  // already flipped the thread. (Convex completeThread is idempotent regardless.)
262
- await flushStream(this.threadId);
263
- clearStreamBuffer(this.threadId);
380
+ await flushStream(this.threadId, turnId);
381
+ clearStreamBuffer(this.threadId, turnId);
264
382
  if (this.replacingStream) {
265
383
  return;
266
384
  }
267
- await signalComplete(this.threadId, costUsd, true, ["Cancelled by user"], this.model);
385
+ await signalComplete(this.threadId, costUsd, true, ["Cancelled by user"], this.model, turnId);
268
386
  void recoverStalledPublish();
269
387
  }
270
- async completeWithError(costUsd, error, runToken) {
271
- this.activeAbort = null;
388
+ async completeWithError(costUsd, error, runToken, turnId) {
272
389
  if (this.activeRun !== runToken) {
273
- await this.completeWithCancel(costUsd);
390
+ await this.completeWithCancel(costUsd, turnId);
274
391
  return;
275
392
  }
276
393
  const rawMessage = error instanceof Error ? error.message : "Unknown error";
@@ -279,7 +396,7 @@ class AgentSession {
279
396
  if (this.pendingQuestions.size > 0) {
280
397
  console.warn(`[AgentSession] SDK errored with ${String(this.pendingQuestions.size)} question(s) still pending on thread ${this.threadId}`);
281
398
  }
282
- await signalComplete(this.threadId, costUsd, true, [message], this.model);
399
+ await signalComplete(this.threadId, costUsd, true, [message], this.model, turnId);
283
400
  void recoverStalledPublish();
284
401
  }
285
402
  }
@@ -297,7 +414,7 @@ function enrichImageError(message) {
297
414
  }
298
415
  async function handleAskUserQuestion(input, toolUseID, ctx, signal) {
299
416
  console.warn(`[AskUserQuestion] blocking for ${toolUseID} on thread ${ctx.threadId}`);
300
- const posted = await postInputRequest(ctx.threadId, toolUseID, input.questions);
417
+ const posted = await postInputRequest(ctx.threadId, toolUseID, input.questions, ctx.turnId);
301
418
  if (!posted) {
302
419
  console.error(`[AskUserQuestion] failed to post input-request for ${toolUseID} — frontend may not show question UI`);
303
420
  }
@@ -327,7 +444,7 @@ async function handleAskUserQuestion(input, toolUseID, ctx, signal) {
327
444
  }, { once: true });
328
445
  });
329
446
  console.warn(`[AskUserQuestion] ${toolUseID} answered, resuming`);
330
- void postInputResolved(ctx.threadId);
447
+ void postInputResolved(ctx.threadId, ctx.turnId);
331
448
  return {
332
449
  behavior: "allow",
333
450
  updatedInput: { questions: input.questions, answers },
@@ -341,6 +458,7 @@ function buildSessionOptions(handlerCtx) {
341
458
  cwd: process.cwd(),
342
459
  settingSources: ["project"],
343
460
  includePartialMessages: true,
461
+ disallowedTools: ["CronCreate", "CronDelete", "CronList", "ScheduleWakeup"],
344
462
  canUseTool: (toolName, input, opts) => {
345
463
  if (toolName === "AskUserQuestion") {
346
464
  return handleAskUserQuestion(input, opts.toolUseID, handlerCtx, opts.signal);