@hyperdrive.bot/paseo-server 0.3.41 → 0.3.43

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 (35) hide show
  1. package/dist/server/server/agent/agent-manager.d.ts +15 -0
  2. package/dist/server/server/agent/agent-manager.js +142 -25
  3. package/dist/server/server/agent/mcp-server.js +6 -1
  4. package/dist/server/server/agent/providers/claude/background-task-tracker.d.ts +38 -1
  5. package/dist/server/server/agent/providers/claude/background-task-tracker.js +114 -9
  6. package/dist/server/server/agent/providers/claude/background-work-kinds.d.ts +95 -0
  7. package/dist/server/server/agent/providers/claude/background-work-kinds.js +73 -0
  8. package/dist/server/server/agent/providers/claude/pty-session-launcher.js +3 -0
  9. package/dist/server/server/agent/providers/claude/transport/pty.d.ts +17 -0
  10. package/dist/server/server/agent/providers/claude/transport/pty.js +51 -1
  11. package/dist/server/server/agent/providers/claude/transport/tmux.d.ts +74 -0
  12. package/dist/server/server/agent/providers/claude/transport/tmux.js +157 -0
  13. package/dist/server/server/agent/providers/claude/transport/types.d.ts +6 -0
  14. package/dist/server/server/agent/providers/opencode-agent.d.ts +7 -0
  15. package/dist/server/server/agent/providers/opencode-agent.js +51 -1
  16. package/dist/server/server/bootstrap.js +1 -0
  17. package/dist/server/server/session.js +1 -0
  18. package/dist/server/server/workflow/workflow-agent-resolution.d.ts +82 -0
  19. package/dist/server/server/workflow/workflow-agent-resolution.js +105 -0
  20. package/dist/server/server/workflow/workflow-manager.d.ts +155 -20
  21. package/dist/server/server/workflow/workflow-manager.js +439 -31
  22. package/dist/server/server/workflow/workflow-progress.d.ts +53 -0
  23. package/dist/server/server/workflow/workflow-progress.js +96 -0
  24. package/dist/server/server/workspace-directory.js +32 -14
  25. package/dist/server/web-ui/_expo/static/js/web/{index-cb251ddad56c08c3021036af3a43fc0f.js → index-2ff48a7009ad2309c577d5a43b925f69.js} +18 -18
  26. package/dist/server/web-ui/_expo/static/js/web/index-2ff48a7009ad2309c577d5a43b925f69.js.br +0 -0
  27. package/dist/server/web-ui/_expo/static/js/web/index-2ff48a7009ad2309c577d5a43b925f69.js.gz +0 -0
  28. package/dist/server/web-ui/_expo/static/js/web/{index-cb251ddad56c08c3021036af3a43fc0f.js.map.br → index-2ff48a7009ad2309c577d5a43b925f69.js.map.br} +0 -0
  29. package/dist/server/web-ui/_expo/static/js/web/{index-cb251ddad56c08c3021036af3a43fc0f.js.map.gz → index-2ff48a7009ad2309c577d5a43b925f69.js.map.gz} +0 -0
  30. package/dist/server/web-ui/index.html +1 -1
  31. package/dist/server/web-ui/index.html.br +0 -0
  32. package/dist/server/web-ui/index.html.gz +0 -0
  33. package/package.json +6 -6
  34. package/dist/server/web-ui/_expo/static/js/web/index-cb251ddad56c08c3021036af3a43fc0f.js.br +0 -0
  35. package/dist/server/web-ui/_expo/static/js/web/index-cb251ddad56c08c3021036af3a43fc0f.js.gz +0 -0
@@ -142,6 +142,14 @@ interface ManagedAgentBase {
142
142
  lastUsage?: AgentUsage;
143
143
  lastError?: string;
144
144
  attention: AttentionState;
145
+ /**
146
+ * How many times a "finished" attention event was suppressed on this agent
147
+ * because it still owned armed background work (see checkAndSetAttention).
148
+ * Diagnostic only — never persisted, never projected to clients. It exists so
149
+ * the deliberately-accepted gap (a prose question swallowed mid-monitor) can
150
+ * be MEASURED from real sessions instead of argued about.
151
+ */
152
+ suppressedAttentionCount?: number;
145
153
  foregroundTurnWaiters: Set<ForegroundTurnWaiter>;
146
154
  finalizedForegroundTurnIds: Set<string>;
147
155
  unsubscribeSession: (() => void) | null;
@@ -472,6 +480,13 @@ export declare class AgentManager {
472
480
  private recordTimeline;
473
481
  private emitState;
474
482
  private syncFeaturesFromSession;
483
+ /**
484
+ * Live background work owned by this agent: monitors, crons, and backgrounded
485
+ * shells the provider is still tracking. Mirrors what `agent-projections`
486
+ * publishes as `activeBackgroundTaskCount`, read through the same optional
487
+ * hook so providers that do not implement it simply report zero.
488
+ */
489
+ private getActiveBackgroundTaskCount;
475
490
  private checkAndSetAttention;
476
491
  private enqueueBackgroundPersist;
477
492
  private enqueueDurableTimelineAppend;
@@ -228,7 +228,10 @@ export class AgentManager {
228
228
  this.mcpAuthToken = options?.mcpAuthToken ?? null;
229
229
  this.configurePaseoTools(options);
230
230
  this.appendSystemPrompt = options.appendSystemPrompt ?? "";
231
- this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
231
+ this.logger = options.logger.child({
232
+ module: "agent",
233
+ component: "agent-manager",
234
+ });
232
235
  this.rescueTimeouts = {
233
236
  reloadSessionCloseMs: options.rescueTimeouts?.reloadSessionCloseMs ?? RELOAD_SESSION_CLOSE_TIMEOUT_MS,
234
237
  interruptSessionMs: options.rescueTimeouts?.interruptSessionMs ?? INTERRUPT_SESSION_TIMEOUT_MS,
@@ -467,7 +470,9 @@ export class AgentManager {
467
470
  }
468
471
  }
469
472
  async listDraftCommands(config) {
470
- const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false });
473
+ const normalizedConfig = await this.normalizeConfig(config, {
474
+ resolveDefaultModel: false,
475
+ });
471
476
  const client = this.requireClient(normalizedConfig.provider);
472
477
  if (!normalizedConfig.model) {
473
478
  return [];
@@ -496,7 +501,9 @@ export class AgentManager {
496
501
  }
497
502
  }
498
503
  async listDraftFeatures(config) {
499
- const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false });
504
+ const normalizedConfig = await this.normalizeConfig(config, {
505
+ resolveDefaultModel: false,
506
+ });
500
507
  const client = this.requireClient(normalizedConfig.provider);
501
508
  if (!normalizedConfig.model) {
502
509
  return [];
@@ -600,7 +607,9 @@ export class AgentManager {
600
607
  async importProviderSession(input) {
601
608
  const resolvedAgentId = validateAgentId(this.idFactory(), "importProviderSession");
602
609
  this.requireEnabledProvider(input.provider);
603
- const client = await this.requireAvailableClient({ provider: input.provider });
610
+ const client = await this.requireAvailableClient({
611
+ provider: input.provider,
612
+ });
604
613
  if (!client.importSession) {
605
614
  throw new Error(`Provider '${input.provider}' does not support importing sessions`);
606
615
  }
@@ -908,7 +917,10 @@ export class AgentManager {
908
917
  async markRecordArchived(record) {
909
918
  const registry = this.requireRegistry();
910
919
  const archivedAt = new Date().toISOString();
911
- const archivedRecord = buildArchivedAgentRecord(record, { archivedAt, updatedAt: archivedAt });
920
+ const archivedRecord = buildArchivedAgentRecord(record, {
921
+ archivedAt,
922
+ updatedAt: archivedAt,
923
+ });
912
924
  await registry.upsert(archivedRecord);
913
925
  await this.archiveNativeSessionBestEffort(record.provider, record.persistence);
914
926
  if (this.agents.has(record.id)) {
@@ -1024,7 +1036,10 @@ export class AgentManager {
1024
1036
  throw new Error("Agent session does not support setting features");
1025
1037
  }
1026
1038
  await agent.session.setFeature(featureId, value);
1027
- agent.config.featureValues = { ...agent.config.featureValues, [featureId]: value };
1039
+ agent.config.featureValues = {
1040
+ ...agent.config.featureValues,
1041
+ [featureId]: value,
1042
+ };
1028
1043
  this.touchUpdatedAt(agent);
1029
1044
  this.emitState(agent);
1030
1045
  }
@@ -1072,7 +1087,9 @@ export class AgentManager {
1072
1087
  const priorColumn = touchesColumn && this.registry
1073
1088
  ? ((await this.registry.get(agentId))?.labels?.[KANBAN_LABEL_KEY] ?? null)
1074
1089
  : null;
1075
- const nextRecord = await this.writeStoredMetadata(agentId, { labels: patch });
1090
+ const nextRecord = await this.writeStoredMetadata(agentId, {
1091
+ labels: patch,
1092
+ });
1076
1093
  if (touchesColumn) {
1077
1094
  this.cardMoveLog?.record(agentId, priorColumn, nextRecord.labels?.[KANBAN_LABEL_KEY] ?? null);
1078
1095
  }
@@ -1107,7 +1124,9 @@ export class AgentManager {
1107
1124
  }
1108
1125
  return { record, live: true, previousParentAgentId: null };
1109
1126
  }
1110
- const { record } = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
1127
+ const { record } = await this.writeLabels(agentId, {
1128
+ [PARENT_AGENT_ID_LABEL]: null,
1129
+ });
1111
1130
  if (!record) {
1112
1131
  throw new Error(`Agent not found in storage after detach: ${agentId}`);
1113
1132
  }
@@ -1121,7 +1140,9 @@ export class AgentManager {
1121
1140
  if (!previousParentAgentId) {
1122
1141
  return { record, live: false, previousParentAgentId: null };
1123
1142
  }
1124
- const result = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
1143
+ const result = await this.writeLabels(agentId, {
1144
+ [PARENT_AGENT_ID_LABEL]: null,
1145
+ });
1125
1146
  if (!result.record) {
1126
1147
  throw new Error(`Agent not found in storage after detach: ${agentId}`);
1127
1148
  }
@@ -1292,7 +1313,9 @@ export class AgentManager {
1292
1313
  });
1293
1314
  return;
1294
1315
  }
1295
- this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
1316
+ this.dispatchStream(agent.id, event, {
1317
+ timestamp: new Date().toISOString(),
1318
+ });
1296
1319
  };
1297
1320
  void (async () => {
1298
1321
  try {
@@ -1432,7 +1455,10 @@ export class AgentManager {
1432
1455
  mutableAgent.lifecycle = nextLifecycle;
1433
1456
  const persistenceHandle = mutableAgent.session.describePersistence() ??
1434
1457
  (mutableAgent.runtimeInfo?.sessionId
1435
- ? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
1458
+ ? {
1459
+ provider: mutableAgent.provider,
1460
+ sessionId: mutableAgent.runtimeInfo.sessionId,
1461
+ }
1436
1462
  : null);
1437
1463
  if (persistenceHandle) {
1438
1464
  mutableAgent.persistence = attachPersistenceCwd(persistenceHandle, mutableAgent.cwd);
@@ -1589,7 +1615,9 @@ export class AgentManager {
1589
1615
  const bufferedResolution = agent.bufferedPermissionResolutions.get(requestId);
1590
1616
  if (bufferedResolution) {
1591
1617
  agent.bufferedPermissionResolutions.delete(requestId);
1592
- this.dispatchStream(agent.id, bufferedResolution, { timestamp: new Date().toISOString() });
1618
+ this.dispatchStream(agent.id, bufferedResolution, {
1619
+ timestamp: new Date().toISOString(),
1620
+ });
1593
1621
  }
1594
1622
  return result;
1595
1623
  }
@@ -1626,7 +1654,11 @@ export class AgentManager {
1626
1654
  continue;
1627
1655
  }
1628
1656
  for (const task of agent.session.listBackgroundTasks()) {
1629
- rows.push({ agentId: agent.id, agentTitle: agent.config.title ?? null, task });
1657
+ rows.push({
1658
+ agentId: agent.id,
1659
+ agentTitle: agent.config.title ?? null,
1660
+ task,
1661
+ });
1630
1662
  }
1631
1663
  }
1632
1664
  return rows;
@@ -1767,7 +1799,10 @@ export class AgentManager {
1767
1799
  this.logger.info({ agentId, provider: agent.provider, messageId, mode }, "agent.rewind.start");
1768
1800
  await invokeRewindCapability(agent.session, { messageId, mode });
1769
1801
  if (mode !== "files") {
1770
- await this.hydrateTimelineFromProvider(agentId, { force: true, broadcast: true });
1802
+ await this.hydrateTimelineFromProvider(agentId, {
1803
+ force: true,
1804
+ broadcast: true,
1805
+ });
1771
1806
  }
1772
1807
  await this.refreshRuntimeInfo(agent);
1773
1808
  await this.persistSnapshot(agent);
@@ -2013,14 +2048,18 @@ export class AgentManager {
2013
2048
  this.agents.set(resolvedAgentId, managed);
2014
2049
  // Initialize previousStatus to track transitions
2015
2050
  this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
2016
- await this.refreshRuntimeInfo(managed, { emit: !options?.publishWhenReady });
2051
+ await this.refreshRuntimeInfo(managed, {
2052
+ emit: !options?.publishWhenReady,
2053
+ });
2017
2054
  await this.persistSnapshot(managed, {
2018
2055
  title: initialPersistedTitle,
2019
2056
  });
2020
2057
  if (!options?.publishWhenReady) {
2021
2058
  this.emitState(managed, { persist: false });
2022
2059
  }
2023
- await this.refreshSessionState(managed, { emit: !options?.publishWhenReady });
2060
+ await this.refreshSessionState(managed, {
2061
+ emit: !options?.publishWhenReady,
2062
+ });
2024
2063
  managed.lifecycle = "idle";
2025
2064
  await this.persistSnapshot(managed);
2026
2065
  this.emitState(managed, { persist: false });
@@ -2280,7 +2319,9 @@ export class AgentManager {
2280
2319
  this.agentStreamCoalescer.flushAndDiscard(agent.id);
2281
2320
  await this.deleteCommittedTimeline(agent.id);
2282
2321
  this.timelineStore.delete(agent.id);
2283
- this.timelineStore.initialize(agent.id, { timestamp: new Date().toISOString() });
2322
+ this.timelineStore.initialize(agent.id, {
2323
+ timestamp: new Date().toISOString(),
2324
+ });
2284
2325
  agent.historyPrimed = true;
2285
2326
  for (const event of historyEvents) {
2286
2327
  const row = this.recordTimeline(agent.id, event.item, event.timestamp ? { timestamp: event.timestamp } : undefined);
@@ -2348,7 +2389,10 @@ export class AgentManager {
2348
2389
  }
2349
2390
  this.agentStreamCoalescer.flushFor(agent.id);
2350
2391
  }
2351
- const flags = { shouldDispatchEvent: true, shouldNotifyWaiters: true };
2392
+ const flags = {
2393
+ shouldDispatchEvent: true,
2394
+ shouldNotifyWaiters: true,
2395
+ };
2352
2396
  const dispatchPromise = this.dispatchStreamEventByType({
2353
2397
  agent,
2354
2398
  event,
@@ -2364,7 +2408,9 @@ export class AgentManager {
2364
2408
  this.finalizeForegroundTurn(agent, eventTurnId);
2365
2409
  }
2366
2410
  if (!options?.fromHistory && flags.shouldDispatchEvent) {
2367
- this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
2411
+ this.dispatchStream(agent.id, event, {
2412
+ timestamp: new Date().toISOString(),
2413
+ });
2368
2414
  }
2369
2415
  this.traceHandleStreamEventEnd(agent, event, eventTurnId, flags);
2370
2416
  return flags.shouldNotifyWaiters;
@@ -2417,7 +2463,10 @@ export class AgentManager {
2417
2463
  agent.currentModeId = event.currentModeId;
2418
2464
  agent.availableModes = event.availableModes;
2419
2465
  if (agent.runtimeInfo) {
2420
- agent.runtimeInfo = { ...agent.runtimeInfo, modeId: event.currentModeId };
2466
+ agent.runtimeInfo = {
2467
+ ...agent.runtimeInfo,
2468
+ modeId: event.currentModeId,
2469
+ };
2421
2470
  }
2422
2471
  flags.shouldDispatchEvent = false;
2423
2472
  this.emitState(agent);
@@ -2425,7 +2474,10 @@ export class AgentManager {
2425
2474
  case "model_changed":
2426
2475
  agent.runtimeInfo = event.runtimeInfo;
2427
2476
  if (!agent.persistence && event.runtimeInfo.sessionId) {
2428
- agent.persistence = attachPersistenceCwd({ provider: agent.provider, sessionId: event.runtimeInfo.sessionId }, agent.cwd);
2477
+ agent.persistence = attachPersistenceCwd({
2478
+ provider: agent.provider,
2479
+ sessionId: event.runtimeInfo.sessionId,
2480
+ }, agent.cwd);
2429
2481
  }
2430
2482
  agent.currentModeId = event.runtimeInfo.modeId ?? agent.currentModeId;
2431
2483
  flags.shouldDispatchEvent = false;
@@ -2442,9 +2494,20 @@ export class AgentManager {
2442
2494
  this.emitState(agent);
2443
2495
  return undefined;
2444
2496
  case "timeline":
2445
- return this.onStreamTimelineEvent({ agent, event, options, isForegroundEvent, flags });
2497
+ return this.onStreamTimelineEvent({
2498
+ agent,
2499
+ event,
2500
+ options,
2501
+ isForegroundEvent,
2502
+ flags,
2503
+ });
2446
2504
  case "turn_completed":
2447
- this.onStreamTurnCompleted({ agent, event, eventTurnId, isForegroundEvent });
2505
+ this.onStreamTurnCompleted({
2506
+ agent,
2507
+ event,
2508
+ eventTurnId,
2509
+ isForegroundEvent,
2510
+ });
2448
2511
  return undefined;
2449
2512
  case "turn_failed":
2450
2513
  return this.onStreamTurnFailed({
@@ -2455,7 +2518,13 @@ export class AgentManager {
2455
2518
  options,
2456
2519
  });
2457
2520
  case "turn_canceled":
2458
- this.onStreamTurnCanceled({ agent, event, eventTurnId, isForegroundEvent, options });
2521
+ this.onStreamTurnCanceled({
2522
+ agent,
2523
+ event,
2524
+ eventTurnId,
2525
+ isForegroundEvent,
2526
+ options,
2527
+ });
2459
2528
  return undefined;
2460
2529
  case "turn_started":
2461
2530
  this.onStreamTurnStarted({ agent, eventTurnId, isForegroundEvent });
@@ -2704,6 +2773,18 @@ export class AgentManager {
2704
2773
  agent.features = agent.session.features;
2705
2774
  }
2706
2775
  }
2776
+ /**
2777
+ * Live background work owned by this agent: monitors, crons, and backgrounded
2778
+ * shells the provider is still tracking. Mirrors what `agent-projections`
2779
+ * publishes as `activeBackgroundTaskCount`, read through the same optional
2780
+ * hook so providers that do not implement it simply report zero.
2781
+ */
2782
+ getActiveBackgroundTaskCount(agent) {
2783
+ if (!("session" in agent)) {
2784
+ return 0;
2785
+ }
2786
+ return agent.session?.getActiveBackgroundTaskCount?.() ?? 0;
2787
+ }
2707
2788
  checkAndSetAttention(agent) {
2708
2789
  const previousStatus = this.previousStatuses.get(agent.id);
2709
2790
  const currentStatus = agent.lifecycle;
@@ -2719,6 +2800,40 @@ export class AgentManager {
2719
2800
  }
2720
2801
  // Check if agent transitioned from running to idle (finished)
2721
2802
  if (previousStatus === "running" && currentStatus === "idle") {
2803
+ // ...but "the lifecycle went running -> idle" is NOT the same fact as
2804
+ // "this agent is finished and wants you".
2805
+ //
2806
+ // A monitor tick, a cron firing, or a background shell reporting progress
2807
+ // all WAKE the session: status goes idle -> running, the agent prints a
2808
+ // progress line ("1/12 done, I'll let you know"), the turn ends, and the
2809
+ // lifecycle settles back to idle. Under the old rule that transition set
2810
+ // requiresAttention="finished" on EVERY tick, which lit the Unread chip,
2811
+ // the dock badge and a push notification for output nobody needs to act
2812
+ // on. With several sessions in parallel that made Unread — the one chip
2813
+ // you actually watch — useless.
2814
+ //
2815
+ // So: if the agent still owns armed background work, the turn ending is a
2816
+ // progress beat, not a handoff. Stay quiet and let the bucket derivation
2817
+ // surface it as "pending" (alive, will come back on its own) instead.
2818
+ //
2819
+ // KNOWN GAP, deliberately named rather than silently accepted: a turn that
2820
+ // ends with the agent asking a real question in PROSE (not a permission
2821
+ // request, which still routes through needs_input and is unaffected) is
2822
+ // also suppressed while background work is armed. Prose questions are not
2823
+ // machine-detectable here. The counter below exists so that gap is
2824
+ // MEASURED rather than assumed — feed it to the Jarvis judge in shadow
2825
+ // mode (PASEO_JARVIS_SHADOW=1) to size the real distribution before
2826
+ // deciding whether it needs rescuing.
2827
+ const activeBackgroundTaskCount = this.getActiveBackgroundTaskCount(agent);
2828
+ if (activeBackgroundTaskCount > 0) {
2829
+ agent.suppressedAttentionCount = (agent.suppressedAttentionCount ?? 0) + 1;
2830
+ this.logger.debug({
2831
+ agentId: agent.id,
2832
+ activeBackgroundTaskCount,
2833
+ suppressedAttentionCount: agent.suppressedAttentionCount,
2834
+ }, "Suppressed finished-attention: agent still owns armed background work");
2835
+ return;
2836
+ }
2722
2837
  agent.attention = {
2723
2838
  requiresAttention: true,
2724
2839
  attentionReason: "finished",
@@ -2938,7 +3053,9 @@ export class AgentManager {
2938
3053
  if (this.paseoToolsEnabled &&
2939
3054
  client.capabilities.supportsNativePaseoTools &&
2940
3055
  this.paseoToolCatalogFactory) {
2941
- context.paseoTools = await this.paseoToolCatalogFactory({ callerAgentId: agentId });
3056
+ context.paseoTools = await this.paseoToolCatalogFactory({
3057
+ callerAgentId: agentId,
3058
+ });
2942
3059
  }
2943
3060
  return context;
2944
3061
  }
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { WorkflowNotFoundError } from "../workflow/workflow-manager.js";
4
- import { WorkflowSnapshotSchema, WorkflowStatusSchema, WorkflowTaskGraphSchema, SessionDigestSchema, } from "../messages.js";
4
+ import { WorkflowAgentPresetSchema, WorkflowSnapshotSchema, WorkflowStatusSchema, WorkflowTaskGraphSchema, SessionDigestSchema, } from "../messages.js";
5
5
  import { AgentStatusEnum } from "./mcp-shared.js";
6
6
  import { expandUserPath } from "../path-utils.js";
7
7
  import { ensureValidJson } from "../json-utils.js";
@@ -28,6 +28,10 @@ const workflowStartArgsSchema = z.object({
28
28
  .record(z.string(), z.string())
29
29
  .optional()
30
30
  .describe("Optional labels copied onto every spawned child alongside the workflow id"),
31
+ agentPresets: z
32
+ .record(z.string(), WorkflowAgentPresetSchema)
33
+ .optional()
34
+ .describe("Optional map of agent name to partial session config; a task's agentType selects one"),
31
35
  });
32
36
  const workflowIdArgsSchema = z.object({
33
37
  workflowId: z.string().describe("Workflow id returned by workflow_start"),
@@ -238,6 +242,7 @@ export async function createAgentMcpServer(options) {
238
242
  baseConfig,
239
243
  ...(args.title ? { title: args.title } : {}),
240
244
  ...(args.labels ? { labels: args.labels } : {}),
245
+ ...(args.agentPresets ? { agentPresets: args.agentPresets } : {}),
241
246
  ...(callerAgentId ? { parentAgentId: callerAgentId } : {}),
242
247
  });
243
248
  const started = await workflowManager.startWorkflow(workflow.id);
@@ -27,9 +27,25 @@
27
27
  * output file's real birth time, reading the output back) is the caller's job.
28
28
  */
29
29
  export type ClaudeBackgroundTaskStatus = "running" | "completed" | "failed" | "canceled";
30
+ /**
31
+ * What kind of background work a record represents. Surfaced so the UI can say
32
+ * WHAT is holding a session open ("pending · monitoring deploy.log") instead of
33
+ * an unexplained spinner, which is how users end up force-quitting sessions.
34
+ *
35
+ * ⚠️ Adding a member here is not enough to make it tracked — it must also get a
36
+ * start pattern above AND a retirement path, and be listed in the exhaustiveness
37
+ * test. See `background-work-kinds.ts`.
38
+ */
39
+ export type ClaudeBackgroundTaskKind = "shell" | "monitor" | "cron";
40
+ export interface BackgroundTaskStart {
41
+ id: string;
42
+ kind: ClaudeBackgroundTaskKind;
43
+ }
30
44
  export interface ClaudeBackgroundTaskRecord {
31
45
  /** Harness-assigned shell id, e.g. `bjuk0pif4`. Stable across start and end. */
32
46
  id: string;
47
+ /** Which flavour of background work this is. Defaults to "shell" (legacy). */
48
+ kind: ClaudeBackgroundTaskKind;
33
49
  /** The shell command, from the originating `tool_use` input. Null if unseen. */
34
50
  command: string | null;
35
51
  /** The tool call's human description ("Run the test suite"), if it carried one. */
@@ -68,8 +84,29 @@ export declare function isTerminalTaskStatus(status: string | null | undefined):
68
84
  * terminal that isn't recognisably a failure or a cancel is a plain completion.
69
85
  */
70
86
  export declare function toTerminalRecordStatus(status: string | null | undefined): Exclude<ClaudeBackgroundTaskStatus, "running">;
71
- /** Extract every background-task id announced in a Bash tool_result's text. */
87
+ /**
88
+ * Extract every background-task id announced in a Bash tool_result's text.
89
+ *
90
+ * Bash shells only — kept at this name and signature because it is part of the
91
+ * module's public surface. For all tracked kinds use
92
+ * {@link extractBackgroundTaskStarts}.
93
+ */
72
94
  export declare function extractBackgroundTaskIds(text: string | null | undefined): string[];
95
+ /** Ids of monitors started in this text. */
96
+ export declare function extractMonitorTaskIds(text: string | null | undefined): string[];
97
+ /** Ids of cron jobs scheduled in this text. */
98
+ export declare function extractCronTaskIds(text: string | null | undefined): string[];
99
+ /** Ids of cron jobs cancelled in this text (a cron's only retirement signal). */
100
+ export declare function extractCancelledCronTaskIds(text: string | null | undefined): string[];
101
+ /**
102
+ * Every background-work start announced in one tool_result, tagged by kind.
103
+ *
104
+ * This is the single place that decides "does this text start something that
105
+ * keeps the agent alive?". A new kind of background work is invisible until it
106
+ * is added HERE — which is precisely how monitors and crons went untracked
107
+ * while `deriveAgentStateBucket` happily reported those sessions as done.
108
+ */
109
+ export declare function extractBackgroundTaskStarts(text: string | null | undefined): BackgroundTaskStart[];
73
110
  /** Extract the output-file path the harness announced alongside the id, if any. */
74
111
  export declare function extractBackgroundOutputFile(text: string | null | undefined): string | null;
75
112
  export declare class ClaudeBackgroundTaskTracker {
@@ -47,6 +47,45 @@ const BACKGROUND_ID_PATTERN = /Command running in background with ID:\s*([A-Za-z
47
47
  * path from the id (the directory is session-scoped and not derivable here).
48
48
  */
49
49
  const OUTPUT_FILE_PATTERN = /Output is being written to:\s*(\S+)/;
50
+ /**
51
+ * Matches the harness line for a started **Monitor** (the long-running watcher
52
+ * tool), which announces itself with a completely different sentence from a
53
+ * backgrounded Bash:
54
+ *
55
+ * Monitor started (task b6dxcqe9y, timeout 20000ms). You will be notified on
56
+ * each event. Keep working — do not poll or sleep.
57
+ *
58
+ * The id is followed by a comma rather than a period, so it needs its own
59
+ * capture. Crucially the monitor's END is already handled: it retires through
60
+ * the SAME `<task-notification>` envelope carrying the same `<task-id>` and a
61
+ * `<status>completed</status>`. Only the start was invisible, which is why a
62
+ * session holding a live monitor reported zero background tasks and settled to
63
+ * "done" while the monitor was still watching.
64
+ *
65
+ * Captured verbatim from a live session on 2026-08-22, not transcribed.
66
+ */
67
+ const MONITOR_ID_PATTERN = /Monitor started \(task\s+([A-Za-z0-9._-]+)/g;
68
+ /**
69
+ * Matches a scheduled **cron** job's announcement. Two shapes, one per mode:
70
+ *
71
+ * Scheduled one-shot task dcdae8f2 (17 4 1 1 *). Session-only ...
72
+ * Scheduled recurring job b8df03d3 (Every Wednesday at 4:23 AM). Session-only ...
73
+ *
74
+ * Note the noun changes with the mode ("task" vs "job"), so both are accepted.
75
+ * Captured verbatim from a live session on 2026-08-22.
76
+ */
77
+ const CRON_ID_PATTERN = /Scheduled\s+(?:one-shot|recurring)\s+(?:task|job)\s+([A-Za-z0-9._-]+)/g;
78
+ /**
79
+ * Matches a cancelled cron job. A cron has no `<task-notification>`, so this
80
+ * tool_result line is its ONLY retirement signal:
81
+ *
82
+ * Cancelled job b8df03d3.
83
+ *
84
+ * The trailing period is glued to the id — the exact defect class documented in
85
+ * {@link stripTrailingSentencePunctuation}. It is stripped, or the id would
86
+ * never match the record and the agent would sit in "pending" forever.
87
+ */
88
+ const CRON_CANCEL_PATTERN = /Cancelled job\s+([A-Za-z0-9._-]+)/g;
50
89
  /**
51
90
  * Trim the sentence punctuation the harness's prose leaves glued to a capture.
52
91
  *
@@ -109,24 +148,66 @@ export function toTerminalRecordStatus(status) {
109
148
  }
110
149
  return "completed";
111
150
  }
112
- /** Extract every background-task id announced in a Bash tool_result's text. */
113
- export function extractBackgroundTaskIds(text) {
151
+ /**
152
+ * Run one module-scoped global pattern over `text` and return every stripped
153
+ * capture. Shared by all start/cancel scanners so the two easy-to-forget
154
+ * details — resetting `lastIndex` on a module-scoped global regex, and
155
+ * stripping the sentence punctuation the harness's prose glues to a capture —
156
+ * are written once instead of per pattern.
157
+ */
158
+ function scanIds(pattern, text) {
114
159
  if (typeof text !== "string" || text.length === 0) {
115
160
  return [];
116
161
  }
117
162
  const ids = [];
118
- // Reset lastIndex defensively — the regex is module-scoped and global.
119
- BACKGROUND_ID_PATTERN.lastIndex = 0;
120
- let match = BACKGROUND_ID_PATTERN.exec(text);
163
+ pattern.lastIndex = 0;
164
+ let match = pattern.exec(text);
121
165
  while (match !== null) {
122
166
  const id = match[1] ? stripTrailingSentencePunctuation(match[1]) : "";
123
167
  if (id) {
124
168
  ids.push(id);
125
169
  }
126
- match = BACKGROUND_ID_PATTERN.exec(text);
170
+ match = pattern.exec(text);
127
171
  }
128
172
  return ids;
129
173
  }
174
+ /**
175
+ * Extract every background-task id announced in a Bash tool_result's text.
176
+ *
177
+ * Bash shells only — kept at this name and signature because it is part of the
178
+ * module's public surface. For all tracked kinds use
179
+ * {@link extractBackgroundTaskStarts}.
180
+ */
181
+ export function extractBackgroundTaskIds(text) {
182
+ return scanIds(BACKGROUND_ID_PATTERN, text);
183
+ }
184
+ /** Ids of monitors started in this text. */
185
+ export function extractMonitorTaskIds(text) {
186
+ return scanIds(MONITOR_ID_PATTERN, text);
187
+ }
188
+ /** Ids of cron jobs scheduled in this text. */
189
+ export function extractCronTaskIds(text) {
190
+ return scanIds(CRON_ID_PATTERN, text);
191
+ }
192
+ /** Ids of cron jobs cancelled in this text (a cron's only retirement signal). */
193
+ export function extractCancelledCronTaskIds(text) {
194
+ return scanIds(CRON_CANCEL_PATTERN, text);
195
+ }
196
+ /**
197
+ * Every background-work start announced in one tool_result, tagged by kind.
198
+ *
199
+ * This is the single place that decides "does this text start something that
200
+ * keeps the agent alive?". A new kind of background work is invisible until it
201
+ * is added HERE — which is precisely how monitors and crons went untracked
202
+ * while `deriveAgentStateBucket` happily reported those sessions as done.
203
+ */
204
+ export function extractBackgroundTaskStarts(text) {
205
+ return [
206
+ ...extractBackgroundTaskIds(text).map((id) => ({ id, kind: "shell" })),
207
+ ...extractMonitorTaskIds(text).map((id) => ({ id, kind: "monitor" })),
208
+ ...extractCronTaskIds(text).map((id) => ({ id, kind: "cron" })),
209
+ ];
210
+ }
130
211
  /** Extract the output-file path the harness announced alongside the id, if any. */
131
212
  export function extractBackgroundOutputFile(text) {
132
213
  if (typeof text !== "string" || text.length === 0) {
@@ -158,15 +239,19 @@ export class ClaudeBackgroundTaskTracker {
158
239
  const outputFile = extractBackgroundOutputFile(normalized.text);
159
240
  const startedAt = normalized.startedAt ?? new Date().toISOString();
160
241
  const added = [];
161
- for (const id of extractBackgroundTaskIds(normalized.text)) {
242
+ for (const { id, kind } of extractBackgroundTaskStarts(normalized.text)) {
162
243
  if (this.running.has(id)) {
163
244
  continue;
164
245
  }
165
246
  this.running.set(id, {
166
247
  id,
248
+ kind,
167
249
  command: normalized.command ?? null,
168
250
  description: normalized.description ?? null,
169
- outputFile,
251
+ // Only a backgrounded shell announces an output file on START. A
252
+ // monitor announces its own on its terminal notification instead, and a
253
+ // cron never has one.
254
+ outputFile: kind === "shell" ? outputFile : null,
170
255
  startedAt,
171
256
  endedAt: null,
172
257
  status: "running",
@@ -174,6 +259,22 @@ export class ClaudeBackgroundTaskTracker {
174
259
  });
175
260
  added.push(id);
176
261
  }
262
+ // A cron has no `<task-notification>`, so its cancellation arrives as
263
+ // another tool_result in this same stream. Retire it here or it stays live
264
+ // forever and pins the agent to "pending" — the "never retires" failure
265
+ // this module already documents for a mis-captured shell id.
266
+ for (const id of extractCancelledCronTaskIds(normalized.text)) {
267
+ const record = this.running.get(id);
268
+ if (!record || record.kind !== "cron") {
269
+ continue;
270
+ }
271
+ this.running.delete(id);
272
+ this.retire({
273
+ ...record,
274
+ status: "canceled",
275
+ endedAt: new Date().toISOString(),
276
+ });
277
+ }
177
278
  return added;
178
279
  }
179
280
  /**
@@ -211,7 +312,11 @@ export class ClaudeBackgroundTaskTracker {
211
312
  return false;
212
313
  }
213
314
  this.running.delete(taskId);
214
- this.retire({ ...record, status: "canceled", endedAt: new Date().toISOString() });
315
+ this.retire({
316
+ ...record,
317
+ status: "canceled",
318
+ endedAt: new Date().toISOString(),
319
+ });
215
320
  return true;
216
321
  }
217
322
  has(taskId) {