charness 3.5.0 → 3.6.1

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 (154) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/DEVELOPMENT.md +12 -1
  3. package/README.md +16 -1
  4. package/SETUP.md +199 -1
  5. package/dist/adapters/adapterEventRouting.d.ts +2 -2
  6. package/dist/adapters/adapterEventRouting.d.ts.map +1 -1
  7. package/dist/adapters/adapterEventRouting.js +4 -3
  8. package/dist/adapters/adapterEventRouting.js.map +1 -1
  9. package/dist/adapters/claudeJsonStreamAdapter.d.ts +74 -5
  10. package/dist/adapters/claudeJsonStreamAdapter.d.ts.map +1 -1
  11. package/dist/adapters/claudeJsonStreamAdapter.js +188 -9
  12. package/dist/adapters/claudeJsonStreamAdapter.js.map +1 -1
  13. package/dist/adapters/createAdapter.d.ts +12 -0
  14. package/dist/adapters/createAdapter.d.ts.map +1 -1
  15. package/dist/adapters/createAdapter.js +38 -2
  16. package/dist/adapters/createAdapter.js.map +1 -1
  17. package/dist/adapters/openCodeAdapter.d.ts +5 -0
  18. package/dist/adapters/openCodeAdapter.d.ts.map +1 -1
  19. package/dist/adapters/openCodeAdapter.js +7 -0
  20. package/dist/adapters/openCodeAdapter.js.map +1 -1
  21. package/dist/bot.d.ts.map +1 -1
  22. package/dist/bot.js +299 -82
  23. package/dist/bot.js.map +1 -1
  24. package/dist/connectors/jira/connector.d.ts +2 -0
  25. package/dist/connectors/jira/connector.d.ts.map +1 -1
  26. package/dist/connectors/jira/connector.js +11 -5
  27. package/dist/connectors/jira/connector.js.map +1 -1
  28. package/dist/connectors/jira/inbound.d.ts +2 -0
  29. package/dist/connectors/jira/inbound.d.ts.map +1 -1
  30. package/dist/connectors/jira/inbound.js +2 -0
  31. package/dist/connectors/jira/inbound.js.map +1 -1
  32. package/dist/connectors/jira/prompt.d.ts +4 -0
  33. package/dist/connectors/jira/prompt.d.ts.map +1 -1
  34. package/dist/connectors/jira/prompt.js +11 -2
  35. package/dist/connectors/jira/prompt.js.map +1 -1
  36. package/dist/connectors/telegram/commands/compaction.d.ts.map +1 -1
  37. package/dist/connectors/telegram/commands/compaction.js +39 -8
  38. package/dist/connectors/telegram/commands/compaction.js.map +1 -1
  39. package/dist/connectors/telegram/forumTopicCreated.d.ts +20 -0
  40. package/dist/connectors/telegram/forumTopicCreated.d.ts.map +1 -0
  41. package/dist/connectors/telegram/forumTopicCreated.js +16 -0
  42. package/dist/connectors/telegram/forumTopicCreated.js.map +1 -0
  43. package/dist/i18n/de.d.ts.map +1 -1
  44. package/dist/i18n/de.js +14 -0
  45. package/dist/i18n/de.js.map +1 -1
  46. package/dist/i18n/en.d.ts.map +1 -1
  47. package/dist/i18n/en.js +14 -0
  48. package/dist/i18n/en.js.map +1 -1
  49. package/dist/i18n/es.d.ts.map +1 -1
  50. package/dist/i18n/es.js +14 -0
  51. package/dist/i18n/es.js.map +1 -1
  52. package/dist/i18n/fr.d.ts.map +1 -1
  53. package/dist/i18n/fr.js +14 -0
  54. package/dist/i18n/fr.js.map +1 -1
  55. package/dist/i18n/hi.d.ts.map +1 -1
  56. package/dist/i18n/hi.js +14 -0
  57. package/dist/i18n/hi.js.map +1 -1
  58. package/dist/i18n/ja.d.ts.map +1 -1
  59. package/dist/i18n/ja.js +14 -0
  60. package/dist/i18n/ja.js.map +1 -1
  61. package/dist/i18n/ka.d.ts.map +1 -1
  62. package/dist/i18n/ka.js +14 -0
  63. package/dist/i18n/ka.js.map +1 -1
  64. package/dist/i18n/pt.d.ts.map +1 -1
  65. package/dist/i18n/pt.js +14 -0
  66. package/dist/i18n/pt.js.map +1 -1
  67. package/dist/i18n/ru.d.ts.map +1 -1
  68. package/dist/i18n/ru.js +14 -0
  69. package/dist/i18n/ru.js.map +1 -1
  70. package/dist/i18n/uk.d.ts.map +1 -1
  71. package/dist/i18n/uk.js +14 -0
  72. package/dist/i18n/uk.js.map +1 -1
  73. package/dist/i18n/uz.d.ts.map +1 -1
  74. package/dist/i18n/uz.js +14 -0
  75. package/dist/i18n/uz.js.map +1 -1
  76. package/dist/i18n/zh.d.ts.map +1 -1
  77. package/dist/i18n/zh.js +14 -0
  78. package/dist/i18n/zh.js.map +1 -1
  79. package/dist/i18n.d.ts +2 -0
  80. package/dist/i18n.d.ts.map +1 -1
  81. package/dist/i18n.js.map +1 -1
  82. package/dist/rateLimiter.d.ts +6 -0
  83. package/dist/rateLimiter.d.ts.map +1 -1
  84. package/dist/rateLimiter.js +9 -0
  85. package/dist/rateLimiter.js.map +1 -1
  86. package/dist/requests/wakeUpEngine.d.ts +4 -0
  87. package/dist/requests/wakeUpEngine.d.ts.map +1 -1
  88. package/dist/requests/wakeUpEngine.js +3 -3
  89. package/dist/requests/wakeUpEngine.js.map +1 -1
  90. package/dist/requests/wakeUpRules.d.ts +2 -1
  91. package/dist/requests/wakeUpRules.d.ts.map +1 -1
  92. package/dist/requests/wakeUpRules.js +3 -2
  93. package/dist/requests/wakeUpRules.js.map +1 -1
  94. package/dist/scheduler/mcpSurface.js +1 -1
  95. package/dist/selfUpdate/updateMessages.d.ts +1 -1
  96. package/dist/selfUpdate/updateMessages.d.ts.map +1 -1
  97. package/dist/selfUpdate/updateMessages.js.map +1 -1
  98. package/dist/state.d.ts +36 -5
  99. package/dist/state.d.ts.map +1 -1
  100. package/dist/state.js +40 -7
  101. package/dist/state.js.map +1 -1
  102. package/dist/testTimings.d.ts +45 -0
  103. package/dist/testTimings.d.ts.map +1 -0
  104. package/dist/testTimings.js +89 -0
  105. package/dist/testTimings.js.map +1 -0
  106. package/dist/types.d.ts +43 -0
  107. package/dist/types.d.ts.map +1 -1
  108. package/dist/utils/adapterSwitchSessionIds.d.ts +5 -1
  109. package/dist/utils/adapterSwitchSessionIds.d.ts.map +1 -1
  110. package/dist/utils/adapterSwitchSessionIds.js +1 -1
  111. package/dist/utils/adapterSwitchSessionIds.js.map +1 -1
  112. package/dist/utils/adoptedToolList.d.ts +16 -21
  113. package/dist/utils/adoptedToolList.d.ts.map +1 -1
  114. package/dist/utils/adoptedToolList.js +18 -17
  115. package/dist/utils/adoptedToolList.js.map +1 -1
  116. package/dist/utils/claudeScheduledJobs.d.ts +49 -0
  117. package/dist/utils/claudeScheduledJobs.d.ts.map +1 -0
  118. package/dist/utils/claudeScheduledJobs.js +62 -0
  119. package/dist/utils/claudeScheduledJobs.js.map +1 -0
  120. package/dist/utils/claudeStreamJson.d.ts +31 -1
  121. package/dist/utils/claudeStreamJson.d.ts.map +1 -1
  122. package/dist/utils/claudeStreamJson.js +25 -8
  123. package/dist/utils/claudeStreamJson.js.map +1 -1
  124. package/dist/utils/compactOnIdle.d.ts +12 -0
  125. package/dist/utils/compactOnIdle.d.ts.map +1 -1
  126. package/dist/utils/compactOnIdle.js +15 -1
  127. package/dist/utils/compactOnIdle.js.map +1 -1
  128. package/dist/utils/doctorLine.d.ts +13 -0
  129. package/dist/utils/doctorLine.d.ts.map +1 -0
  130. package/dist/utils/doctorLine.js +18 -0
  131. package/dist/utils/doctorLine.js.map +1 -0
  132. package/dist/utils/effortPickerPlan.d.ts +1 -2
  133. package/dist/utils/effortPickerPlan.d.ts.map +1 -1
  134. package/dist/utils/effortPickerPlan.js.map +1 -1
  135. package/dist/utils/jsonStreamHost.d.ts +19 -7
  136. package/dist/utils/jsonStreamHost.d.ts.map +1 -1
  137. package/dist/utils/jsonStreamHost.js +25 -11
  138. package/dist/utils/jsonStreamHost.js.map +1 -1
  139. package/dist/utils/jsonStreamPendingWork.d.ts +44 -0
  140. package/dist/utils/jsonStreamPendingWork.d.ts.map +1 -0
  141. package/dist/utils/jsonStreamPendingWork.js +48 -0
  142. package/dist/utils/jsonStreamPendingWork.js.map +1 -0
  143. package/dist/utils/lostWork.d.ts +117 -0
  144. package/dist/utils/lostWork.d.ts.map +1 -0
  145. package/dist/utils/lostWork.js +187 -0
  146. package/dist/utils/lostWork.js.map +1 -0
  147. package/dist/utils/nativeWindows.d.ts +29 -0
  148. package/dist/utils/nativeWindows.d.ts.map +1 -0
  149. package/dist/utils/nativeWindows.js +50 -0
  150. package/dist/utils/nativeWindows.js.map +1 -0
  151. package/dist/utils/updateScreenPlan.d.ts +1 -1
  152. package/dist/utils/updateScreenPlan.d.ts.map +1 -1
  153. package/dist/utils/updateScreenPlan.js.map +1 -1
  154. package/package.json +2 -2
@@ -63,6 +63,9 @@ const claudeProfiles_1 = require("../utils/claudeProfiles");
63
63
  const adapterNames_1 = require("./adapterNames");
64
64
  const claudeCodeVersion_1 = require("../utils/claudeCodeVersion");
65
65
  const sessionLaunchDefaults_1 = require("./sessionLaunchDefaults");
66
+ const jsonStreamPendingWork_1 = require("../utils/jsonStreamPendingWork");
67
+ const lostWork_1 = require("../utils/lostWork");
68
+ const claudeScheduledJobs_1 = require("../utils/claudeScheduledJobs");
66
69
  /**
67
70
  * In-flight explicit stops, per key — a second stop (or a start's implicit stop)
68
71
  * AWAITS the first instead of racing it: the delayed first `tmux kill-session`
@@ -126,9 +129,12 @@ class ClaudeJsonStreamAdapter extends events_1.EventEmitter {
126
129
  * conversation at a time whichever lifecycle runs it.
127
130
  */
128
131
  lifecycle;
132
+ /** How long the agent's own pending work holds the process after its last turn (`utils/jsonStreamPendingWork.ts`). */
133
+ pendingWorkHoldMs;
129
134
  constructor(options = { lifecycle: 'idle' }) {
130
135
  super();
131
136
  this.lifecycle = options.lifecycle;
137
+ this.pendingWorkHoldMs = options.backgroundHoldMs ?? jsonStreamPendingWork_1.pendingWorkHoldDefaultMs;
132
138
  this.name = options.lifecycle === 'perTurn' ? adapterNames_1.claudePerTurnAdapterName : adapterNames_1.claudeJsonStreamAdapterName;
133
139
  this.label = options.lifecycle === 'perTurn' ? 'Claude Code (per-turn)' : 'Claude Code (stream)';
134
140
  }
@@ -146,6 +152,12 @@ class ClaudeJsonStreamAdapter extends events_1.EventEmitter {
146
152
  claudeConfigDirReader = null;
147
153
  /** The stops in flight — the module-level map shared by both lifecycles' instances (see {@link stopsInFlightByKey}). */
148
154
  stopsInFlight = stopsInFlightByKey;
155
+ /** The agent's own pending work each ended process took with it, until the bot takes it ({@link takeLostWork}). */
156
+ lostWorkByKey = new Map();
157
+ /** A test instance's shorter pending-work hold (`createAdapter.registerPendingWorkHoldMs`). */
158
+ setPendingWorkHoldMs(holdMs) {
159
+ this.pendingWorkHoldMs = holdMs;
160
+ }
149
161
  setDisplayPrefsReader(reader) {
150
162
  this.displayPrefsReader = reader;
151
163
  }
@@ -337,7 +349,8 @@ class ClaudeJsonStreamAdapter extends events_1.EventEmitter {
337
349
  compactionInProgress: false, pendingCompaction: null,
338
350
  pendingQuestion: null, apiErrorFired: false, rejectedResetAtMs: null, swallowNextAbortError: false,
339
351
  lastWatermarkOffset: -1,
340
- backgroundTaskIds: new Set(), claudeCodeVersion: null, applyingChunk: null,
352
+ backgroundTasks: new Map(), notificationTurnPendingSince: null, lastTurnEndedAt: Date.now(), isCompactionResultPending: false, scheduledJobs: new Map(),
353
+ claudeCodeVersion: null, applyingChunk: null,
341
354
  adoptCatchUpOffset: null, adoptCatchUpResolvers: [],
342
355
  };
343
356
  this.sessions.set((0, sessionKey_1.keyToString)(key), session);
@@ -518,6 +531,12 @@ class ClaudeJsonStreamAdapter extends events_1.EventEmitter {
518
531
  // Only act if this session is still the current one for the key.
519
532
  if (this.sessions.get((0, sessionKey_1.keyToString)(key)) !== session)
520
533
  return;
534
+ // An explicit stop (`/quit`, `/new`, a backend switch) releases the conversation and its record: nothing to tell
535
+ // its agent, nothing to write back — a write after the release would bring back a record the bot dropped.
536
+ if (!session.isStopping || session.isSuspending) {
537
+ this.recordLostWork(session);
538
+ this.clearPendingWorkOfEndedProcess(session);
539
+ }
521
540
  this.clearTimers(session);
522
541
  session.isActive = false;
523
542
  this.sessions.delete((0, sessionKey_1.keyToString)(key));
@@ -626,6 +645,7 @@ class ClaudeJsonStreamAdapter extends events_1.EventEmitter {
626
645
  const exitCode = (0, jsonStreamHost_1.readExitCodeFile)(paths.exitCodeFile);
627
646
  if (pid === null || exitCode !== null || !(0, jsonStreamHost_1.checkIsPidAlive)(pid)) {
628
647
  console.log(`[ClaudeJson] adopt: ${sessionName} process is dead (pid=${pid} exit=${exitCode}), cleaning up`);
648
+ this.storeLostWork(key, (0, lostWork_1.getLostWorkFromTailRecord)(persistedTail, claudeSessionId));
629
649
  await this.killOrphanTmuxSession(sessionName);
630
650
  return false;
631
651
  }
@@ -636,7 +656,8 @@ class ClaudeJsonStreamAdapter extends events_1.EventEmitter {
636
656
  return false;
637
657
  }
638
658
  const size = (0, jsonStreamHost_1.getFileSize)(paths.stdoutFile) ?? 0;
639
- const { startOffset, backgroundTaskIds, isTurnInFlight, outstandingToolUseIds, claudeCodeVersion } = (0, jsonStreamHost_1.resolveAdoptedTail)(persistedTail, claudeSessionId, size);
659
+ const adoptedTail = (0, jsonStreamHost_1.resolveAdoptedTail)(persistedTail, claudeSessionId, size);
660
+ const { startOffset, isTurnInFlight, outstandingToolUseIds, claudeCodeVersion } = adoptedTail;
640
661
  console.log(`[ClaudeJson] adopt: re-attaching to ${sessionName} in ${workDir} (pid=${pid}, tail=${startOffset}/${size})`);
641
662
  const launchFlags = (0, jsonStreamHost_1.readWrapperScriptLaunchFlags)(paths.wrapperFile);
642
663
  const session = {
@@ -659,7 +680,8 @@ class ClaudeJsonStreamAdapter extends events_1.EventEmitter {
659
680
  // the topic resolves to now, and a later re-spawn passes them on.
660
681
  model: launchFlags.model, reportedModel: null, effort: launchFlags.effort,
661
682
  // The account the surviving process runs on is in its own wrapper; the bot compares it with the topic's pick.
662
- configDir: (0, jsonStreamHost_1.readWrapperScriptConfigDir)(paths.wrapperFile), isConfigDirSwitchPending: false, startCostUsd: null,
683
+ // A switch the user picked while the process worked survives the reload: it still applies at the turn's end.
684
+ configDir: (0, jsonStreamHost_1.readWrapperScriptConfigDir)(paths.wrapperFile), isConfigDirSwitchPending: adoptedTail.isConfigDirSwitchPending, startCostUsd: null,
663
685
  currentResponseText: '', emittedLength: 0, outputTimer: null,
664
686
  reasoningText: '', reasoningStartedAt: null, reasoningTimer: null, reasoningActive: false,
665
687
  toolNamesById: new Map(), questionToolUseIds: new Set(),
@@ -670,8 +692,14 @@ class ClaudeJsonStreamAdapter extends events_1.EventEmitter {
670
692
  apiErrorFired: false, rejectedResetAtMs: null, swallowNextAbortError: false,
671
693
  lastWatermarkOffset: -1,
672
694
  // The frames that built the list and reported the version lie before the
673
- // tail offset (not replayed): both come from the persisted record.
674
- backgroundTaskIds: new Set(backgroundTaskIds), claudeCodeVersion, applyingChunk: null,
695
+ // tail offset (not replayed): both come from the persisted record, with the
696
+ // rest of the pending work (a turn announced, the hold's start, scheduled jobs).
697
+ backgroundTasks: new Map(adoptedTail.backgroundTasks.map((task) => [task.taskId, task.description])),
698
+ notificationTurnPendingSince: adoptedTail.notificationTurnPendingSinceMs,
699
+ lastTurnEndedAt: adoptedTail.lastTurnEndedAtMs ?? Date.now(),
700
+ isCompactionResultPending: false,
701
+ scheduledJobs: new Map(adoptedTail.scheduledJobs.map((job) => [job.jobId, job])),
702
+ claudeCodeVersion, applyingChunk: null,
675
703
  // Nothing to catch up on when the tail already sits at EOF.
676
704
  adoptCatchUpOffset: startOffset < size ? size : null, adoptCatchUpResolvers: [],
677
705
  };
@@ -710,6 +738,9 @@ class ClaudeJsonStreamAdapter extends events_1.EventEmitter {
710
738
  const resolvers = session.adoptCatchUpResolvers.splice(0);
711
739
  for (const resolve of resolvers)
712
740
  resolve();
741
+ // A switch the user picked before the reload, whose turn ended meanwhile, applies now that the state is current.
742
+ if (!isSessionGone)
743
+ this.applyPendingConfigDirSwitch(session);
713
744
  }
714
745
  /** Restore a pending question persisted by a previous bot life (see
715
746
  * {@link writeQuestionSidecar}). Parse-boundary narrowing; the questions
@@ -841,15 +872,105 @@ class ClaudeJsonStreamAdapter extends events_1.EventEmitter {
841
872
  return false;
842
873
  return session.isBusy || this.checkHasBackgroundWork(key);
843
874
  }
844
- /** Background tasks, input not yet taken in, a compaction in flight — "working" minus the turn (L-D2). */
875
+ /**
876
+ * The agent's own pending work (background tasks and scheduled jobs within the hold, a finished task's turn on its
877
+ * way), input not yet taken in, a compaction in flight — "working" minus the turn (L-D2).
878
+ */
845
879
  checkHasBackgroundWork(key) {
846
880
  const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
847
881
  if (!session?.isActive)
848
882
  return false;
849
- return session.backgroundTaskIds.size > 0
883
+ return (0, jsonStreamPendingWork_1.checkHasPendingWork)(this.getPendingWorkInput(session))
850
884
  || session.unconsumedInputCount > 0
851
885
  || session.compactionInProgress;
852
886
  }
887
+ /**
888
+ * @description The hold left for the session's background tasks / scheduled jobs (`utils/jsonStreamPendingWork.ts`):
889
+ * `null` without a live session or with nothing pending, `0` once spent. The idle fire re-checks no later than this.
890
+ */
891
+ getPendingWorkHoldRemainingMs(key) {
892
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
893
+ return session?.isActive ? (0, jsonStreamPendingWork_1.getPendingWorkHoldRemainingMs)(this.getPendingWorkInput(session)) : null;
894
+ }
895
+ /**
896
+ * @description Read what the process wrote since the last tail poll, so the working judgement is current: a stop
897
+ * decides on it, and the poll may lag the process by its backoff.
898
+ */
899
+ refreshWorkingState(key) {
900
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
901
+ if (!session?.isActive)
902
+ return;
903
+ try {
904
+ this.drainStdoutTail(session);
905
+ }
906
+ catch (e) {
907
+ console.warn(`[ClaudeJson] stdout tail read failed for ${(0, sessionKey_1.keyToString)(key)}:`, e instanceof Error ? e.message : e);
908
+ }
909
+ }
910
+ /**
911
+ * @description The agent's own pending work the conversation's LAST process took with it when it ended — a crash, a
912
+ * re-spawn, the idle stop after the pending-work hold, a process found gone at the adopt — handed over once (`null`
913
+ * when nothing was lost or it was already taken). The bot tells the agent (`utils/lostWork.ts`).
914
+ */
915
+ takeLostWork(key) {
916
+ const k = (0, sessionKey_1.keyToString)(key);
917
+ const lostWork = this.lostWorkByKey.get(k) ?? null;
918
+ this.lostWorkByKey.delete(k);
919
+ return lostWork;
920
+ }
921
+ /**
922
+ * @description What the LIVE process holds now that would die with it — read by the bot before it stops the
923
+ * process itself for a backend switch (an explicit stop records nothing); `null` when nothing is pending.
924
+ */
925
+ getPendingWork(key) {
926
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
927
+ if (!session?.isActive)
928
+ return null;
929
+ const pendingWork = this.describePendingWork(session, { isTurnCutOff: true });
930
+ return (0, lostWork_1.checkHasLostWork)(pendingWork) ? pendingWork : null;
931
+ }
932
+ /**
933
+ * Keep what a process that ended by itself (or at the idle stop) held. A turn a crash cut off is not counted: the
934
+ * request core re-posts and wakes an open request (R21); the idle stop never runs mid-turn.
935
+ */
936
+ recordLostWork(session) {
937
+ this.storeLostWork(session.key, this.describePendingWork(session, { isTurnCutOff: false }));
938
+ }
939
+ /** The pending work of a process: its background tasks, its scheduled jobs, a finished task's turn not yet run, and — when the bot itself cuts it off — the turn under way. */
940
+ describePendingWork(session, options) {
941
+ return {
942
+ backgroundTasks: [...session.backgroundTasks].map(([taskId, description]) => ({ taskId, description })),
943
+ scheduledJobCount: session.scheduledJobs.size,
944
+ hasUnreadTaskResult: (0, jsonStreamPendingWork_1.checkIsNotificationTurnPending)({ now: Date.now(), notificationTurnPendingSince: session.notificationTurnPendingSince }),
945
+ wasTurnInFlight: options.isTurnCutOff && session.isBusy && !session.compactionInProgress,
946
+ };
947
+ }
948
+ /**
949
+ * The ended process's pending work is handled here (kept for the bot, or released): the record must not carry it,
950
+ * or the next boot would read the conversation's process as one that died with work pending.
951
+ */
952
+ clearPendingWorkOfEndedProcess(session) {
953
+ session.backgroundTasks = new Map();
954
+ session.scheduledJobs = new Map();
955
+ session.notificationTurnPendingSince = null;
956
+ this.jsonStreamTailWriter?.(session.key, buildTailRecord(session, session.lastPersistedTailOffset));
957
+ }
958
+ storeLostWork(key, lostWork) {
959
+ if ((0, lostWork_1.checkHasLostWork)(lostWork))
960
+ this.lostWorkByKey.set((0, sessionKey_1.keyToString)(key), lostWork);
961
+ else
962
+ this.lostWorkByKey.delete((0, sessionKey_1.keyToString)(key));
963
+ }
964
+ getPendingWorkInput(session) {
965
+ return {
966
+ now: Date.now(),
967
+ backgroundTaskCount: session.backgroundTasks.size,
968
+ scheduledJobCount: session.scheduledJobs.size,
969
+ lastTurnEndedAt: session.lastTurnEndedAt,
970
+ holdMs: this.pendingWorkHoldMs,
971
+ notificationTurnPendingSince: session.notificationTurnPendingSince,
972
+ };
973
+ }
853
974
  /** The CLI version the live process reported on `system/init`, `null` until then (L-D10 gate input). */
854
975
  getClaudeCodeVersion(key) {
855
976
  return this.sessions.get((0, sessionKey_1.keyToString)(key))?.claudeCodeVersion ?? null;
@@ -934,6 +1055,7 @@ class ClaudeJsonStreamAdapter extends events_1.EventEmitter {
934
1055
  timer.unref?.();
935
1056
  session.pendingCompaction = { resolve, timer, sawSuccess: false };
936
1057
  session.compactionInProgress = true;
1058
+ session.isCompactionResultPending = true;
937
1059
  // Fresh turn: reset the answer cursor + idle-watchdog clock, mark busy.
938
1060
  session.currentResponseText = '';
939
1061
  session.emittedLength = 0;
@@ -1186,6 +1308,9 @@ class ClaudeJsonStreamAdapter extends events_1.EventEmitter {
1186
1308
  case 'backgroundTasks':
1187
1309
  this.handleBackgroundTasks(session, action);
1188
1310
  return;
1311
+ case 'taskNotification':
1312
+ this.handleTaskNotification(session, action);
1313
+ return;
1189
1314
  case 'apiRetry':
1190
1315
  this.maybeEmitApiError(session, action.text);
1191
1316
  return;
@@ -1205,10 +1330,20 @@ class ClaudeJsonStreamAdapter extends events_1.EventEmitter {
1205
1330
  // it `/status` and the pinned banner showed no model at all on a default
1206
1331
  // start / resume / adopt (nothing ever set the `--model` pick).
1207
1332
  this.handleInit(session, action);
1333
+ // `init` opens every turn: the turn runs from here, not from its first output.
1334
+ this.markTurnStarted(session);
1335
+ return;
1336
+ case 'turnStarted':
1337
+ this.markTurnStarted(session);
1338
+ if (action.wakeupSource !== null)
1339
+ session.scheduledJobs = (0, claudeScheduledJobs_1.getScheduledJobsAfterFiring)(session.scheduledJobs, action.wakeupSource);
1208
1340
  return;
1209
1341
  case 'backgroundTasks':
1210
1342
  this.handleBackgroundTasks(session, action);
1211
1343
  return;
1344
+ case 'taskNotification':
1345
+ this.handleTaskNotification(session, action);
1346
+ return;
1212
1347
  case 'textDelta':
1213
1348
  // Mid-turn activity marks the session busy: `sendInput` normally set it,
1214
1349
  // but an ADOPTED session (bot restart mid-turn) reconstructs the busy
@@ -1274,7 +1409,17 @@ class ClaudeJsonStreamAdapter extends events_1.EventEmitter {
1274
1409
  * WHOLE on every change, so no diffing). The tail-offset persist that follows
1275
1410
  * this frame's drain carries the new list to `state.json`. */
1276
1411
  handleBackgroundTasks(session, action) {
1277
- session.backgroundTaskIds = new Set(action.taskIds);
1412
+ session.backgroundTasks = new Map(action.tasks.map((task) => [task.taskId, task.description]));
1413
+ }
1414
+ /** A background task ended: its own turn is coming — the list empties right after this frame, the turn shows later. */
1415
+ handleTaskNotification(session, action) {
1416
+ if (session.backgroundTasks.has(action.taskId))
1417
+ session.notificationTurnPendingSince = Date.now();
1418
+ }
1419
+ /** A turn began (its `init`, the CLI's running state, a scheduled job firing): busy from here; an announced turn has shown. */
1420
+ markTurnStarted(session) {
1421
+ session.isBusy = true;
1422
+ session.notificationTurnPendingSince = null;
1278
1423
  }
1279
1424
  // — compaction (F3) —
1280
1425
  handleCompactStatus(session, action) {
@@ -1401,11 +1546,25 @@ class ClaudeJsonStreamAdapter extends events_1.EventEmitter {
1401
1546
  if (session.questionToolUseIds.has(action.toolUseId))
1402
1547
  return;
1403
1548
  const tool = session.toolNamesById.get(action.toolUseId) ?? 'tool';
1549
+ this.trackScheduledJob(session, tool, action.output);
1404
1550
  if (!action.output.trim())
1405
1551
  return;
1406
1552
  // Mode-agnostic emit — the bot applies the per-thread `/tool_results` pref.
1407
1553
  this.emit('toolResult', session.key, { tool, output: action.output });
1408
1554
  }
1555
+ /** A `CronCreate` / `CronDelete` / `ScheduleWakeup` result changes the session-only jobs the process holds. */
1556
+ trackScheduledJob(session, tool, output) {
1557
+ if (tool === claudeScheduledJobs_1.cronCreateToolName || tool === claudeScheduledJobs_1.loopWakeupToolName) {
1558
+ const job = tool === claudeScheduledJobs_1.cronCreateToolName ? (0, claudeScheduledJobs_1.getScheduledJobFromCronCreateResult)(output) : (0, claudeScheduledJobs_1.getScheduledJobFromLoopWakeupResult)(output);
1559
+ if (job)
1560
+ session.scheduledJobs.set(job.jobId, job);
1561
+ }
1562
+ else if (tool === claudeScheduledJobs_1.cronDeleteToolName) {
1563
+ const jobId = (0, claudeScheduledJobs_1.getCancelledJobIdFromCronDeleteResult)(output);
1564
+ if (jobId)
1565
+ session.scheduledJobs.delete(jobId);
1566
+ }
1567
+ }
1409
1568
  // — sub-agent (v1) —
1410
1569
  markSubagentActive(session) {
1411
1570
  session.subagentActive = true;
@@ -1469,6 +1628,13 @@ class ClaudeJsonStreamAdapter extends events_1.EventEmitter {
1469
1628
  console.log(`${exports.claudeJsonStreamUsageLogPrefix}${(0, sessionKey_1.keyToString)(session.key)}: input=${inputTokens} cacheRead=${cacheReadTokens} cacheWrite=${cacheWriteTokens} output=${outputTokens}`);
1470
1629
  }
1471
1630
  session.isBusy = false;
1631
+ // The pending-work hold counts from the agent's own last turn: a bot-issued compaction is not one. A task that
1632
+ // finished during the turn is NOT folded into it: its own turn starts right after this `result` (`init` +45 ms,
1633
+ // measured on 2.1.310), so its announcement stays until that turn shows — clearing it here would let the per-turn
1634
+ // stop kill the process in that gap.
1635
+ if (!session.isCompactionResultPending)
1636
+ session.lastTurnEndedAt = Date.now();
1637
+ session.isCompactionResultPending = false;
1472
1638
  // Self-healing accounting: a FAILED turn (usage limit, API error) may never echo the messages that were
1473
1639
  // queued behind it, and a count left above zero would make the session look "working" forever — a pending
1474
1640
  // account switch would never fire. A message still genuinely queued is echoed later (the decrement clamps
@@ -1803,6 +1969,9 @@ class ClaudeJsonStreamAdapter extends events_1.EventEmitter {
1803
1969
  const { key, workDir, sessionId } = session;
1804
1970
  const configDir = change.configDir === undefined ? session.configDir : change.configDir;
1805
1971
  session.isRespawning = true;
1972
+ // The re-spawned process starts with none of the old one's background tasks or jobs, and the turn it cuts off is
1973
+ // re-prompted by nothing else.
1974
+ this.storeLostWork(key, this.describePendingWork(session, { isTurnCutOff: true }));
1806
1975
  this.rejectQuestion(key);
1807
1976
  await session.stdinWriteChain.catch(() => { });
1808
1977
  this.clearTimers(session);
@@ -1843,6 +2012,8 @@ class ClaudeJsonStreamAdapter extends events_1.EventEmitter {
1843
2012
  }
1844
2013
  if (!options.isAfterLimit && this.checkIsWorking(key)) {
1845
2014
  session.isConfigDirSwitchPending = true;
2015
+ // Recorded now: a reload before the turn ends must not drop the user's pick.
2016
+ this.jsonStreamTailWriter?.(key, buildTailRecord(session, session.lastPersistedTailOffset));
1846
2017
  return 'deferred';
1847
2018
  }
1848
2019
  session.isConfigDirSwitchPending = false;
@@ -1900,14 +2071,22 @@ exports.ClaudeJsonStreamAdapter = ClaudeJsonStreamAdapter;
1900
2071
  * state in force there (task list, turn in flight, outstanding tools) travel together
1901
2072
  * (see `JsonStreamTailOffset`). */
1902
2073
  function buildTailRecord(session, offsetBytes) {
2074
+ const backgroundTasks = [...session.backgroundTasks].map(([taskId, description]) => ({ taskId, description }));
2075
+ const isWorkPending = session.backgroundTasks.size > 0 || session.scheduledJobs.size > 0;
1903
2076
  return {
1904
2077
  sessionId: session.sessionId,
1905
2078
  offsetBytes,
1906
- backgroundTaskIds: [...session.backgroundTaskIds],
2079
+ backgroundTaskIds: backgroundTasks.map((task) => task.taskId),
1907
2080
  isTurnInFlight: session.isBusy,
1908
2081
  outstandingToolUseIds: [...session.outstandingToolUseIds],
1909
2082
  // Only once known: the record never claims a version the process did not report.
1910
2083
  ...(session.claudeCodeVersion !== null ? { claudeCodeVersion: session.claudeCodeVersion } : {}),
2084
+ // The pending work only while there is some: an idle record stays as it was.
2085
+ ...(backgroundTasks.length > 0 ? { backgroundTasks } : {}),
2086
+ ...(isWorkPending ? { lastTurnEndedAtMs: session.lastTurnEndedAt } : {}),
2087
+ ...(session.scheduledJobs.size > 0 ? { scheduledJobs: [...session.scheduledJobs.values()] } : {}),
2088
+ ...(session.notificationTurnPendingSince !== null ? { notificationTurnPendingSinceMs: session.notificationTurnPendingSince } : {}),
2089
+ ...(session.isConfigDirSwitchPending ? { isConfigDirSwitchPending: true } : {}),
1911
2090
  };
1912
2091
  }
1913
2092
  /**