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
package/dist/bot.js CHANGED
@@ -70,8 +70,11 @@ const terminalAdapter_1 = require("./adapters/terminalAdapter");
70
70
  const openCodeQuestionFlow_1 = require("./openCodeQuestionFlow");
71
71
  const pendingQuestionRepost_1 = require("./pendingQuestionRepost");
72
72
  const rateLimiter_1 = require("./rateLimiter");
73
+ const testTimings_1 = require("./testTimings");
73
74
  const installManager_1 = require("./installManager");
74
75
  const startupReadiness_1 = require("./utils/startupReadiness");
76
+ const doctorLine_1 = require("./utils/doctorLine");
77
+ const nativeWindows_1 = require("./utils/nativeWindows");
75
78
  const state_1 = require("./state");
76
79
  const instanceLock_1 = require("./instanceLock");
77
80
  const shutdown_1 = require("./shutdown");
@@ -85,6 +88,7 @@ const timezoneRecompute_1 = require("./scheduler/timezoneRecompute");
85
88
  const validation_1 = require("./validation");
86
89
  const bindPicker_1 = require("./connectors/telegram/bindPicker");
87
90
  const topicCommand_1 = require("./connectors/telegram/commands/topicCommand");
91
+ const forumTopicCreated_1 = require("./connectors/telegram/forumTopicCreated");
88
92
  const providerDisconnectPlan_1 = require("./utils/providerDisconnectPlan");
89
93
  const folderName_1 = require("./folderName");
90
94
  const threadRouting_1 = require("./threadRouting");
@@ -138,6 +142,8 @@ const backendUnavailableError_1 = require("./adapters/backendUnavailableError");
138
142
  const claudeCodeVersion_1 = require("./utils/claudeCodeVersion");
139
143
  const keyedTransitionQueue_1 = require("./utils/keyedTransitionQueue");
140
144
  const adoptedToolList_1 = require("./utils/adoptedToolList");
145
+ const lostWork_1 = require("./utils/lostWork");
146
+ const jsonStreamPendingWork_1 = require("./utils/jsonStreamPendingWork");
141
147
  const compactOnIdle_1 = require("./utils/compactOnIdle");
142
148
  const fileSendService_1 = require("./utils/fileSendService");
143
149
  const messageSendService_1 = require("./utils/messageSendService");
@@ -319,12 +325,17 @@ function parseEnv() {
319
325
  const code = e.code ?? 'unknown';
320
326
  errors.push(`WORK_ROOT="${workRoot}" is not accessible (${code})`);
321
327
  }
328
+ // A test instance's timing overrides (never a setting); an unknown key or a bad value stops the boot, naming it.
329
+ const testTimingsParse = (0, testTimings_1.parseTestTimings)(process.env[testTimings_1.testTimingsEnvName]);
330
+ if (!testTimingsParse.ok)
331
+ errors.push(...testTimingsParse.errors);
322
332
  if (errors.length > 0) {
323
333
  for (const err of errors)
324
334
  console.error(`[startup] ${err}`);
325
335
  process.exit(1);
326
336
  }
327
337
  return {
338
+ testTimings: testTimingsParse.ok ? testTimingsParse.timings : {},
328
339
  botToken,
329
340
  servedPlatforms: (0, connectorSet_1.getServedPlatforms)(connectors),
330
341
  isTelegramServed,
@@ -336,6 +347,15 @@ function parseEnv() {
336
347
  };
337
348
  }
338
349
  const ENV = parseEnv();
350
+ const testTimingsBootLine = (0, testTimings_1.formatTestTimingsBootLine)(ENV.testTimings);
351
+ if (testTimingsBootLine !== null)
352
+ console.log(testTimingsBootLine);
353
+ if (ENV.testTimings.telegramSendIntervalMs !== undefined)
354
+ (0, rateLimiter_1.configureGlobalSendInterval)(ENV.testTimings.telegramSendIntervalMs);
355
+ if (ENV.testTimings.backgroundHoldMaxMs !== undefined)
356
+ (0, createAdapter_1.registerPendingWorkHoldMs)(ENV.testTimings.backgroundHoldMaxMs);
357
+ /** The short retry that probes an unconfirmed usage limit (a test instance may shorten it). */
358
+ const limitProbeDelayMs = ENV.testTimings.limitProbeDelayMs ?? apiErrorRetry_1.unconfirmedLimitProbeDelayMs;
339
359
  // The instance default effort (`/effort` in General) is `settings.json`'s `defaultEffort`, re-read when the file changes.
340
360
  (0, effortLevels_1.setInstanceDefaultEffortReader)((0, instanceSettings_1.createInstanceDefaultEffortReader)(() => (0, instanceSettings_1.getInstanceSettingsPath)(), (line) => console.error(line)));
341
361
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -1292,9 +1312,9 @@ function handleUnconfirmedLimit(key) {
1292
1312
  return false;
1293
1313
  }
1294
1314
  console.log(`[apiRetry] ${k}: an unconfirmed usage limit — probing with a short retry before waiting for the reset`);
1295
- void replyToThread(key, (0, i18n_1.t)('apiRetry.transientNotice', { minutes: Math.round(apiErrorRetry_1.unconfirmedLimitProbeDelayMs / apiRetryMsPerMinute), attempt: 1 }));
1315
+ void replyToThread(key, (0, i18n_1.t)('apiRetry.transientNotice', { minutes: Math.round(limitProbeDelayMs / apiRetryMsPerMinute), attempt: 1 }));
1296
1316
  const now = Date.now();
1297
- armApiRetryTimer(key, { kind: 'transient', attempt: 1, fireAt: now + apiErrorRetry_1.unconfirmedLimitProbeDelayMs, delayMs: apiErrorRetry_1.unconfirmedLimitProbeDelayMs, isLimitProbe: true });
1317
+ armApiRetryTimer(key, { kind: 'transient', attempt: 1, fireAt: now + limitProbeDelayMs, delayMs: limitProbeDelayMs, isLimitProbe: true });
1298
1318
  return true;
1299
1319
  }
1300
1320
  /**
@@ -3683,6 +3703,9 @@ async function releaseThreadSession(key) {
3683
3703
  * adapters can't end up polling/streaming the same thread at once — that
3684
3704
  * desync caused mixed output, silent `/stop`, and the "I keep getting
3685
3705
  * 'Login successful' on every message" bug reported 2026-05-15.
3706
+ *
3707
+ * Resolves the work the stop cut off that the agent's next prompt is told about (`null`: none), so a caller's line to
3708
+ * the operator is derived from the very record the note lists.
3686
3709
  */
3687
3710
  async function switchThreadAdapter(key, newName) {
3688
3711
  const prevName = (0, createAdapter_1.getThreadAdapterNameRaw)(key);
@@ -3691,9 +3714,13 @@ async function switchThreadAdapter(key, newName) {
3691
3714
  // reminding (or, in a terminal, TYPING into) a session that never saw it.
3692
3715
  if (prevName && !(0, createAdapter_1.checkIsSameConversationSwitch)(prevName, newName))
3693
3716
  cancelConversationRequest(key);
3717
+ let switchCutOffWork = null;
3694
3718
  if (prevName && prevName !== newName) {
3695
3719
  try {
3696
3720
  const prev = (0, createAdapter_1.getAdapter)(prevName);
3721
+ // What dies with the stopped process: its own background work and the turn it was in (an explicit stop records nothing).
3722
+ if (prev.checkIsActive(key))
3723
+ switchCutOffWork = prev.getPendingWork?.(key) ?? null;
3697
3724
  if (prev.checkIsActive(key)) {
3698
3725
  // `stopSession` is `void` in the AgentAdapter contract — OpenCode
3699
3726
  // queues teardown behind its lifecycle lock and returns
@@ -3715,7 +3742,7 @@ async function switchThreadAdapter(key, newName) {
3715
3742
  (0, createAdapter_1.setThreadAdapter)(key, newName);
3716
3743
  const agent = state.getAgent(key);
3717
3744
  if (!agent)
3718
- return;
3745
+ return null;
3719
3746
  // Wipe ids that don't belong to the new adapter. We persist via
3720
3747
  // setAgent (which merges), so build a fresh record with only the
3721
3748
  // surviving fields.
@@ -3733,13 +3760,25 @@ async function switchThreadAdapter(key, newName) {
3733
3760
  // fallback switch or a plain /claude ↔ /opencode comes back to the conversation it left
3734
3761
  // (L-D4: a sleeping conversation is woken, `/new` is the fresh start). The reattach loop never reads the parked
3735
3762
  // slot, so the audit S12 bug (both ids on the live row) stays fixed.
3736
- const switched = (0, adapterSwitchSessionIds_1.getAdapterSwitchSessionIds)({ previousName: prevName, nextName: newName, agent, parked: state.getParkedSession(key), parkedAt: new Date().toISOString() });
3763
+ const parkedBefore = state.getParkedSession(key);
3764
+ const switched = (0, adapterSwitchSessionIds_1.getAdapterSwitchSessionIds)({ previousName: prevName, nextName: newName, agent, parked: parkedBefore, parkedAt: new Date().toISOString() });
3737
3765
  if (switched.ids.claudeSessionId)
3738
3766
  next.claudeSessionId = switched.ids.claudeSessionId;
3739
3767
  if (switched.ids.opencodeSessionId)
3740
3768
  next.opencodeSessionId = switched.ids.opencodeSessionId;
3769
+ // Work the switch cut off is told twice, once to each agent: the next prompt (the hand-over bridge, the next message)
3770
+ // tells the agent the topic moves to, and the parked conversation keeps it for its own agent's first prompt back.
3771
+ const cutOffRecord = switchCutOffWork ? { ...switchCutOffWork, reason: 'switched', lostAt: Date.now() } : null;
3772
+ // The parked copy knows the agent the topic moves to hears it first (it may restart some of the work itself).
3773
+ const parkedLoss = switchCutOffWork && cutOffRecord ? { ...cutOffRecord, otherAgentTold: switchCutOffWork } : null;
3741
3774
  if (switched.parked !== undefined)
3742
- await state.setParkedSession(key, switched.parked);
3775
+ await state.setParkedSession(key, switched.parked && parkedLoss ? { ...switched.parked, lostWork: parkedLoss } : switched.parked);
3776
+ // Back to a parked conversation (the side being left may be parked in the same step): what its agent was never told
3777
+ // rides its first prompt.
3778
+ if (switched.isRestored && parkedBefore?.lostWork)
3779
+ await foldLostWorkIntoNextPrompt(key, parkedBefore.lostWork);
3780
+ if (cutOffRecord)
3781
+ await foldLostWorkIntoNextPrompt(key, cutOffRecord);
3743
3782
  // The watermark is scoped to its session: it rides along while that session is kept. Every wake of a sleeping
3744
3783
  // conversation passes through here, and the wake reads it as the proof the conversation took a turn.
3745
3784
  if (agent.seenWatermark && [next.claudeSessionId, next.opencodeSessionId].includes(agent.seenWatermark.sessionId)) {
@@ -3749,6 +3788,7 @@ async function switchThreadAdapter(key, newName) {
3749
3788
  // fields we kept.
3750
3789
  await state.removeAgent(key);
3751
3790
  await state.setAgent(key, next);
3791
+ return switchCutOffWork;
3752
3792
  }
3753
3793
  /**
3754
3794
  * @description The post-start "ready" notice for an agent that just came up — or
@@ -3853,21 +3893,20 @@ async function persistSessionStart(key, adapter) {
3853
3893
  }
3854
3894
  }
3855
3895
  /**
3856
- * @description An adopted json-stream process whose tool list is stale (L4) is
3857
- * stopped at its next idle point: now when idle, else once it stops working —
3858
- * polled, since the bot gets no "turn ended" event it can hang this on. The next
3859
- * trigger resumes the conversation with the current tools. The first look waits
3860
- * for the adopt's replay: until the tail has consumed what stdout held at the
3861
- * adopt, the live state is the persisted one plus nothing, and a turn that ended
3862
- * or began during the downtime would be misread.
3896
+ * @description A per-turn json-stream process that outlived the bot (L5) is stopped as soon as it is idle — its turn
3897
+ * (if any) ended and none of its own work is pending — the per-turn backend's after-turn stop, which the downtime
3898
+ * swallowed. Polled, since the turn may have ended before the adopt. The first look waits for the adopt's replay:
3899
+ * until the tail has consumed what stdout held at the adopt, the live state is the persisted one plus nothing, and a
3900
+ * turn that ended or began during the downtime would be misread. The idle-lifecycle backend has no such stop: its
3901
+ * adopted process, stale tool list or not, is stopped by the idle fire after the compaction.
3863
3902
  */
3864
- function stopAdoptedSessionWhenIdle(key, adapter, reason) {
3903
+ function stopAdoptedPerTurnSessionWhenIdle(key, adapter) {
3865
3904
  const kStr = (0, sessionKey_1.keyToString)(key);
3866
3905
  const tick = () => {
3867
3906
  if (!adapter.checkIsActive(key))
3868
- return; // gone meanwhile (a stop, a crash): nothing to refresh
3907
+ return; // gone meanwhile (a stop, a crash): nothing left to stop
3869
3908
  if (adapter.checkIsWorking?.(key)) {
3870
- const timer = setTimeout(tick, adoptedToolListRefreshPollMs);
3909
+ const timer = setTimeout(tick, adoptedPerTurnStopPollMs);
3871
3910
  timer.unref?.();
3872
3911
  return;
3873
3912
  }
@@ -3875,22 +3914,22 @@ function stopAdoptedSessionWhenIdle(key, adapter, reason) {
3875
3914
  // the suspend would decline on every poll. Stop looking: a later turn's `init` reports the version,
3876
3915
  // and the lifecycle's own stop (the idle timer, the per-turn `turnEnded`) takes over from there.
3877
3916
  if (!adapter.checkIsAutoStopSupported(key)) {
3878
- console.log(`[reattach] ${kStr}: the adopted process is kept (${reason}): its Claude Code version is unknown or too old for the auto-stop; its own lifecycle stop takes over once a turn reports it`);
3917
+ console.log(`[reattach] ${kStr}: the adopted process is kept (the per-turn lifecycle): its Claude Code version is unknown or too old for the auto-stop; its own lifecycle stop takes over once a turn reports it`);
3879
3918
  return;
3880
3919
  }
3881
- console.log(`[reattach] ${kStr}: stopping the adopted process (${reason}); the next trigger resumes it`);
3920
+ console.log(`[reattach] ${kStr}: stopping the adopted process (the per-turn lifecycle); the next trigger resumes it`);
3882
3921
  void suspendThreadSession(key)
3883
3922
  .then(() => {
3884
3923
  // The suspend declines when a turn began between the look and the transition: look again.
3885
3924
  if (adapter.checkIsActive(key))
3886
- setTimeout(tick, adoptedToolListRefreshPollMs).unref?.();
3925
+ setTimeout(tick, adoptedPerTurnStopPollMs).unref?.();
3887
3926
  })
3888
- .catch((e) => console.warn(`[reattach] ${kStr}: the stale-tool-list stop failed:`, e instanceof Error ? e.message : e));
3927
+ .catch((e) => console.warn(`[reattach] ${kStr}: the per-turn stop of the adopted process failed:`, e instanceof Error ? e.message : e));
3889
3928
  };
3890
3929
  void adapter.whenAdoptReplayed(key).then(tick);
3891
3930
  }
3892
- /** How often an adopted, stale-tool-list process that is still working is re-checked for idleness (L4). */
3893
- const adoptedToolListRefreshPollMs = 5000;
3931
+ /** How often an adopted per-turn process that is still working is re-checked for idleness (L5). */
3932
+ const adoptedPerTurnStopPollMs = 5000;
3894
3933
  async function startAgentSession(key, args) {
3895
3934
  const kStr = (0, sessionKey_1.keyToString)(key);
3896
3935
  // The bound folder IS the agent's cwd — refuse to start without one. The
@@ -4159,6 +4198,8 @@ async function suspendThreadSession(key) {
4159
4198
  }
4160
4199
  autoStopUnsupportedLoggedKeys.delete(kStr);
4161
4200
  await sessionTransitions.run(kStr, async () => {
4201
+ // Decide on what the process wrote up to now, not on the last tail poll: a background task's turn may have just begun.
4202
+ adapter.refreshWorkingState?.(key);
4162
4203
  if (!adapter.checkIsActive(key) || adapter.checkIsWorking?.(key))
4163
4204
  return;
4164
4205
  startupPromptBuffer.markStarting(kStr);
@@ -4179,13 +4220,20 @@ async function suspendThreadSession(key) {
4179
4220
  /** Conversations already told once that their CLI is too old for the auto-stop (L-D10). */
4180
4221
  const autoStopUnsupportedLoggedKeys = new Set();
4181
4222
  /**
4182
- * @description `turnEnded` from a json-stream adapter: in the per-turn lifecycle
4183
- * (L5, L-D3) the process is stopped right after the turn — unless a deferred
4184
- * compaction is armed (its drain compacts first, and that turn ends here too) or
4185
- * the process still works (a background task, input not yet taken in). The
4186
- * session sleeps; the next trigger resumes it with the prompt cache warm.
4223
+ * @description `turnEnded` from a json-stream adapter. The idle countdown runs from
4224
+ * the end of the last turn: an idle fire that found the turn still running is not
4225
+ * rescheduled (D2), and a turn whose answer went out through `answer_request`
4226
+ * produces no later output to re-arm it — the process would never reach its idle
4227
+ * compaction and stop, and its pending-work hold would never be enforced. (A
4228
+ * bot-issued compaction's own turn end is skipped by the arming's loop guard.)
4229
+ * In the per-turn lifecycle (L5, L-D3) the process is then stopped right after
4230
+ * the turn — unless a deferred compaction is armed (its drain compacts first,
4231
+ * and that turn ends here too) or the process still works (a background task,
4232
+ * input not yet taken in). The session sleeps; the next trigger resumes it with
4233
+ * the prompt cache warm.
4187
4234
  */
4188
4235
  function handleAgentTurnEnded(key) {
4236
+ noteThreadActivity(key);
4189
4237
  const adapter = (0, createAdapter_1.getThreadAdapter)(key);
4190
4238
  if (adapter.name !== adapterNames_1.claudePerTurnAdapterName)
4191
4239
  return;
@@ -4289,7 +4337,10 @@ async function forwardPromptToAgent(key, adapter, text, sentAtMs, options = {})
4289
4337
  });
4290
4338
  // The request header (S7) leads the per-message body, like the reply quote and
4291
4339
  // unlike the once-per-change thread preamble: it names THIS message's request.
4292
- const body = options.requestHeader ? `${options.requestHeader}${quotedBody}` : quotedBody;
4340
+ const messageBody = options.requestHeader ? `${options.requestHeader}${quotedBody}` : quotedBody;
4341
+ // A loss the agent was not told about yet (`utils/lostWork.ts`) rides the first real prompt, once, ahead of it.
4342
+ const lostWorkRecord = isReplayable && adapter.name !== 'terminal' ? state.getLostWork(key) : undefined;
4343
+ const body = lostWorkRecord ? `${(0, lostWork_1.buildLostWorkNote)(lostWorkRecord, (0, i18n_1.getLocaleEndonym)((0, i18n_1.getActiveLang)()))}\n\n${messageBody}` : messageBody;
4293
4344
  // Cache the (reply-folded) prompt so a wedged OpenCode session can be recovered
4294
4345
  // by restarting fresh and replaying it — replaying WITH the quote is correct.
4295
4346
  // A genuine new prompt also opens a fresh recovery episode (clears the guard).
@@ -4341,6 +4392,9 @@ async function forwardPromptToAgent(key, adapter, text, sentAtMs, options = {})
4341
4392
  await adapter.detachRunningSubagents(key);
4342
4393
  }
4343
4394
  adapter.sendInput(key, promptText);
4395
+ // Told only once the prompt carrying the note reached a live session: a forward that failed keeps it for the next.
4396
+ if (lostWorkRecord && adapter.checkIsActive(key))
4397
+ void state.clearLostWork(key);
4344
4398
  // S2 busy-onset arm: start the Claude liveness loop the moment the prompt is
4345
4399
  // forwarded — driven by `checkIsBusy`, not by waiting for an opportunistic
4346
4400
  // scrape emit that may never come during a long quiet think. The arming grace
@@ -5478,13 +5532,9 @@ bot.action('open_sessions', async (ctx) => {
5478
5532
  await ctx.answerCbQuery();
5479
5533
  await handleSessionsList(key);
5480
5534
  });
5481
- function formatDoctorLine(line) {
5482
- if (line.status === 'ok')
5483
- return (0, i18n_1.t)('doctor.ok', { label: line.label });
5484
- if (line.status === 'warn')
5485
- return (0, i18n_1.t)('doctor.warn', { label: line.label, hint: line.hint ?? '' });
5486
- return (0, i18n_1.t)('doctor.fail', { label: line.label, hint: line.hint ?? '' });
5487
- }
5535
+ // ═══════════════════════════════════════════════════════════════════════════════
5536
+ // /doctor — self-diagnostics (plan §20.1)
5537
+ // ═══════════════════════════════════════════════════════════════════════════════
5488
5538
  async function checkCliPresent(cmd) {
5489
5539
  try {
5490
5540
  await execFileAsync(cmd, ['--version'], { timeout: 1500 });
@@ -5496,6 +5546,10 @@ async function checkCliPresent(cmd) {
5496
5546
  }
5497
5547
  async function runDoctor() {
5498
5548
  const lines = [];
5549
+ // 0. Native Windows runs with limits (no tmux, no self-update restart, no hot mode, no local speech install).
5550
+ const nativeWindowsLine = (0, nativeWindows_1.getNativeWindowsDoctorLine)(process.platform, i18n_1.t);
5551
+ if (nativeWindowsLine)
5552
+ lines.push(nativeWindowsLine);
5499
5553
  // 1. Bot admin permissions in the configured group.
5500
5554
  const groupId = getAllowedGroupId();
5501
5555
  if (groupId === null) {
@@ -5602,7 +5656,7 @@ command('doctor', async (_ctx, key) => {
5602
5656
  // Single big edit-in-place would be nicer, but doctor is rarely run and
5603
5657
  // gathering the report in series keeps it dead simple.
5604
5658
  const lines = await runDoctor();
5605
- const body = lines.map(formatDoctorLine).join('\n');
5659
+ const body = lines.map((line) => (0, doctorLine_1.formatDoctorLine)(line, i18n_1.t)).join('\n');
5606
5660
  await replyToThread(key, `${(0, i18n_1.t)('doctor.header')}\n\n${body}`, { parse_mode: 'Markdown' });
5607
5661
  });
5608
5662
  function loadMcpFile(filePath) {
@@ -5763,12 +5817,14 @@ async function applyClaudeBackendSwitch(key, target) {
5763
5817
  const wasActive = (0, createAdapter_1.getThreadAdapter)(key).checkIsActive(key);
5764
5818
  const sessionId = state.getAgent(key)?.claudeSessionId;
5765
5819
  // Stops the previous backend, records the pick, keeps `claudeSessionId`.
5766
- await switchThreadAdapter(key, target);
5820
+ const cutOffWork = await switchThreadAdapter(key, target);
5821
+ // The operator hears that the background work died with the stopped process; the agent hears it in its next prompt.
5822
+ const backgroundStoppedSuffix = (0, lostWork_1.checkHasLostBackgroundWork)(cutOffWork) ? `\n${(0, i18n_1.t)('fallback.backgroundStopped')}` : '';
5767
5823
  if (!wasActive)
5768
5824
  return (0, i18n_1.t)('claudeMode.set_idle', { label });
5769
5825
  const decision = getWorkDirStartDecision(key);
5770
5826
  if (!decision.ok)
5771
- return decision.message;
5827
+ return `${decision.message}${backgroundStoppedSuffix}`;
5772
5828
  const targetAdapter = (0, createAdapter_1.getThreadAdapter)(key);
5773
5829
  sendThreadTypingIndicator(key).catch(() => { });
5774
5830
  if (sessionId && targetAdapter.resumeSession) {
@@ -5777,14 +5833,14 @@ async function applyClaudeBackendSwitch(key, target) {
5777
5833
  try {
5778
5834
  await targetAdapter.resumeSession(key, decision.workDir, sessionId, { isWithRecentContext: true });
5779
5835
  await persistSessionStart(key, targetAdapter);
5780
- return (0, i18n_1.t)('claudeMode.switched_resumed', { label });
5836
+ return `${(0, i18n_1.t)('claudeMode.switched_resumed', { label })}${backgroundStoppedSuffix}`;
5781
5837
  }
5782
5838
  catch (e) {
5783
5839
  console.error('[claude_mode] resume failed, starting fresh:', e instanceof Error ? e.message : e);
5784
5840
  }
5785
5841
  }
5786
5842
  const msg = await startAgentSession(key);
5787
- return msg || (0, i18n_1.t)('claudeMode.switched_fresh', { label });
5843
+ return `${msg || (0, i18n_1.t)('claudeMode.switched_fresh', { label })}${backgroundStoppedSuffix}`;
5788
5844
  }
5789
5845
  command('claude_mode', async (_ctx, key, parsed) => {
5790
5846
  if (checkIsGeneral(key)) {
@@ -6276,15 +6332,25 @@ async function switchTopicToFallbackEntry(key, from, to, options) {
6276
6332
  if (from.profile !== to.profile)
6277
6333
  await state.setClaudeProfileOverride(key, to.profile);
6278
6334
  // The turn is dead after a limit: re-spawn now, even over background work (it is stopped; the notice says so).
6279
- const adapter = (0, createAdapter_1.getThreadAdapter)(key);
6280
- isStoppingBackground = adapter instanceof claudeJsonStreamAdapter_1.ClaudeJsonStreamAdapter && adapter.checkHasBackgroundWork(key);
6281
- const outcome = await applyClaudeProfileToThread(key, { isAfterLimit: true });
6335
+ lostWorkFoldingKeys.add(k);
6336
+ let outcome;
6337
+ try {
6338
+ outcome = await applyClaudeProfileToThread(key, { isAfterLimit: true });
6339
+ }
6340
+ finally {
6341
+ lostWorkFoldingKeys.delete(k);
6342
+ }
6282
6343
  if (outcome === 'deferred' || outcome === 'restartRequired') {
6283
6344
  fallbackSwitchOffers.delete(k);
6284
6345
  const notice = (0, fallbackSwitchNotice_1.buildFallbackDeferredNotice)(from, to);
6285
6346
  await say(notice);
6286
6347
  return { isSwitched: false, notice };
6287
6348
  }
6349
+ // The re-spawn took the old process's background work: the notice below says so, the continue nudge tells the agent.
6350
+ const cutOffWork = takeLostWorkOf(key);
6351
+ isStoppingBackground = (0, lostWork_1.checkHasLostBackgroundWork)(cutOffWork);
6352
+ if (cutOffWork)
6353
+ await foldLostWorkIntoNextPrompt(key, { ...cutOffWork, reason: 'restarted', lostAt: Date.now() });
6288
6354
  }
6289
6355
  else if (isSameFamily && to.backend === 'opencode') {
6290
6356
  const error = await (0, createAdapter_1.getThreadAdapter)(key).setModel?.(key, (0, fallbackChain_1.buildOpenCodeModelLabel)(to));
@@ -6298,7 +6364,7 @@ async function switchTopicToFallbackEntry(key, from, to, options) {
6298
6364
  reason: 'limit',
6299
6365
  resetsAtText: getFallbackResetTime(from),
6300
6366
  });
6301
- await moveTopicToOtherBackend(key, to.backend, to.backend === 'claude' ? to.profile : null);
6367
+ isStoppingBackground = await moveTopicToOtherBackend(key, to.backend, to.backend === 'claude' ? to.profile : null);
6302
6368
  if (to.backend === 'opencode') {
6303
6369
  const error = await (0, createAdapter_1.getThreadAdapter)(key).setModel?.(key, (0, fallbackChain_1.buildOpenCodeModelLabel)(to));
6304
6370
  if (error)
@@ -6325,12 +6391,13 @@ async function switchTopicToFallbackEntry(key, from, to, options) {
6325
6391
  * half: record `account` as the topic's own Claude pick when one is given (the chain's rung, the operator's pick — a
6326
6392
  * switch that only names the account the topic follows passes `null`), then `switchThreadAdapter` stops the old
6327
6393
  * session, parks its id and brings back the target family's parked one. The context bridge is built BEFORE this, while
6328
- * the adapter being left still runs.
6394
+ * the adapter being left still runs. Resolves whether the stop took background work with it — the caller tells the
6395
+ * operator (the agent is told by the next prompt, `switchThreadAdapter`).
6329
6396
  */
6330
6397
  async function moveTopicToOtherBackend(key, backend, account) {
6331
6398
  if (account !== null)
6332
6399
  await state.setClaudeProfileOverride(key, account);
6333
- await switchThreadAdapter(key, backend === 'claude' ? (0, createAdapter_1.resolveClaudeBackendName)(key) : adapterNames_1.openCodeAdapterName);
6400
+ return (0, lostWork_1.checkHasLostBackgroundWork)(await switchThreadAdapter(key, backend === 'claude' ? (0, createAdapter_1.resolveClaudeBackendName)(key) : adapterNames_1.openCodeAdapterName));
6334
6401
  }
6335
6402
  /**
6336
6403
  * @description The second half, after a move (across backends or between accounts): start or resume the topic's
@@ -6421,7 +6488,10 @@ async function executeBackendSwitch(key, target, options) {
6421
6488
  userMessage: options.userMessage,
6422
6489
  })
6423
6490
  : null;
6424
- await moveTopicToOtherBackend(key, target.backend, target.backend === 'claude' && target.isProfilePicked ? target.profile : null);
6491
+ const isStoppingBackground = await moveTopicToOtherBackend(key, target.backend, target.backend === 'claude' && target.isProfilePicked ? target.profile : null);
6492
+ // The operator hears that the background work died with the old session; its agent hears it in the first prompt.
6493
+ if (isStoppingBackground)
6494
+ await replyToThread(key, (0, i18n_1.t)('fallback.backgroundStopped'));
6425
6495
  cancelApiRetry(key); // a wait armed for the backend just left is over
6426
6496
  await resumeTopicOnNewSession(key, {
6427
6497
  modelToPin: (0, backendSwitchPlan_1.getBackendSwitchModelToPin)(target),
@@ -8080,7 +8150,11 @@ bot.on((0, filters_1.message)('forum_topic_created'), async (ctx) => {
8080
8150
  // attacker-chosen folder. Stash the topic name so a later authorised-user
8081
8151
  // message in the same thread can still benefit from fuzzy auto-bind.
8082
8152
  const userId = ctx.from?.id;
8083
- if (!userId || !(await checkIsAllowedUser(ctx))) {
8153
+ const action = await (0, forumTopicCreated_1.getForumTopicCreatedAction)({ creatorId: userId, botUserId: bot.botInfo?.id, checkIsCreatorAllowed: () => checkIsAllowedUser(ctx) });
8154
+ // `/topic` binds and greets the topic it created itself; its own service message needs nothing more.
8155
+ if (action === 'ignoreOwn')
8156
+ return;
8157
+ if (action === 'cacheNameOnly') {
8084
8158
  if (topicName) {
8085
8159
  pendingTopicNames.set((0, sessionKey_1.keyToString)(key), { name: topicName, ts: Date.now() });
8086
8160
  }
@@ -8490,12 +8564,9 @@ bot.action(/^reask_(\d+)$/, async (ctx) => {
8490
8564
  // The label goes to the agent as a prompt and nothing else in the topic shows it whole (the buttons clip long
8491
8565
  // labels): a label too long for the toast is posted in full, as the record of the answer that was sent.
8492
8566
  await (0, callbackToast_1.answerCallbackWithWholeText)(ctx, label, (wholeLabel) => replyToThread(key, wholeLabel));
8493
- // An answer to the agent's own question is never a request (S7), even delivered as a prompt.
8494
- if (isStarting) {
8495
- noteThreadUserActivity(key);
8496
- await withThreadLocale(key, () => deliverPromptOrBuffer(key, label, true));
8497
- return;
8498
- }
8567
+ // An answer to the agent's own question is never a request (S7), even delivered as a prompt. Routed at delivery,
8568
+ // not on the `isStarting` read above: the stop may have ended during the toast, and a label buffered into its closed
8569
+ // window was never replayed.
8499
8570
  const outcome = await withThreadLocale(key, () => deliverActivePrompt(key, adapter, label, null));
8500
8571
  // The label reached no session (the topic was told why): the question is still open, so are its buttons.
8501
8572
  if (outcome === 'noSession' && labels && !reAskedQuestionOptions.has((0, sessionKey_1.keyToString)(key)))
@@ -10004,6 +10075,8 @@ function handleQuestionGone(key) {
10004
10075
  void unpinThreadQuestion(key);
10005
10076
  }
10006
10077
  function handleAgentClosed(key) {
10078
+ // What the process held that died with it (its own background work), taken before the cleanup below.
10079
+ const lostWork = takeLostWorkOf(key);
10007
10080
  // Session is gone — drop any not-yet-sent output AND status frame so they
10008
10081
  // don't surface after the "session ended" notice (the trailing-output bug:
10009
10082
  // a 429 backlog could let queued deltas land seconds after the close).
@@ -10024,11 +10097,99 @@ function handleAgentClosed(key) {
10024
10097
  // A closed session has nothing to compact — drop the idle watchdog + any arm.
10025
10098
  clearThreadCompaction(key);
10026
10099
  const adapter = (0, createAdapter_1.getThreadAdapter)(key);
10027
- replyToThread(key, (0, i18n_1.t)('agent.session_ended', { label: adapter.label })).catch(() => { });
10100
+ // Work died with the process: the lost-work line (and the resume it announces) replaces the bare "session ended".
10101
+ if (lostWork)
10102
+ scheduleLostWorkNote(key, lostWork, 'exited');
10103
+ else
10104
+ replyToThread(key, (0, i18n_1.t)('agent.session_ended', { label: adapter.label })).catch(() => { });
10028
10105
  // Banner now reads `idle`; closed sessions may also persist with the
10029
10106
  // wrong model/agent label otherwise.
10030
10107
  updatePinnedStatus(key).catch(() => { });
10031
10108
  }
10109
+ const msPerHour = 60 * 60 * 1000;
10110
+ /** The pending-work hold in force, in hours, as the topic line names it (`utils/jsonStreamPendingWork.ts`; a test instance's shorter one). */
10111
+ const pendingWorkHoldHours = Number(((ENV.testTimings.backgroundHoldMaxMs ?? jsonStreamPendingWork_1.pendingWorkHoldDefaultMs) / msPerHour).toFixed(1));
10112
+ /** What the conversation's last process took with it when it ended, handed over once by its adapter. */
10113
+ function takeLostWorkOf(key, adapter = (0, createAdapter_1.getThreadAdapter)(key)) {
10114
+ return adapter.takeLostWork?.(key) ?? null;
10115
+ }
10116
+ /**
10117
+ * The conversations a usage-limit switch is re-spawning right now: the re-spawn's `started` leaves the loss to the
10118
+ * switch, which folds it into its own first prompt — decided at the call site, never by which of the two ran first.
10119
+ */
10120
+ const lostWorkFoldingKeys = new Set();
10121
+ /**
10122
+ * @description Keep a loss for the NEXT prompt the conversation gets, with no line and no resume of its own: a move
10123
+ * that already says so in its notice and sends a first prompt (a limit switch's continue nudge, a backend switch's
10124
+ * hand-over bridge, a return to a parked conversation).
10125
+ */
10126
+ async function foldLostWorkIntoNextPrompt(key, record) {
10127
+ console.log(`[lost-work] ${(0, sessionKey_1.keyToString)(key)}: the agent's own work ended with the process (${record.reason}); the move's first prompt tells the agent`);
10128
+ await state.setLostWork(key, (0, lostWork_1.mergeLostWorkRecords)(state.getLostWork(key), record));
10129
+ }
10130
+ /** {@link noteLostWork} from an event handler: in the conversation's locale, not awaited, a failure logged. */
10131
+ function scheduleLostWorkNote(key, lostWork, reason) {
10132
+ if (!lostWork)
10133
+ return;
10134
+ void withThreadLocale(key, () => noteLostWork(key, lostWork, reason)).catch((e) => console.warn(`[lost-work] ${(0, sessionKey_1.keyToString)(key)}:`, e instanceof Error ? e.message : e));
10135
+ }
10136
+ /**
10137
+ * @description A process ended with the agent's own work pending (`utils/lostWork.ts`): keep the loss until the agent
10138
+ * is told, post the ONE plain line in a topic, and — unless the plan says the note waits for the next prompt — resume
10139
+ * the conversation with the note as its prompt. Runs in the conversation's locale.
10140
+ */
10141
+ async function noteLostWork(key, lostWork, reason) {
10142
+ if (!lostWork)
10143
+ return;
10144
+ const kStr = (0, sessionKey_1.keyToString)(key);
10145
+ const now = Date.now();
10146
+ const record = (0, lostWork_1.mergeLostWorkRecords)(state.getLostWork(key), { ...lostWork, reason, lostAt: now });
10147
+ await state.setLostWork(key, record);
10148
+ const lastAutoResumeAt = state.getLostWorkResumedAt(key);
10149
+ const isLimitWaitArmed = getArmedApiRetry(key)?.kind === 'usageLimit';
10150
+ const plan = (0, lostWork_1.getLostWorkPlan)({
10151
+ reason,
10152
+ isLimitWaitArmed,
10153
+ msSinceLastAutoResume: lastAutoResumeAt === undefined ? null : now - lastAutoResumeAt,
10154
+ // A tracker issue's agent answers only through a request: a bare note would have nowhere to answer.
10155
+ isRequestRequired: !(0, sessionKeyCodec_1.checkIsTelegramKey)(key),
10156
+ });
10157
+ console.log(`[lost-work] ${kStr}: the process ended (${reason}) with the agent's own work pending (${record.backgroundTasks.length} background task(s), ${record.scheduledJobCount} scheduled job(s)${record.hasUnreadTaskResult ? ', an unread task result' : ''}${record.wasTurnInFlight ? ', a cut-off turn' : ''}); ${plan.isAutoResume ? 'resuming it with a note' : 'the note rides the next prompt'}`);
10158
+ let linePlan = plan;
10159
+ if (plan.isAutoResume) {
10160
+ await state.setLostWorkResumedAt(key, now);
10161
+ // The line says what happened: a resume that failed leaves the note for the next prompt, worded by the same rule.
10162
+ if (!(await resumeConversationForLostWork(key)))
10163
+ linePlan = (0, lostWork_1.getHeldLostWorkPlan)({ reason, isLimitWaitArmed });
10164
+ }
10165
+ if ((0, sessionKeyCodec_1.checkIsTelegramKey)(key))
10166
+ replyToThread(key, (0, lostWork_1.formatLostWorkTopicLine)(record, linePlan, pendingWorkHoldHours, i18n_1.t)).catch(() => { });
10167
+ }
10168
+ /**
10169
+ * @description Resume the conversation with the lost-work note as its prompt; `false` when the post failed — the note
10170
+ * then stays for the next prompt.
10171
+ */
10172
+ async function resumeConversationForLostWork(key) {
10173
+ const record = state.getLostWork(key);
10174
+ if (!record)
10175
+ return true;
10176
+ await state.clearLostWork(key);
10177
+ const posted = await (0, postToSession_1.postToSession)(createLostWorkPostDeps(), (0, sessionKey_1.keyToString)(key), (0, lostWork_1.buildLostWorkNote)(record, (0, i18n_1.getLocaleEndonym)((0, i18n_1.getActiveLang)())));
10178
+ if (posted.ok)
10179
+ return true;
10180
+ console.warn(`[lost-work] ${(0, sessionKey_1.keyToString)(key)}: the resume failed (${posted.reason === 'forward-failed' ? posted.error : posted.reason}); the note rides the next prompt`);
10181
+ await state.setLostWork(key, (0, lostWork_1.mergeLostWorkRecords)(state.getLostWork(key), record));
10182
+ return false;
10183
+ }
10184
+ /** The session post of a lost-work note: a bot note to the agent, not a request — no request is opened for it. */
10185
+ function createLostWorkPostDeps() {
10186
+ return {
10187
+ ...createSessionPostDeps(),
10188
+ forwardPrompt: async (conversationKey, text) => {
10189
+ await deliverPromptOrBuffer((0, sessionKey_1.keyFromString)(conversationKey), text, startupPromptBuffer.checkIsStarting(conversationKey));
10190
+ },
10191
+ };
10192
+ }
10032
10193
  function handleAgentError(key, error) {
10033
10194
  console.error(`[Bot] adapter error ${(0, sessionKey_1.keyToString)(key)}:`, error.message);
10034
10195
  stopClaudeLiveness(key);
@@ -10064,6 +10225,10 @@ function handleAgentStarted(key) {
10064
10225
  // activity instant rather than restart at the full 55 minutes. A genuinely fresh
10065
10226
  // session has no persisted instant and so gets the full window anyway.
10066
10227
  rearmThreadIdleTimer(key);
10228
+ // A re-spawn (model, effort, account) began without the old process's background work — unless a usage-limit switch
10229
+ // is re-spawning it: that switch folds the loss into its own first prompt (`switchTopicToFallbackEntry`).
10230
+ if (!lostWorkFoldingKeys.has((0, sessionKey_1.keyToString)(key)))
10231
+ scheduleLostWorkNote(key, takeLostWorkOf(key), 'restarted');
10067
10232
  }
10068
10233
  /**
10069
10234
  * @description `stopped` from the adapter — flip the banner back to `idle`.
@@ -10139,6 +10304,8 @@ function dropLostConversationWatermark(key) {
10139
10304
  function handleAgentSuspended(key) {
10140
10305
  clearGoneProcessFrames(key);
10141
10306
  updatePinnedStatus(key).catch(() => { });
10307
+ // An idle stop takes pending work with it only once the hold ran out.
10308
+ scheduleLostWorkNote(key, takeLostWorkOf(key), 'holdExpired');
10142
10309
  }
10143
10310
  // ═══════════════════════════════════════════════════════════════════════════════
10144
10311
  // Startup orchestration — state init, re-attach, setMyCommands, launch
@@ -10440,10 +10607,14 @@ async function reattachExistingSessions(opts = { quietReattach: false }) {
10440
10607
  // json-stream thread with a persisted id but no live process SLEEPS
10441
10608
  // (lifecycle plan L4): it is NOT re-spawned — its next trigger resumes it
10442
10609
  // — and it gets the downtime recap read from the transcript alone. An
10443
- // adopted process keeps the bot-MCP tool list it got at its own start, so
10444
- // one started under an earlier bot build is stopped at its next idle point
10445
- // and resumed with the current tools (`decideAdoptedToolListRefresh`).
10610
+ // adopted process keeps the bot-MCP tool list it got at its own start: one
10611
+ // started under an earlier bot build is NOT stopped early — it follows the
10612
+ // normal idle lifecycle (compaction at the idle mark, then the stop, never
10613
+ // while its own work is pending) and the next trigger resumes it with the
10614
+ // current tools (`checkIsAdoptedToolListStale`).
10446
10615
  const claudeJsonAdapter = (0, createAdapter_1.getAdapter)(adapterNames_1.claudeJsonStreamAdapterName);
10616
+ /** The conversations whose process was gone at boot with the agent's own work pending, by key — each told once. */
10617
+ const lostWorkFoundAtBoot = new Map();
10447
10618
  let jsonAdopted = 0;
10448
10619
  let jsonKilled = 0;
10449
10620
  let jsonSleeping = 0;
@@ -10477,33 +10648,31 @@ async function reattachExistingSessions(opts = { quietReattach: false }) {
10477
10648
  jsonAdopted += 1;
10478
10649
  if (ownerAdapter.lifecycle === 'perTurn') {
10479
10650
  // A per-turn process that outlived the bot is stopped as soon as its turn (if any) ends (L5).
10480
- stopAdoptedSessionWhenIdle(key, ownerAdapter, 'the per-turn lifecycle');
10651
+ stopAdoptedPerTurnSessionWhenIdle(key, ownerAdapter);
10481
10652
  }
10482
- const refresh = (0, adoptedToolList_1.decideAdoptedToolListRefresh)({
10483
- persistedDigest: agent.mcpToolDigest,
10484
- currentDigest: botMcpToolDigestReader?.(key.platform) ?? '',
10485
- isWorking: ownerAdapter.checkIsWorking(key),
10486
- });
10487
- if (refresh !== 'fresh') {
10488
- // Read from the persisted state (the turn-in-flight flag, the task list); the stop itself
10489
- // looks again once the downtime frames have replayed.
10490
- console.log(`[reattach] ${(0, sessionKey_1.keyToString)(key)}: adopted with a stale tool list (${refresh === 'stopNow' ? 'idle: stopped now' : 'working: stopped once idle'})`);
10491
- if (ownerAdapter.lifecycle !== 'perTurn')
10492
- stopAdoptedSessionWhenIdle(key, ownerAdapter, 'the stale tool list');
10653
+ if ((0, adoptedToolList_1.checkIsAdoptedToolListStale)({ persistedDigest: agent.mcpToolDigest, currentDigest: botMcpToolDigestReader?.(key.platform) ?? '' })) {
10654
+ console.log(`[reattach] ${(0, sessionKey_1.keyToString)(key)}: adopted with a stale tool list; it gets the current tools when it resumes after its idle stop`);
10493
10655
  }
10494
- // The surviving process keeps the account it was spawned with; a topic whose pick changed while the
10495
- // bot was down gets it at the next idle point (the resume reads the current profile).
10656
+ // The surviving process keeps the account it was spawned with. A switch the user picked while it worked is
10657
+ // restored with the adopt and still applies at the end of its turn; any other change of the resolved
10658
+ // profile reaches it when it resumes after its idle stop.
10496
10659
  const spawnedConfigDir = ownerAdapter.getSessionClaudeConfigDir(key);
10497
10660
  if (spawnedConfigDir !== undefined && !(0, claudeProfiles_1.checkIsSameClaudeConfigDir)(spawnedConfigDir, resolveThreadClaudeConfigDir(key))) {
10498
10661
  console.log(`[reattach] ${(0, sessionKey_1.keyToString)(key)}: adopted under profile dir ${spawnedConfigDir ?? 'default'}, the topic now resolves to ${resolveThreadClaudeConfigDir(key) ?? 'default'}`);
10499
- if (ownerAdapter.lifecycle !== 'perTurn')
10500
- stopAdoptedSessionWhenIdle(key, ownerAdapter, 'the account profile');
10501
10662
  }
10502
10663
  }
10503
10664
  else {
10504
10665
  // Dead/zombie — adopt cleaned it up itself; the thread sleeps (below)
10505
- // and its next trigger resumes it from the persisted session id.
10666
+ // and its next trigger resumes it from the persisted session id — at once
10667
+ // when its own work died with it (the agent is told, once the adopts are done).
10506
10668
  jsonKilled += 1;
10669
+ const lostWork = takeLostWorkOf(key, ownerAdapter);
10670
+ if (lostWork) {
10671
+ lostWorkFoundAtBoot.set((0, sessionKey_1.keyToString)(key), { key, lostWork });
10672
+ // Told once: the record it came from drops it (the pass over the sleeping conversations below reads it too).
10673
+ if (agent.jsonStreamTail)
10674
+ void state.setJsonStreamTail(key, (0, lostWork_1.getTailRecordWithoutPendingWork)(agent.jsonStreamTail));
10675
+ }
10507
10676
  }
10508
10677
  }
10509
10678
  catch (e) {
@@ -10525,6 +10694,13 @@ async function reattachExistingSessions(opts = { quietReattach: false }) {
10525
10694
  // Sleeping (L4): no process is started — the next trigger resumes the conversation. What the agent
10526
10695
  // produced while the bot was down is still recapped, read from the transcript on disk.
10527
10696
  jsonSleeping += 1;
10697
+ // A process that died while the bot was down (its tmux session gone with it) left its pending work in the record:
10698
+ // the agent is told — once: the record drops it now.
10699
+ const goneProcessLostWork = (0, lostWork_1.getLostWorkFromTailRecord)(agent.jsonStreamTail ?? null, agent.claudeSessionId);
10700
+ if (agent.jsonStreamTail && (0, lostWork_1.checkHasLostWork)(goneProcessLostWork) && !lostWorkFoundAtBoot.has((0, sessionKey_1.keyToString)(key))) {
10701
+ lostWorkFoundAtBoot.set((0, sessionKey_1.keyToString)(key), { key, lostWork: goneProcessLostWork });
10702
+ void state.setJsonStreamTail(key, (0, lostWork_1.getTailRecordWithoutPendingWork)(agent.jsonStreamTail));
10703
+ }
10528
10704
  // A sleeping per-turn session with a compaction due gets its idle timer from the persisted stamps (L-D7).
10529
10705
  if (agent.name === adapterNames_1.claudePerTurnAdapterName)
10530
10706
  rearmThreadIdleTimer(key);
@@ -10534,6 +10710,9 @@ async function reattachExistingSessions(opts = { quietReattach: false }) {
10534
10710
  void postReattachRecap(key, ownerAdapter, workDirDecision.workDir, agent.claudeSessionId, agent.seenWatermark ?? null, !opts.quietReattach).catch(() => { });
10535
10711
  }
10536
10712
  console.log(`[reattach] claude-json-stream: adopted ${jsonAdopted}, sleeping ${jsonSleeping}, killed ${jsonKilled} orphans (quiet=${opts.quietReattach})`);
10713
+ // Not awaited: a resume must not hold up the rest of the boot (the prompt is buffered while the session starts).
10714
+ for (const { key, lostWork } of lostWorkFoundAtBoot.values())
10715
+ scheduleLostWorkNote(key, lostWork, 'foundDead');
10537
10716
  // 3. Terminal — tmux shells (`term-…`). Like the Claude scan but simpler:
10538
10717
  // a terminal has no session-id to recover, so adoption keys purely on a
10539
10718
  // live binding whose agent is `terminal`. The tmux name is derived from
@@ -11252,10 +11431,11 @@ async function resolveBotAdminRights(groupId, botUserId) {
11252
11431
  }
11253
11432
  }
11254
11433
  /**
11255
- * @description The General topic the updater talks in: the paired group's General when a group is paired, else the owner's DM
11256
- * General (`null` when neither exists — a Jira-only instance has no Telegram to say anything in).
11434
+ * @description The General topic the bot speaks to the operator in (the updater, the native-Windows notice): the paired group's
11435
+ * General when a group is paired, else the owner's DM General (`null` when neither exists — a Jira-only instance has no
11436
+ * Telegram to say anything in).
11257
11437
  */
11258
- function getUpdateGeneralKey() {
11438
+ function getOperatorGeneralKey() {
11259
11439
  const groupId = getAllowedGroupId();
11260
11440
  if (groupId !== null)
11261
11441
  return (0, sessionKeyCodec_1.makeTelegramKey)(groupId, threadRouting_1.GENERAL_THREAD_ID);
@@ -11270,7 +11450,7 @@ function getUpdateGeneralKey() {
11270
11450
  async function postUpdateMessageToGeneral(message) {
11271
11451
  if (!ENV.isTelegramServed)
11272
11452
  return;
11273
- const generalKey = getUpdateGeneralKey();
11453
+ const generalKey = getOperatorGeneralKey();
11274
11454
  if (generalKey === null) {
11275
11455
  console.log(`[update] no General topic to post "${message.kind}" in`);
11276
11456
  return;
@@ -11395,6 +11575,20 @@ async function sendStartupStatus(isHotReload) {
11395
11575
  }
11396
11576
  console.warn('[startup-status] no reachable target — status not delivered');
11397
11577
  }
11578
+ /**
11579
+ * @description On native Windows, say once per cold start in General which features do not work there and point to WSL2,
11580
+ * worded in that chat's language. Best-effort like the readiness status: no General topic to speak in is not an error (the
11581
+ * console line already said it), and a failed send is logged by the send path.
11582
+ */
11583
+ async function postNativeWindowsNoticeToGeneral(isHotReload) {
11584
+ const generalKey = getOperatorGeneralKey();
11585
+ if (generalKey === null)
11586
+ return;
11587
+ const text = withThreadLocale(generalKey, () => (0, nativeWindows_1.getNativeWindowsGeneralNotice)({ platform: process.platform, isHotReload }, i18n_1.t));
11588
+ if (text === null)
11589
+ return;
11590
+ await replyToThread(generalKey, text);
11591
+ }
11398
11592
  /**
11399
11593
  * @description Load and prepare the Jira connector, or end the start: a broken
11400
11594
  * config, a token of another account or an unknown trigger status is fatal, with
@@ -11407,6 +11601,7 @@ async function prepareJiraConnectorOrExit() {
11407
11601
  dataDir: path.dirname(state.stateFilePath),
11408
11602
  workRoot: ENV.workRoot,
11409
11603
  openCodeUrl: process.env.OPENCODE_URL,
11604
+ pollIntervalMsOverride: ENV.testTimings.jiraPollIntervalMs,
11410
11605
  });
11411
11606
  }
11412
11607
  catch (error) {
@@ -11459,7 +11654,7 @@ async function startBot() {
11459
11654
  // 1. State store. The `[startup] DATA_DIR=` shape is load-bearing: the
11460
11655
  // README's two-instances troubleshooting tells the operator to compare this
11461
11656
  // exact line across instances.
11462
- state = await (0, state_1.getStateStore)();
11657
+ state = await (0, state_1.getStateStore)({ saveDebounceMs: ENV.testTimings.stateSaveDebounceMs });
11463
11658
  console.log(`[startup] DATA_DIR=${path.dirname(state.stateFilePath)}`);
11464
11659
  // R13: before anything reads, rewrites or acts on the state, it must hold only
11465
11660
  // conversations of the platforms this instance serves.
@@ -11489,6 +11684,8 @@ async function startBot() {
11489
11684
  (0, createAdapter_1.registerClaudeConfigDirReader)(resolveThreadClaudeConfigDir);
11490
11685
  (0, createAdapter_1.registerClaudeRateLimitSink)(onClaudeRateLimitEvent);
11491
11686
  (0, createAdapter_1.registerClaudeTurnCostSink)(onClaudeTurnCost);
11687
+ if (ENV.testTimings.openCodeServerUpBudgetMs !== undefined)
11688
+ (0, createAdapter_1.registerOpenCodeServerUpBudgetMs)(ENV.testTimings.openCodeServerUpBudgetMs);
11492
11689
  // The usage cache: a first poll now (off the boot path), then the timer.
11493
11690
  claudeUsageRefresher.start();
11494
11691
  void claudeUsageRefresher.refreshAll();
@@ -11542,6 +11739,8 @@ async function startBot() {
11542
11739
  return result.ok ? (result.alertRef ?? null) : null;
11543
11740
  },
11544
11741
  backstopMs: (0, wakeUpRules_1.getRequestBackstopMs)(process.env.REQUEST_BACKSTOP_MINUTES),
11742
+ sweepIntervalMs: ENV.testTimings.requestSweepIntervalMs,
11743
+ firstPostRetryDelayMs: ENV.testTimings.requestFirstPostRetryDelayMs,
11545
11744
  // Only a Telegram topic in the answers-only view hides the stream from someone who expects to see the work.
11546
11745
  checkIsAnswerTailWatched: (key) => (0, sessionKeyCodec_1.checkIsTelegramKey)(key) && !(0, topicView_1.checkIsStreamShown)(state.getDisplayPrefs(key).view),
11547
11746
  remindAnswerTail,
@@ -11565,6 +11764,9 @@ async function startBot() {
11565
11764
  const bootMode = (0, bootClassifier_1.classifyBoot)(downtimeMs);
11566
11765
  console.log(`Boot mode: ${bootMode.isHotReload ? 'HOT RELOAD' : 'COLD START'} ` +
11567
11766
  `(downtime=${downtimeMs === null ? 'unknown' : `${downtimeMs}ms`})`);
11767
+ const nativeWindowsConsoleLine = (0, nativeWindows_1.getNativeWindowsConsoleLine)(process.platform, i18n_1.t);
11768
+ if (nativeWindowsConsoleLine)
11769
+ console.warn(nativeWindowsConsoleLine);
11568
11770
  console.log(`Chat mode: ${getChatMode()}`);
11569
11771
  // In `both`, the DM surface only lights up when an OWNER_USER_ID is set;
11570
11772
  // otherwise the instance serves the group only. Surface that explicitly so an
@@ -11626,10 +11828,19 @@ async function startBot() {
11626
11828
  onApiError: (key, error) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'apiError', () => withThreadLocale(key, () => handleApiError(key, error))),
11627
11829
  onNoResponse: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'noResponse', () => handleNoResponse(key)),
11628
11830
  onQuestionGone: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'questionGone', () => withThreadLocale(key, () => handleQuestionGone(key))),
11629
- onClosed: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'closed', () => withThreadLocale(key, () => handleAgentClosed(key)), () => clearForeignSessionState(key)),
11630
- onStarted: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'started', () => withThreadLocale(key, () => handleAgentStarted(key))),
11831
+ onClosed: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'closed', () => withThreadLocale(key, () => handleAgentClosed(key)), () => {
11832
+ const lostWork = takeLostWorkOf(key);
11833
+ clearForeignSessionState(key);
11834
+ scheduleLostWorkNote(key, lostWork, 'exited');
11835
+ }),
11836
+ // An issue's process gets the idle countdown and the lost-work note of a re-spawn: it has no banner.
11837
+ onStarted: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'started', () => withThreadLocale(key, () => handleAgentStarted(key)), () => {
11838
+ rearmThreadIdleTimer(key);
11839
+ if (!lostWorkFoldingKeys.has((0, sessionKey_1.keyToString)(key)))
11840
+ scheduleLostWorkNote(key, takeLostWorkOf(key), 'restarted');
11841
+ }),
11631
11842
  onStopped: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'stopped', () => withThreadLocale(key, () => handleAgentStopped(key)), () => clearForeignSessionState(key)),
11632
- onSuspended: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'suspended', () => withThreadLocale(key, () => handleAgentSuspended(key))),
11843
+ onSuspended: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'suspended', () => withThreadLocale(key, () => handleAgentSuspended(key)), () => scheduleLostWorkNote(key, takeLostWorkOf(key), 'holdExpired')),
11633
11844
  onTurnEnded: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'turnEnded', () => handleAgentTurnEnded(key)),
11634
11845
  onContextCompacted: (key, trigger) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'contextCompacted', () => noteConversationContextReset(key, `compaction (${trigger ?? 'bot'})`)),
11635
11846
  onConversationLost: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'conversationLost', () => withThreadLocale(key, () => handleConversationLost(key)), () => { dropLostConversationWatermark(key); noteConversationContextReset(key, 'its transcript was lost'); }),
@@ -12073,6 +12284,12 @@ async function startBot() {
12073
12284
  catch (e) {
12074
12285
  console.warn('[startup-status] failed:', e instanceof Error ? e.message : e);
12075
12286
  }
12287
+ try {
12288
+ await postNativeWindowsNoticeToGeneral(bootMode.isHotReload);
12289
+ }
12290
+ catch (e) {
12291
+ console.warn('[platform] native Windows notice failed:', e instanceof Error ? e.message : e);
12292
+ }
12076
12293
  await startUpdates();
12077
12294
  // The local speech engine: clean up after an earlier run, re-attach to / start its install. Starts no process.
12078
12295
  void bootSttLocalEngine().catch((e) => console.warn('[STT] boot step failed:', e instanceof Error ? e.message : e));