@agent-native/core 0.70.2 → 0.71.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 (93) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +19 -0
  3. package/corpus/core/docs/content/multi-app-workspace.md +2 -2
  4. package/corpus/core/docs/design/durable-agent-runs.md +458 -4
  5. package/corpus/core/package.json +1 -1
  6. package/corpus/core/src/action.ts +21 -10
  7. package/corpus/core/src/agent/durable-background.ts +192 -0
  8. package/corpus/core/src/agent/production-agent.ts +330 -13
  9. package/corpus/core/src/agent/run-manager.ts +66 -3
  10. package/corpus/core/src/agent/run-store.ts +129 -23
  11. package/corpus/core/src/agent/types.ts +20 -0
  12. package/corpus/core/src/client/AgentPanel.tsx +12 -9
  13. package/corpus/core/src/client/blocks/library/FileTreeBlock.tsx +72 -14
  14. package/corpus/core/src/deploy/build.ts +96 -0
  15. package/corpus/core/src/deploy/workspace-deploy.ts +121 -0
  16. package/corpus/core/src/mcp/build-server.ts +22 -13
  17. package/corpus/core/src/server/agent-chat-plugin.ts +133 -66
  18. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +19 -0
  19. package/corpus/templates/analytics/AGENTS.md +8 -0
  20. package/corpus/templates/analytics/actions/compose-dashboard.ts +317 -0
  21. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +33 -6
  22. package/corpus/templates/analytics/changelog/2026-06-23-build-large-first-party-analytics-dashboards-in-one-fast-cal.md +6 -0
  23. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +574 -0
  24. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +8 -2
  25. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -9
  26. package/corpus/templates/clips/changelog/2026-06-23-dragging-the-desktop-camera-bubble-now-glides-to-a-stop-at-t.md +6 -0
  27. package/corpus/templates/clips/changelog/2026-06-23-screen-recordings-now-capture-at-a-crisp-1080p-bitrate-inste.md +6 -0
  28. package/corpus/templates/clips/chrome-extension/package.json +1 -0
  29. package/corpus/templates/clips/chrome-extension/scripts/dev.ts +102 -0
  30. package/corpus/templates/clips/chrome-extension/src/background.ts +164 -32
  31. package/corpus/templates/clips/chrome-extension/src/content-script.ts +163 -13
  32. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +198 -35
  33. package/corpus/templates/clips/chrome-extension/src/overlay.css +73 -23
  34. package/corpus/templates/clips/chrome-extension/src/overlay.ts +82 -13
  35. package/corpus/templates/clips/chrome-extension/src/popup.html +62 -5
  36. package/corpus/templates/clips/chrome-extension/src/popup.ts +290 -1
  37. package/corpus/templates/clips/chrome-extension/src/styles.css +34 -0
  38. package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +21 -16
  39. package/corpus/templates/clips/desktop/src/lib/recorder.ts +17 -20
  40. package/corpus/templates/clips/desktop/src/overlays/bubble.tsx +73 -23
  41. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +0 -6
  42. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +56 -46
  43. package/corpus/templates/clips/desktop/src/styles.css +33 -5
  44. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +113 -0
  45. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
  46. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +2 -2
  47. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +9 -1
  48. package/corpus/templates/clips/shared/upload-limits.ts +5 -2
  49. package/corpus/templates/plan/app/pages/PlansPage.tsx +11 -7
  50. package/corpus/templates/plan/changelog/2026-06-23-plan-loading-skeleton-no-longer-cuts-off-the-canvas-preview-.md +6 -0
  51. package/dist/action.js +21 -10
  52. package/dist/action.js.map +1 -1
  53. package/dist/agent/durable-background.d.ts +60 -0
  54. package/dist/agent/durable-background.d.ts.map +1 -0
  55. package/dist/agent/durable-background.js +144 -0
  56. package/dist/agent/durable-background.js.map +1 -0
  57. package/dist/agent/production-agent.d.ts +20 -0
  58. package/dist/agent/production-agent.d.ts.map +1 -1
  59. package/dist/agent/production-agent.js +292 -14
  60. package/dist/agent/production-agent.js.map +1 -1
  61. package/dist/agent/run-manager.d.ts +48 -0
  62. package/dist/agent/run-manager.d.ts.map +1 -1
  63. package/dist/agent/run-manager.js +45 -3
  64. package/dist/agent/run-manager.js.map +1 -1
  65. package/dist/agent/run-store.d.ts +30 -1
  66. package/dist/agent/run-store.d.ts.map +1 -1
  67. package/dist/agent/run-store.js +124 -24
  68. package/dist/agent/run-store.js.map +1 -1
  69. package/dist/agent/types.d.ts +20 -0
  70. package/dist/agent/types.d.ts.map +1 -1
  71. package/dist/agent/types.js.map +1 -1
  72. package/dist/client/AgentPanel.d.ts.map +1 -1
  73. package/dist/client/AgentPanel.js +2 -2
  74. package/dist/client/AgentPanel.js.map +1 -1
  75. package/dist/client/blocks/library/FileTreeBlock.d.ts.map +1 -1
  76. package/dist/client/blocks/library/FileTreeBlock.js +37 -4
  77. package/dist/client/blocks/library/FileTreeBlock.js.map +1 -1
  78. package/dist/deploy/build.d.ts +29 -0
  79. package/dist/deploy/build.d.ts.map +1 -1
  80. package/dist/deploy/build.js +85 -0
  81. package/dist/deploy/build.js.map +1 -1
  82. package/dist/deploy/workspace-deploy.d.ts.map +1 -1
  83. package/dist/deploy/workspace-deploy.js +108 -0
  84. package/dist/deploy/workspace-deploy.js.map +1 -1
  85. package/dist/mcp/build-server.d.ts.map +1 -1
  86. package/dist/mcp/build-server.js +23 -9
  87. package/dist/mcp/build-server.js.map +1 -1
  88. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  89. package/dist/server/agent-chat-plugin.js +68 -13
  90. package/dist/server/agent-chat-plugin.js.map +1 -1
  91. package/docs/content/multi-app-workspace.md +2 -2
  92. package/docs/design/durable-agent-runs.md +458 -4
  93. package/package.json +1 -1
@@ -64,9 +64,42 @@ export const DEFAULT_HOSTED_RUN_SOFT_TIMEOUT_MS = 40_000;
64
64
  * env value just guarantees the cutoff is a hard error instead of a graceful
65
65
  * hand-off. Any resolved value above this is clamped down when hosted. Local
66
66
  * dev (non-hosted) is left alone so long-running local turns aren't chunked.
67
+ *
68
+ * IMPORTANT: this clamp is for the INTERACTIVE / foreground path and must NOT
69
+ * be raised. The foreground POST still rides a synchronous serverless function
70
+ * (~60-65s wall), so 40s remains correct there. The only sanctioned exception
71
+ * is the opt-in `backgroundFunction` mode (see
72
+ * `BACKGROUND_SOFT_TIMEOUT_CEILING_MS`), which runs inside a Netlify background
73
+ * function (no ~60s wall, 15-min budget) and therefore can safely outlast 40s.
67
74
  */
68
75
  export const HOSTED_SOFT_TIMEOUT_CEILING_MS = 40_000;
69
76
 
77
+ /**
78
+ * Hard ceiling for the soft timeout when a run executes inside a Netlify
79
+ * background function (any deployed function whose name ends in `-background`).
80
+ * Background functions return 202 immediately and run detached for up to 15
81
+ * minutes, so the ~60s synchronous function wall that 40s defends against does
82
+ * NOT apply. 13 minutes leaves ~2 min of headroom under Netlify's 15-min hard
83
+ * kill to abort, persist the partial turn, write the terminal event, and (for
84
+ * the rare >13-min turn) self-fire another background continuation.
85
+ *
86
+ * This ceiling is used ONLY when a caller explicitly opts in with
87
+ * `backgroundFunction: true`. It does not change the foreground/interactive
88
+ * ceiling and does not fire unless the durable-background path dispatched the
89
+ * run into a background function. Per the design doc Guardrail, the 40s
90
+ * interactive clamp stays correct for every non-background run.
91
+ */
92
+ export const BACKGROUND_SOFT_TIMEOUT_CEILING_MS = 13 * 60_000; // 780_000
93
+
94
+ /**
95
+ * Default soft-timeout budget for a background-function run when the caller
96
+ * does not pass an explicit `softTimeoutMs`. Same value as the ceiling — we
97
+ * want a background turn to use nearly its whole 15-min budget before handing
98
+ * off to a chained background continuation.
99
+ */
100
+ export const DEFAULT_BACKGROUND_RUN_SOFT_TIMEOUT_MS =
101
+ BACKGROUND_SOFT_TIMEOUT_CEILING_MS;
102
+
70
103
  /** Default SQL retention for completed run event logs (24 hours). */
71
104
  export const DEFAULT_COMPLETED_RUN_RETENTION_MS = 24 * 60 * 60 * 1000;
72
105
 
@@ -137,10 +170,28 @@ export interface StartRunOptions {
137
170
  * share one `turnId` so the durable assistant message can be folded across
138
171
  * them instead of dropped per-run. Defaults to the runId (turn == run). */
139
172
  turnId?: string;
173
+ /**
174
+ * Opt into the durable-background-function soft-timeout regime for THIS run
175
+ * only. When true, `resolveRunSoftTimeoutMs` lifts the hosted ceiling from
176
+ * 40s to ~13min (`BACKGROUND_SOFT_TIMEOUT_CEILING_MS`) because the run is
177
+ * executing inside a Netlify background function (no ~60s wall). Off by
178
+ * default — the foreground/interactive path never sets this, so its 40s
179
+ * clamp is unchanged. See the design doc + the durable-background dispatch
180
+ * decision in production-agent.ts.
181
+ */
182
+ backgroundFunction?: boolean;
140
183
  }
141
184
 
142
185
  export interface ResolveRunSoftTimeoutOptions {
143
186
  useHostedDefault?: boolean;
187
+ /**
188
+ * Resolve the soft timeout for a run executing inside a Netlify background
189
+ * function. Lifts the hosted clamp to `BACKGROUND_SOFT_TIMEOUT_CEILING_MS`
190
+ * (~13min) for this invocation only and, when no override/env is supplied,
191
+ * defaults to `DEFAULT_BACKGROUND_RUN_SOFT_TIMEOUT_MS`. Does NOT change the
192
+ * foreground ceiling. Off by default.
193
+ */
194
+ backgroundFunction?: boolean;
144
195
  }
145
196
 
146
197
  function isHostedRuntime(): boolean {
@@ -172,15 +223,21 @@ export function resolveRunSoftTimeoutMs(
172
223
  options?: ResolveRunSoftTimeoutOptions,
173
224
  ): number {
174
225
  const hosted = isHostedRuntime();
226
+ const background = options?.backgroundFunction === true;
227
+ // The interactive/foreground ceiling is 40s — the synchronous serverless
228
+ // function wall. A background-function run (opt-in only) has no ~60s wall, so
229
+ // it is allowed to outlast that and is clamped to the larger 13-min ceiling
230
+ // instead. The 40s clamp for non-background hosted runs is unchanged.
231
+ const ceiling = background
232
+ ? BACKGROUND_SOFT_TIMEOUT_CEILING_MS
233
+ : HOSTED_SOFT_TIMEOUT_CEILING_MS;
175
234
  // A configured/env soft timeout that exceeds the upstream walls can never
176
235
  // actually fire (the gateway/function kills the run first), so clamp it down
177
236
  // on hosted runtimes. This is what makes auto_continue reach the client
178
237
  // instead of the run dying as builder_gateway_timeout / stale_run. `0` means
179
238
  // "disabled" and is never clamped up.
180
239
  const clampHosted = (ms: number): number =>
181
- hosted && ms > HOSTED_SOFT_TIMEOUT_CEILING_MS
182
- ? HOSTED_SOFT_TIMEOUT_CEILING_MS
183
- : ms;
240
+ hosted && ms > ceiling ? ceiling : ms;
184
241
 
185
242
  if (typeof overrideMs === "number" && Number.isFinite(overrideMs)) {
186
243
  return clampHosted(Math.max(0, overrideMs));
@@ -190,6 +247,11 @@ export function resolveRunSoftTimeoutMs(
190
247
  const raw = Number(envValue);
191
248
  if (Number.isFinite(raw) && raw >= 0) return clampHosted(raw);
192
249
  }
250
+ // A background-function run uses the full background budget by default; the
251
+ // foreground default (40s) is unchanged.
252
+ if (background) {
253
+ return hosted ? DEFAULT_BACKGROUND_RUN_SOFT_TIMEOUT_MS : 0;
254
+ }
193
255
  return options?.useHostedDefault && hosted
194
256
  ? DEFAULT_HOSTED_RUN_SOFT_TIMEOUT_MS
195
257
  : 0;
@@ -343,6 +405,7 @@ export function startRun(
343
405
  }, 1500);
344
406
  const softTimeoutMs = resolveRunSoftTimeoutMs(options?.softTimeoutMs, {
345
407
  useHostedDefault: options?.useHostedSoftTimeoutDefault === true,
408
+ backgroundFunction: options?.backgroundFunction === true,
346
409
  });
347
410
  const softTimeoutTimer =
348
411
  softTimeoutMs > 0
@@ -18,6 +18,21 @@ let _initPromise: Promise<void> | undefined;
18
18
  */
19
19
  export const RUN_STALE_MS = 15_000;
20
20
 
21
+ /**
22
+ * Stale window for runs dispatched into a Netlify background function
23
+ * (`dispatch_mode = 'background'`). The design doc flags the 15s reaper vs a
24
+ * background cold-start as the #1 false-failure risk: the foreground POST
25
+ * inserts the `running` row, then `fireInternalDispatch` returns 202 and the
26
+ * background function may take >15s to cold-start and emit its first heartbeat.
27
+ * With the normal 15s window the reaper would falsely kill that freshly-
28
+ * inserted-but-not-yet-heartbeaten row. 90s tolerates a slow background
29
+ * cold-start while still reaping a genuinely dead background worker promptly.
30
+ *
31
+ * Only applied to rows explicitly marked background-dispatched; ordinary
32
+ * foreground runs keep the tight 15s window unchanged.
33
+ */
34
+ export const BACKGROUND_RUN_STALE_MS = 90_000;
35
+
21
36
  export const STALE_RUN_ERROR_EVENT = {
22
37
  type: "error",
23
38
  error:
@@ -44,7 +59,8 @@ async function ensureRunTables(): Promise<void> {
44
59
  last_progress_at ${intType()},
45
60
  turn_id TEXT,
46
61
  error_code TEXT,
47
- error_detail TEXT
62
+ error_detail TEXT,
63
+ dispatch_mode TEXT
48
64
  )
49
65
  `);
50
66
  // Backfill heartbeat_at on older deployments.
@@ -97,7 +113,16 @@ async function ensureRunTables(): Promise<void> {
97
113
  // error_code / error_detail = terminal failure classification captured
98
114
  // at completion so errored/cut-off runs are queryable for
99
115
  // pattern analysis (see listErroredRuns).
100
- for (const col of ["turn_id", "error_code", "error_detail"] as const) {
116
+ // dispatch_mode marks how a run was started: NULL/"foreground" for the
117
+ // normal synchronous path, "background" for a run dispatched into a
118
+ // Netlify background function. The reaper/claim widen the stale window
119
+ // for background rows so a slow cold-start isn't falsely reaped.
120
+ for (const col of [
121
+ "turn_id",
122
+ "error_code",
123
+ "error_detail",
124
+ "dispatch_mode",
125
+ ] as const) {
101
126
  try {
102
127
  if (isPostgres()) {
103
128
  await client.execute(
@@ -234,16 +259,68 @@ export async function insertRun(
234
259
  id: string,
235
260
  threadId: string,
236
261
  turnId?: string,
262
+ options?: { dispatchMode?: "foreground" | "background" },
237
263
  ): Promise<void> {
238
264
  await ensureRunTables();
239
265
  const client = getDbExec();
240
266
  const now = Date.now();
241
267
  await client.execute({
242
- sql: `INSERT INTO agent_runs (id, thread_id, status, started_at, heartbeat_at, last_progress_at, turn_id) VALUES (?, ?, 'running', ?, ?, ?, ?)`,
243
- args: [id, threadId, now, now, now, turnId ?? id],
268
+ sql: `INSERT INTO agent_runs (id, thread_id, status, started_at, heartbeat_at, last_progress_at, turn_id, dispatch_mode) VALUES (?, ?, 'running', ?, ?, ?, ?, ?)`,
269
+ args: [
270
+ id,
271
+ threadId,
272
+ now,
273
+ now,
274
+ now,
275
+ turnId ?? id,
276
+ options?.dispatchMode ?? null,
277
+ ],
244
278
  });
245
279
  }
246
280
 
281
+ /**
282
+ * SQL fragment that resolves the per-row stale cutoff. Background-dispatched
283
+ * runs (`dispatch_mode` starting with `background`) tolerate a much longer gap
284
+ * without a heartbeat (slow Netlify background-function cold-start) before being
285
+ * reaped; every other run keeps the tight 15s window. The bound `now` is
286
+ * subtracted by the resolved window so the comparison is
287
+ * `COALESCE(heartbeat_at, started_at) < (now - window)`.
288
+ *
289
+ * `dispatch_mode` is one of NULL/"foreground" (normal sync path), "background"
290
+ * (foreground inserted the row for a background dispatch), or
291
+ * "background-processing" (the background worker claimed it). Both background
292
+ * states get the wider window via a LIKE-prefix match.
293
+ */
294
+ function backgroundAwareStaleCutoffSql(): string {
295
+ return `(? - CASE WHEN dispatch_mode LIKE 'background%' THEN ${BACKGROUND_RUN_STALE_MS} ELSE ${RUN_STALE_MS} END)`;
296
+ }
297
+
298
+ /**
299
+ * Atomically claim a background-dispatched run for processing. The foreground
300
+ * POST inserts the run row with `dispatch_mode = 'background'`; the FIRST
301
+ * delivery of the background dispatch flips it to `background-processing` and
302
+ * wins the claim. A duplicate Netlify delivery (background functions can be
303
+ * retried) sees `background-processing` and loses, so it no-ops — mirroring
304
+ * `claimAgentTeamRun` returning null. Returns true when this caller won.
305
+ *
306
+ * Idempotent and conditional: the WHERE clause only matches the unclaimed
307
+ * `background` state AND a still-running row, so a reaped/terminal row can't be
308
+ * re-claimed.
309
+ */
310
+ export async function claimBackgroundRun(runId: string): Promise<boolean> {
311
+ await ensureRunTables();
312
+ const client = getDbExec();
313
+ const { rowsAffected } = await client.execute({
314
+ sql: `UPDATE agent_runs
315
+ SET dispatch_mode = 'background-processing'
316
+ WHERE id = ?
317
+ AND status = 'running'
318
+ AND dispatch_mode = 'background'`,
319
+ args: [runId],
320
+ });
321
+ return (rowsAffected ?? 0) > 0;
322
+ }
323
+
247
324
  /**
248
325
  * Atomically acquire a run lease for a thread. Succeeds (returns true) only
249
326
  * when no other run for the same thread is currently status='running' with a
@@ -256,18 +333,37 @@ export async function insertRun(
256
333
  */
257
334
  export async function tryClaimRunSlot(
258
335
  threadId: string,
259
- maxStaleMs: number = RUN_STALE_MS,
336
+ maxStaleMs?: number,
260
337
  ): Promise<{ claimed: boolean; activeRunId: string | null }> {
261
338
  await ensureRunTables();
262
339
  const client = getDbExec();
263
- const heartbeatCutoff = Date.now() - maxStaleMs;
340
+ const now = Date.now();
341
+ // Default: per-row background-aware window so a live background run (which can
342
+ // legitimately go >15s between heartbeats during a cold-start) isn't seen as
343
+ // "free" and double-claimed by a racing foreground POST. An explicit
344
+ // `maxStaleMs` override keeps a flat window for callers that want one.
345
+ if (typeof maxStaleMs === "number") {
346
+ const heartbeatCutoff = now - maxStaleMs;
347
+ const { rows } = await client.execute({
348
+ sql: `SELECT id FROM agent_runs
349
+ WHERE thread_id = ?
350
+ AND status = 'running'
351
+ AND COALESCE(heartbeat_at, started_at) >= ?
352
+ ORDER BY started_at DESC LIMIT 1`,
353
+ args: [threadId, heartbeatCutoff],
354
+ });
355
+ if (rows.length > 0) {
356
+ return { claimed: false, activeRunId: (rows[0] as { id: string }).id };
357
+ }
358
+ return { claimed: true, activeRunId: null };
359
+ }
264
360
  const { rows } = await client.execute({
265
361
  sql: `SELECT id FROM agent_runs
266
362
  WHERE thread_id = ?
267
363
  AND status = 'running'
268
- AND COALESCE(heartbeat_at, started_at) >= ?
364
+ AND COALESCE(heartbeat_at, started_at) >= ${backgroundAwareStaleCutoffSql()}
269
365
  ORDER BY started_at DESC LIMIT 1`,
270
- args: [threadId, heartbeatCutoff],
366
+ args: [threadId, now],
271
367
  });
272
368
  if (rows.length > 0) {
273
369
  const row = rows[0] as { id: string };
@@ -335,12 +431,20 @@ export async function bumpRunProgress(runId: string): Promise<void> {
335
431
  */
336
432
  export async function reapIfStale(
337
433
  runId: string,
338
- maxStaleMs: number = RUN_STALE_MS,
434
+ maxStaleMs?: number,
339
435
  ): Promise<boolean> {
340
436
  await ensureRunTables();
341
437
  const client = getDbExec();
342
438
  const completedAt = Date.now();
343
- const cutoff = completedAt - maxStaleMs;
439
+ // Background-dispatched runs get the wider stale window so a slow cold-start
440
+ // isn't reaped; foreground runs keep the tight 15s window. An explicit
441
+ // override forces a flat window for callers that want one.
442
+ const staleClause =
443
+ typeof maxStaleMs === "number"
444
+ ? `COALESCE(heartbeat_at, started_at) < ?`
445
+ : `COALESCE(heartbeat_at, started_at) < ${backgroundAwareStaleCutoffSql()}`;
446
+ const staleArgs =
447
+ typeof maxStaleMs === "number" ? [completedAt - maxStaleMs] : [completedAt];
344
448
  const { rowsAffected } = await client.execute({
345
449
  sql: `UPDATE agent_runs
346
450
  SET status = 'errored',
@@ -349,13 +453,13 @@ export async function reapIfStale(
349
453
  error_detail = ?
350
454
  WHERE id = ?
351
455
  AND status = 'running'
352
- AND COALESCE(heartbeat_at, started_at) < ?`,
456
+ AND ${staleClause}`,
353
457
  args: [
354
458
  completedAt,
355
459
  STALE_RUN_ERROR_EVENT.errorCode,
356
460
  STALE_RUN_ERROR_EVENT.details,
357
461
  runId,
358
- cutoff,
462
+ ...staleArgs,
359
463
  ],
360
464
  });
361
465
  const reaped = (rowsAffected ?? 0) > 0;
@@ -675,12 +779,13 @@ export async function getCurrentTurnEventsForThread(
675
779
  export async function reapAllStaleRuns(): Promise<number> {
676
780
  await ensureRunTables();
677
781
  const client = getDbExec();
678
- const heartbeatCutoff = Date.now() - RUN_STALE_MS;
782
+ const now = Date.now();
783
+ // Background-dispatched runs use the wider window; everything else 15s.
679
784
  const stale = await client.execute({
680
785
  sql: `SELECT id FROM agent_runs
681
786
  WHERE status = 'running'
682
- AND COALESCE(heartbeat_at, started_at) < ?`,
683
- args: [heartbeatCutoff],
787
+ AND COALESCE(heartbeat_at, started_at) < ${backgroundAwareStaleCutoffSql()}`,
788
+ args: [now],
684
789
  });
685
790
  const completedAt = Date.now();
686
791
  const { rowsAffected } = await client.execute({
@@ -690,12 +795,12 @@ export async function reapAllStaleRuns(): Promise<number> {
690
795
  error_code = ?,
691
796
  error_detail = ?
692
797
  WHERE status = 'running'
693
- AND COALESCE(heartbeat_at, started_at) < ?`,
798
+ AND COALESCE(heartbeat_at, started_at) < ${backgroundAwareStaleCutoffSql()}`,
694
799
  args: [
695
800
  completedAt,
696
801
  STALE_RUN_ERROR_EVENT.errorCode,
697
802
  STALE_RUN_ERROR_EVENT.details,
698
- heartbeatCutoff,
803
+ completedAt,
699
804
  ],
700
805
  });
701
806
  for (const row of stale.rows) {
@@ -730,15 +835,15 @@ export async function cleanupOldRuns(
730
835
  // SELECT covers BOTH UPDATE conditions so the terminal-event-append loop
731
836
  // below catches every row we're about to flip — a 24h-old row with a
732
837
  // somehow-fresh heartbeat would slip past a heartbeat-only SELECT.
733
- const heartbeatCutoff = Date.now() - RUN_STALE_MS;
838
+ const now = Date.now();
734
839
  const stale = await client.execute({
735
840
  sql: `SELECT id FROM agent_runs
736
841
  WHERE status = 'running'
737
842
  AND (
738
- COALESCE(heartbeat_at, started_at) < ?
843
+ COALESCE(heartbeat_at, started_at) < ${backgroundAwareStaleCutoffSql()}
739
844
  OR started_at < ?
740
845
  )`,
741
- args: [heartbeatCutoff, cutoff],
846
+ args: [now, cutoff],
742
847
  });
743
848
  const completedAt = Date.now();
744
849
  await client.execute({
@@ -755,7 +860,8 @@ export async function cleanupOldRuns(
755
860
  cutoff,
756
861
  ],
757
862
  });
758
- // Also expire runs whose heartbeat is stale — producer has died.
863
+ // Also expire runs whose heartbeat is stale — producer has died. Uses the
864
+ // background-aware window so a slow background cold-start isn't reaped early.
759
865
  await client.execute({
760
866
  sql: `UPDATE agent_runs
761
867
  SET status = 'errored',
@@ -763,12 +869,12 @@ export async function cleanupOldRuns(
763
869
  error_code = ?,
764
870
  error_detail = ?
765
871
  WHERE status = 'running'
766
- AND COALESCE(heartbeat_at, started_at) < ?`,
872
+ AND COALESCE(heartbeat_at, started_at) < ${backgroundAwareStaleCutoffSql()}`,
767
873
  args: [
768
874
  completedAt,
769
875
  STALE_RUN_ERROR_EVENT.errorCode,
770
876
  STALE_RUN_ERROR_EVENT.details,
771
- heartbeatCutoff,
877
+ completedAt,
772
878
  ],
773
879
  });
774
880
  for (const row of stale.rows) {
@@ -129,6 +129,26 @@ export interface AgentChatRequest {
129
129
  attachments?: AgentChatAttachment[];
130
130
  /** Internal retry/continuation requests should not create visible user turns. */
131
131
  internalContinuation?: boolean;
132
+ /**
133
+ * Internal marker set ONLY by the durable-background self-dispatch (see
134
+ * `AGENT_CHAT_BACKGROUND_RUN_FIELD`). Present when the agent-chat handler is
135
+ * re-entered as the background worker: it carries the pre-claimed `runId` and
136
+ * logical `turnId` so the worker runs the loop inline with the background
137
+ * soft-timeout instead of re-claiming the slot or re-dispatching. Untrusted
138
+ * on its own — the `_process-run` route HMAC-verifies the dispatch before
139
+ * invoking the handler. Absent on every normal client request.
140
+ */
141
+ __backgroundRun?: {
142
+ runId: string;
143
+ turnId?: string;
144
+ /**
145
+ * Number of server-driven background→background continuations already
146
+ * chained into this logical turn (0 on the first chunk). The worker
147
+ * increments this when it re-fires `_process-run` at a soft-timeout
148
+ * boundary and refuses to chain past `MAX_BACKGROUND_RUN_CONTINUATIONS`.
149
+ */
150
+ continuationCount?: number;
151
+ };
132
152
  /**
133
153
  * Stable identity for the logical assistant turn this request belongs to.
134
154
  * The client sends the SAME turnId for the initial POST and every
@@ -1072,6 +1072,18 @@ function AgentPanelInner({
1072
1072
  </button>
1073
1073
  </DropdownMenuTrigger>
1074
1074
  <DropdownMenuContent align="end" sideOffset={6} className="w-48">
1075
+ {onCollapse && (
1076
+ <>
1077
+ <DropdownMenuItem onSelect={onCollapse}>
1078
+ <IconLayoutSidebarRightCollapse
1079
+ size={14}
1080
+ className="shrink-0"
1081
+ />
1082
+ Collapse sidebar
1083
+ </DropdownMenuItem>
1084
+ <DropdownMenuSeparator />
1085
+ </>
1086
+ )}
1075
1087
  {mode === "chat" && toggleHistory && (
1076
1088
  <DropdownMenuItem onSelect={toggleHistory}>
1077
1089
  <IconHistory size={14} className="shrink-0" />
@@ -1133,15 +1145,6 @@ function AgentPanelInner({
1133
1145
  {isFullscreen ? "Exit fullscreen" : "Fullscreen"}
1134
1146
  </DropdownMenuItem>
1135
1147
  )}
1136
- {onCollapse && (
1137
- <DropdownMenuItem onSelect={onCollapse}>
1138
- <IconLayoutSidebarRightCollapse
1139
- size={14}
1140
- className="shrink-0"
1141
- />
1142
- Collapse sidebar
1143
- </DropdownMenuItem>
1144
- )}
1145
1148
  {((mode === "chat" && activeTabId) ||
1146
1149
  (mode === "cli" && canUseCodeTools && activeCliTab)) && (
1147
1150
  <>
@@ -119,6 +119,10 @@ interface FolderNode {
119
119
  /** Full slash path of the folder, used as a stable key. */
120
120
  path: string;
121
121
  children: TreeNode[];
122
+ /** Present when the folder was authored as an explicit directory entry (a
123
+ * `path` ending in `/`), carrying its note/metadata + flat `entries` index. */
124
+ entry?: FileTreeEntry;
125
+ index?: number;
122
126
  }
123
127
 
124
128
  type TreeNode = FolderNode | FileLeaf;
@@ -136,6 +140,10 @@ interface FolderBuild {
136
140
  files: FileLeaf[];
137
141
  /** Insertion order of child names (folders + files) for stable rendering. */
138
142
  order: string[];
143
+ /** Set when an explicit directory entry (trailing slash) targets this folder,
144
+ * carrying its note/metadata + flat `entries` index. */
145
+ entry?: FileTreeEntry;
146
+ index?: number;
139
147
  }
140
148
 
141
149
  function makeFolder(name: string, path: string): FolderBuild {
@@ -154,8 +162,15 @@ function buildTree(entries: FileTreeEntry[]): TreeNode[] {
154
162
  entries.forEach((entry, index) => {
155
163
  const segments = entry.path.split("/").filter(Boolean);
156
164
  if (segments.length === 0) return;
157
- const fileName = segments[segments.length - 1] as string;
158
- const folderSegments = segments.slice(0, -1);
165
+
166
+ // A trailing slash marks a DIRECTORY entry: every segment is a folder and
167
+ // the entry's note/metadata attaches to the deepest folder (the last segment
168
+ // does NOT become a file leaf). Because folders are keyed by name, this also
169
+ // merges with any folder the sibling file paths already implied — so
170
+ // `packages/shared/` and `packages/shared/src/…` collapse onto one `shared`
171
+ // folder instead of a duplicate folder + file pair.
172
+ const isDir = /\/\s*$/.test(entry.path);
173
+ const folderSegments = isDir ? segments : segments.slice(0, -1);
159
174
 
160
175
  let cursor = root;
161
176
  let prefix = "";
@@ -170,6 +185,17 @@ function buildTree(entries: FileTreeEntry[]): TreeNode[] {
170
185
  cursor = next;
171
186
  }
172
187
 
188
+ if (isDir) {
189
+ // Attach metadata to the deepest folder (first declaration wins, so an
190
+ // explicit note isn't clobbered by a later bare prefix re-declaration).
191
+ if (!cursor.entry) {
192
+ cursor.entry = entry;
193
+ cursor.index = index;
194
+ }
195
+ return;
196
+ }
197
+
198
+ const fileName = segments[segments.length - 1] as string;
173
199
  cursor.files.push({
174
200
  kind: "file",
175
201
  name: fileName,
@@ -191,6 +217,8 @@ function buildTree(entries: FileTreeEntry[]): TreeNode[] {
191
217
  name: child.name,
192
218
  path: child.path,
193
219
  children: materialize(child),
220
+ entry: child.entry,
221
+ index: child.index,
194
222
  });
195
223
  } else {
196
224
  const file = folder.files[Number(key.slice(2))];
@@ -212,7 +240,16 @@ function compactFolderNode(folder: FolderNode): FolderNode {
212
240
  let path = folder.path;
213
241
  let children = folder.children;
214
242
 
215
- while (children.length === 1 && children[0]?.kind === "folder") {
243
+ // Collapse single-child folder chains (a/b/c) into one row but never across
244
+ // a folder that carries its own directory note, so an explicitly authored
245
+ // directory (e.g. `packages/shared/` with a note) keeps its own row instead of
246
+ // being folded into its parent or child and losing the note.
247
+ while (
248
+ !folder.entry &&
249
+ children.length === 1 &&
250
+ children[0]?.kind === "folder" &&
251
+ !children[0].entry
252
+ ) {
216
253
  const child = children[0];
217
254
  names.push(child.name);
218
255
  path = child.path;
@@ -223,6 +260,8 @@ function compactFolderNode(folder: FolderNode): FolderNode {
223
260
  kind: "folder",
224
261
  name: names.join("/"),
225
262
  path,
263
+ entry: folder.entry,
264
+ index: folder.index,
226
265
  children: compactTree(children),
227
266
  };
228
267
  }
@@ -353,30 +392,49 @@ export function FileTreeRead({
353
392
  const indent = depth * INDENT_STEP;
354
393
  if (node.kind === "folder") {
355
394
  const collapsed = collapsedFolders[node.path] ?? false;
395
+ // An explicit directory entry (trailing-slash path) can be a leaf folder
396
+ // with no children — e.g. `apps/mail/`. It still renders as a folder, just
397
+ // without a toggle chevron. Its authored note shows inline like a file's.
398
+ const expandable = node.children.length > 0;
399
+ const note = node.entry?.note?.trim();
400
+ const open = expandable && !collapsed;
356
401
  return (
357
402
  <div key={`d:${node.path}`}>
358
403
  <button
359
404
  type="button"
360
405
  data-plan-interactive
361
- aria-expanded={!collapsed}
362
- onClick={() => toggleFolder(node.path)}
406
+ disabled={!expandable}
407
+ aria-expanded={expandable ? !collapsed : undefined}
408
+ onClick={expandable ? () => toggleFolder(node.path) : undefined}
363
409
  style={{ paddingLeft: indent + 8 }}
364
- className="flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left text-[13px] transition-colors hover:bg-accent/40"
410
+ className={cn(
411
+ "flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left text-[13px] transition-colors",
412
+ expandable ? "hover:bg-accent/40" : "cursor-default",
413
+ )}
365
414
  >
366
- <IconChevronRight
367
- className={cn(
368
- "size-3.5 shrink-0 text-plan-muted transition-transform",
369
- !collapsed && "rotate-90",
370
- )}
371
- />
372
- {collapsed ? (
373
- <IconFolder className="size-4 shrink-0 text-plan-muted" />
415
+ {expandable ? (
416
+ <IconChevronRight
417
+ className={cn(
418
+ "size-3.5 shrink-0 text-plan-muted transition-transform",
419
+ !collapsed && "rotate-90",
420
+ )}
421
+ />
374
422
  ) : (
423
+ <span className="size-3.5 shrink-0" aria-hidden />
424
+ )}
425
+ {open ? (
375
426
  <IconFolderOpen className="size-4 shrink-0 text-plan-muted" />
427
+ ) : (
428
+ <IconFolder className="size-4 shrink-0 text-plan-muted" />
376
429
  )}
377
430
  <span className="min-w-0 truncate font-medium text-plan-text">
378
431
  {node.name}
379
432
  </span>
433
+ {note && (
434
+ <span className="ml-1 min-w-0 flex-1 truncate text-xs text-plan-muted">
435
+ {note}
436
+ </span>
437
+ )}
380
438
  </button>
381
439
  </div>
382
440
  );