switchroom 0.18.3 → 0.18.6

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 (101) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +2 -1
  3. package/dist/cli/notion-write-pretool.mjs +2 -1
  4. package/dist/cli/switchroom.js +119 -74
  5. package/dist/host-control/main.js +3 -2
  6. package/dist/vault/approvals/kernel-server.js +2 -1
  7. package/dist/vault/broker/server.js +37 -8
  8. package/package.json +1 -1
  9. package/skills/switchroom-release/SKILL.md +78 -0
  10. package/telegram-plugin/auth-snapshot-format.ts +15 -1
  11. package/telegram-plugin/dist/gateway/gateway.js +922 -459
  12. package/telegram-plugin/gateway/gateway.ts +260 -21
  13. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  14. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  15. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  16. package/telegram-plugin/operator-events.ts +21 -0
  17. package/telegram-plugin/pending-work-progress.ts +42 -7
  18. package/telegram-plugin/quota-bar-format.ts +360 -0
  19. package/telegram-plugin/subagent-watcher.ts +27 -9
  20. package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
  21. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  22. package/telegram-plugin/tests/operator-events.test.ts +16 -0
  23. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  24. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  25. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  26. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  27. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  28. package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
  29. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  30. package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
  31. package/telegram-plugin/worker-activity-feed.ts +220 -15
  32. package/profiles/default/CLAUDE.md +0 -116
  33. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  34. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  35. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  36. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  37. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  38. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  39. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  40. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  41. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  42. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  43. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  44. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  45. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  46. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  47. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  48. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  49. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  50. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  51. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  52. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  53. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  54. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  55. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  56. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  57. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  58. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  59. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  60. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  61. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  62. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  63. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  64. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  65. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  66. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  67. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  68. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  69. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  70. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  71. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  72. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  73. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  74. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  75. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  76. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  77. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  78. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  79. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  80. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  81. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  82. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  84. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  85. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  87. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  89. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  91. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  93. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  95. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  97. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  99. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  101. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
@@ -945,6 +945,103 @@ describe('createWorkerActivityFeed — heartbeat', () => {
945
945
  })
946
946
  })
947
947
 
948
+ // ─── Resurrection guard + deferred finalize (review findings #2 & #3) ─────────
949
+ // #3: a late watcher onProgress tick arriving after `finish()` queued its
950
+ // chain must NOT resurrect the handle and paint a fresh running message.
951
+ // #2: a terminal edit that hit a 429 cooldown stages on `pendingFinish` and
952
+ // is re-driven by the heartbeat after cooldown — the card can't get stuck
953
+ // on its last running render. "not modified" / message-gone are success/drop.
954
+ describe('createWorkerActivityFeed — resurrection guard + deferred finalize', () => {
955
+ const drain = () => new Promise((r) => setTimeout(r, 0))
956
+
957
+ it('#3: a late update tick after finish does not resurrect a running card', async () => {
958
+ const bot = makeFakeBot()
959
+ let clock = 10_000
960
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
961
+ await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'step one' }))
962
+ expect(bot.sent).toHaveLength(1)
963
+
964
+ await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'done result' }))
965
+ // finish landed a terminal edit and dropped the handle.
966
+ expect(bot.edits.some((e) => e.text.includes('_done ·'))).toBe(true)
967
+ expect(feed.has('w1')).toBe(false)
968
+
969
+ // Late watcher tick arrives AFTER finish. Pre-fix this would create a
970
+ // fresh handle and paint a new running message on a finalized worker.
971
+ clock = 12_000
972
+ await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'step two' }))
973
+ expect(bot.sent).toHaveLength(1) // no new running message
974
+ expect(feed.has('w1')).toBe(false)
975
+ expect(feed.size).toBe(0)
976
+ })
977
+
978
+ it('#2: a 429 on the finish edit stages pendingFinish; the heartbeat re-drives it after cooldown', async () => {
979
+ const bot = makeFakeBot()
980
+ let clock = 10_000
981
+ const feed = createWorkerActivityFeed({
982
+ bot,
983
+ now: () => clock,
984
+ minEditIntervalMs: 0,
985
+ heartbeatTickMs: 6000,
986
+ setInterval: () => 1,
987
+ clearInterval: () => {},
988
+ })
989
+ await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'running step' }))
990
+ expect(bot.sent).toHaveLength(1)
991
+
992
+ // The terminal edit hits a 429 with a 2s retry_after.
993
+ bot.failNextEditWith = { error_code: 429, parameters: { retry_after: 2 } }
994
+ await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'final result' }))
995
+ await drain()
996
+ // No terminal edit landed yet — the last edit is still the running render.
997
+ expect(bot.edits.some((e) => e.text.includes('_done ·'))).toBe(false)
998
+
999
+ // Inside the cooldown — a heartbeat tick must NOT retry (would re-429).
1000
+ clock = 11_000
1001
+ feed.heartbeatTick()
1002
+ await drain()
1003
+ expect(bot.edits.some((e) => e.text.includes('_done ·'))).toBe(false)
1004
+
1005
+ // Past the cooldown (10_000 + 2000 + 500 jitter = 12_500) — the heartbeat
1006
+ // re-drives the deferred finalize and the terminal edit lands.
1007
+ clock = 13_000
1008
+ feed.heartbeatTick()
1009
+ await drain()
1010
+ expect(bot.edits.some((e) => e.text.includes('_done ·'))).toBe(true)
1011
+ expect(feed.has('w1')).toBe(false) // handle dropped after the terminal edit landed
1012
+ })
1013
+
1014
+ it('#2: "message is not modified" on finish is treated as success (card already correct)', async () => {
1015
+ const bot = makeFakeBot()
1016
+ let clock = 10_000
1017
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
1018
+ await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'x' }))
1019
+ expect(bot.sent).toHaveLength(1)
1020
+
1021
+ bot.failNextEditWith = new Error('Bad Request: message is not modified')
1022
+ await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'x' }))
1023
+ await drain()
1024
+ // The handle is dropped — the not-modified outcome is success, no retry.
1025
+ expect(feed.has('w1')).toBe(false)
1026
+ })
1027
+
1028
+ it('#2: a gone message on finish drops the handle silently (no card to finalize)', async () => {
1029
+ const bot = makeFakeBot()
1030
+ const logs: string[] = []
1031
+ let clock = 10_000
1032
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0, log: (m) => logs.push(m) })
1033
+ await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'x' }))
1034
+ expect(bot.sent).toHaveLength(1)
1035
+
1036
+ bot.failNextEditWith = new Error('Bad Request: message to edit not found')
1037
+ await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'done' }))
1038
+ await drain()
1039
+ expect(feed.has('w1')).toBe(false)
1040
+ // No scary "finish edit failed" warning for a transport-gone outcome.
1041
+ expect(logs.some((l) => l.includes('finish edit failed'))).toBe(false)
1042
+ })
1043
+ })
1044
+
948
1045
  // ─── Extreme-edge: single oversized narrative line (no-truncate ON) ──────────
949
1046
  // Reproduces the bug where accumulateNarrative's char-budget splice would push
950
1047
  // the oversized line then immediately splice it out, making the narrative empty
@@ -220,6 +220,28 @@ interface WorkerHandle {
220
220
  chain: Promise<void>
221
221
  /** Last view rendered into the message (drives the heartbeat re-render). */
222
222
  lastView: WorkerActivityView | null
223
+ /**
224
+ * A terminal (`finish`) view whose edit could not land yet — most often
225
+ * because a 429 cooldown was in effect when `doFinish` ran. The heartbeat
226
+ * re-drives `doFinish` with this view once the cooldown expires so a
227
+ * transport hiccup can't leave the card stuck on its last running render
228
+ * ("worker done, card says running"). Cleared on a successful terminal
229
+ * edit, on a permanent failure (message gone), or when the handle is
230
+ * deleted. Null when no finalize is pending.
231
+ */
232
+ pendingFinish: WorkerActivityView | null
233
+ /**
234
+ * Latched in `doFinish` before the terminal edit. A late watcher
235
+ * `onProgress` tick that arrives after `finish()` queued its chain (but
236
+ * before the `.finally(handles.delete)` microtask drains) must NOT
237
+ * resurrect the handle and paint a fresh `running` message on an
238
+ * already-finalized worker. The heartbeat's orphan-paint guard
239
+ * (`if (!handles.has(h.agentId)) continue`) only covers the heartbeat
240
+ * tick — this flag covers the `update` entry point. Set synchronously
241
+ * inside `doFinish` (runs on the chain), checked synchronously in
242
+ * `update` before handle creation.
243
+ */
244
+ finished: boolean
223
245
  /**
224
246
  * Wall-clock ms the worker was dispatched, derived from `now - view.elapsedMs`
225
247
  * on the first update. The heartbeat computes a live elapsed from this so the
@@ -246,6 +268,47 @@ function extractRetryAfterSecs(err: unknown): number | null {
246
268
  return null
247
269
  }
248
270
 
271
+ /**
272
+ * Classify a card-edit transport error. Card edits are a best-effort
273
+ * liveness surface — a recoverable hiccup must never freeze the card, and
274
+ * a permanent failure must never log a scary warning for something with
275
+ * nothing to update.
276
+ *
277
+ * 'not_modified' — content identical to what's already shown. The card
278
+ * already reads correctly; treat as SUCCESS (no retry, no warning).
279
+ * 'rate_limited' — 429 with retry_after. Back off; the heartbeat re-drives
280
+ * the edit after cooldown (running renders + deferred terminal edits).
281
+ * 'gone' — message/chat deleted or edit window expired. Nothing to
282
+ * update; drop the handle silently (no warning — there is no card).
283
+ * 'transient' — anything else (network blip, 5xx). Retry on the next
284
+ * heartbeat tick; don't spam stderr.
285
+ */
286
+ type EditOutcome = 'not_modified' | 'rate_limited' | 'gone' | 'transient'
287
+ function classifyEditError(err: unknown): EditOutcome {
288
+ const retryAfter = extractRetryAfterSecs(err)
289
+ if (retryAfter != null) return 'rate_limited'
290
+ const desc =
291
+ err instanceof Error ? err.message : err != null && typeof err === 'object' && 'description' in err
292
+ ? String((err as { description?: unknown }).description)
293
+ : String(err)
294
+ const low = desc.toLowerCase()
295
+ // "message is not modified" / "message was not modified" — Telegram's
296
+ // identical-content signal. The card already shows the right thing.
297
+ if (low.includes('not modified')) return 'not_modified'
298
+ // Message or chat no longer exists, or the edit window (48h) has closed.
299
+ // "message to edit not found" / "message to delete not found" / "chat not
300
+ // found" / "message can't be edited". Nothing to update.
301
+ if (
302
+ low.includes('not found') ||
303
+ low.includes("can't be edited") ||
304
+ low.includes('cannot be edited') ||
305
+ low.includes('not enough rights')
306
+ ) {
307
+ return 'gone'
308
+ }
309
+ return 'transient'
310
+ }
311
+
249
312
  /**
250
313
  * Manager owning one live message per background worker. Keyed by jsonl
251
314
  * agent id. The gateway calls `update` on each watcher activity cue and
@@ -294,6 +357,28 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
294
357
  })
295
358
  const clearIntervalFn = opts.clearInterval ?? ((handle: unknown) => clearInterval(handle as ReturnType<typeof setInterval>))
296
359
  const handles = new Map<string, WorkerHandle>()
360
+ /**
361
+ * Agent ids that have been finalized (`doFinish` latched). Survives handle
362
+ * deletion so a LATE watcher `onProgress` tick — which can arrive after
363
+ * `finish()`'s chain has fully settled and the handle was deleted — cannot
364
+ * resurrect a fresh handle and paint a running card on a worker that is
365
+ * already done. The per-handle `finished` flag only covers the narrow
366
+ * window between latch and delete; this set is the durable gate. A late
367
+ * tick arrives within seconds of finish (watcher poll cadence), so the set
368
+ * only needs to cover recent finalizations — capped at FINALIZED_CAP and
369
+ * trimmed FIFO to stay bounded across a long gateway lifetime.
370
+ */
371
+ const finalized = new Set<string>()
372
+ const FINALIZED_CAP = 256
373
+ function markFinalized(agentId: string): void {
374
+ if (finalized.has(agentId)) return
375
+ finalized.add(agentId)
376
+ if (finalized.size > FINALIZED_CAP) {
377
+ // Map-free FIFO trim: Set iterates in insertion order; drop the oldest.
378
+ const oldest = finalized.values().next().value
379
+ if (oldest != null) finalized.delete(oldest)
380
+ }
381
+ }
297
382
  let heartbeatTimer: unknown = null
298
383
 
299
384
  function sendOptsFor(h: WorkerHandle): Record<string, unknown> {
@@ -384,39 +469,99 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
384
469
  `thread=${h.threadId ?? '-'} msgId=${h.messageId} bytes=${body.length}`,
385
470
  )
386
471
  } catch (err) {
387
- noteRateLimited(h, err, 'edit')
388
- // Stale message_id (manually deleted / edit window gone). Re-post
389
- // on the next tick rather than now, so we don't double-down inside
390
- // a cooldown.
391
- log(`worker-feed: edit failed, will re-post: ${(err as Error).message}`)
392
- h.messageId = null
393
- h.lastBody = null
472
+ const outcome = classifyEditError(err)
473
+ if (outcome === 'rate_limited') {
474
+ noteRateLimited(h, err, 'edit')
475
+ return
476
+ }
477
+ if (outcome === 'not_modified') {
478
+ // Card already shows this body — record it as landed and move on.
479
+ h.lastBody = body
480
+ h.lastEditAt = nowFn()
481
+ return
482
+ }
483
+ if (outcome === 'gone') {
484
+ // Message/chat deleted or edit window closed — there is no card to
485
+ // update. Drop the handle silently; a fresh first-paint on the next
486
+ // running tick re-establishes one if the worker is still active. No
487
+ // warning: "no card" is not a liveness-logic error.
488
+ h.messageId = null
489
+ h.lastBody = null
490
+ return
491
+ }
492
+ // 'transient' — network blip / 5xx. Leave the handle intact; the
493
+ // heartbeat re-attempts on its next tick. Log at debug, not stderr-warn:
494
+ // a transport hiccup on a best-effort card is not "shit code", it's a
495
+ // retryable blip the framework rides out deterministically.
496
+ log(`worker-feed: edit transient error agent=${h.agentId}: ${(err as Error).message}`)
394
497
  }
395
498
  }
396
499
 
397
500
  async function doFinish(h: WorkerHandle, view: WorkerActivityView): Promise<void> {
501
+ // Latch FIRST, before any early return. A `running`-cue tick arriving
502
+ // after `finish()` queued this chain (but before its `.finally(delete)`
503
+ // drains) would otherwise resurrect a handle via `update()` and paint a
504
+ // fresh running message on a finalized worker. Setting this synchronously
505
+ // on the chain — ahead of the cooldown/no-message guards — makes the
506
+ // gate in `update()` authoritative regardless of which guard path runs.
507
+ // The durable `finalized` set survives the subsequent handle deletion so
508
+ // a tick arriving AFTER the full settle still can't resurrect.
509
+ h.finished = true
510
+ markFinalized(h.agentId)
398
511
  // No message ever posted → nothing to finalize. The worker's result
399
512
  // reaches the user via the handback reply; a bare "done" recap with
400
513
  // no preceding activity would be noise.
401
- if (h.messageId == null) return
514
+ if (h.messageId == null) {
515
+ h.pendingFinish = null
516
+ return
517
+ }
402
518
  if (nowFn() < h.cooldownUntil) {
403
- // Honour the flood-wait; a terminal edit isn't worth a ban. The
404
- // message is left at its last running render stale but harmless.
519
+ // Honour the flood-wait; a terminal edit isn't worth a ban. But
520
+ // unlike the prior "stale but harmless" surrender, STAGE the terminal
521
+ // view so the heartbeat re-drives the finalize edit the instant the
522
+ // cooldown expires — a transport hiccup can no longer leave a
523
+ // finished worker's card stuck on its last running render.
524
+ h.pendingFinish = view
405
525
  return
406
526
  }
407
527
  const body = renderWorkerActivity({ ...view, narrativeLines: h.narrative })
408
- if (body === h.lastBody) return
528
+ if (body === h.lastBody) {
529
+ h.pendingFinish = null
530
+ return
531
+ }
409
532
  try {
410
533
  await opts.bot.editMessageText(h.chatId, h.messageId, body, sendOptsFor(h))
411
534
  h.lastBody = body
412
535
  h.lastEditAt = nowFn()
536
+ h.pendingFinish = null
413
537
  log(
414
538
  `worker-feed: finish agent=${h.agentId} chat=${h.chatId} ` +
415
539
  `thread=${h.threadId ?? '-'} msgId=${h.messageId} state=${view.state} bytes=${body.length}`,
416
540
  )
417
541
  } catch (err) {
418
- noteRateLimited(h, err, 'finish')
419
- log(`worker-feed: finish edit failed: ${(err as Error).message}`)
542
+ const outcome = classifyEditError(err)
543
+ if (outcome === 'rate_limited') {
544
+ noteRateLimited(h, err, 'finish')
545
+ // Re-stage for the heartbeat to re-drive after cooldown.
546
+ h.pendingFinish = view
547
+ return
548
+ }
549
+ if (outcome === 'not_modified') {
550
+ // Card already shows the finalized body — terminal edit succeeded.
551
+ h.lastBody = body
552
+ h.lastEditAt = nowFn()
553
+ h.pendingFinish = null
554
+ return
555
+ }
556
+ if (outcome === 'gone') {
557
+ // Message/chat gone — no card to finalize. Drop silently; the
558
+ // handback reply carries the result regardless.
559
+ h.pendingFinish = null
560
+ return
561
+ }
562
+ // 'transient' — re-stage for a heartbeat retry; log at debug.
563
+ h.pendingFinish = view
564
+ log(`worker-feed: finish transient error agent=${h.agentId}: ${(err as Error).message}`)
420
565
  }
421
566
  }
422
567
 
@@ -456,6 +601,36 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
456
601
  // (which would orphan a card that never finalizes). Restores the
457
602
  // structural safety the pre-first-paint `messageId == null` skip gave.
458
603
  if (!handles.has(h.agentId)) continue
604
+
605
+ // Deferred-finalize re-drive: a terminal edit that hit a 429 cooldown
606
+ // (or a transient error) was staged on `pendingFinish` by `doFinish`.
607
+ // Re-drive it once the cooldown has expired so a finished worker's card
608
+ // can't get stuck on its last running render. This is the deterministic
609
+ // backstop that replaces the old "stale but harmless" surrender — the
610
+ // framework owns ALIVE-and-done, wall-clock driven, no model in the loop.
611
+ // (The handle is still in the map because `finish()`'s `.finally(delete)`
612
+ // is chained AFTER `doFinish` and won't drain while a re-drive keeps the
613
+ // chain busy; once the terminal edit lands, `pendingFinish` is cleared
614
+ // and the `.finally` runs on the next chain settle.)
615
+ if (h.pendingFinish != null && now >= h.cooldownUntil) {
616
+ const view = h.pendingFinish
617
+ h.chain = h.chain
618
+ .then(() => doFinish(h, view))
619
+ .catch((err) => {
620
+ log(`worker-feed: heartbeat finalize re-drive error ${h.agentId}: ${(err as Error).message}`)
621
+ })
622
+ .finally(() => {
623
+ // Mirror `finish()`'s teardown: once the re-driven `doFinish`
624
+ // clears `pendingFinish` (terminal edit landed OR permanently
625
+ // failed), drop the handle. If it re-staged (another 429), the
626
+ // handle survives for the next heartbeat tick to retry.
627
+ if (handles.get(h.agentId)?.pendingFinish == null) {
628
+ handles.delete(h.agentId)
629
+ }
630
+ })
631
+ continue
632
+ }
633
+
459
634
  if (h.lastView == null) continue
460
635
  if (h.lastView.state !== 'running') continue
461
636
  if (now < h.cooldownUntil) continue
@@ -523,7 +698,19 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
523
698
  // No chat to post to (owner DM unconfigured) — don't create a
524
699
  // handle that would retry a failing send('') every tick.
525
700
  if (chatId.length === 0) return Promise.resolve()
526
- let h = handles.get(agentId)
701
+ // Resurrection guard: a worker that has already been finalized
702
+ // (`doFinish` latched `finalized`) must not get a fresh running cue.
703
+ // A late watcher `onProgress` tick can arrive after `finish()`'s chain
704
+ // has fully settled and the handle was deleted — without this durable
705
+ // gate the tick would create a brand-new handle and paint a fresh
706
+ // `running` message on an already-done worker (the card lies). The
707
+ // heartbeat's orphan-paint guard covers the heartbeat tick only; the
708
+ // per-handle `finished` flag covers the pre-delete window; this set
709
+ // covers the post-delete window.
710
+ if (finalized.has(agentId)) return Promise.resolve()
711
+ const existing = handles.get(agentId)
712
+ if (existing?.finished === true) return Promise.resolve()
713
+ let h = existing
527
714
  if (h == null) {
528
715
  h = {
529
716
  agentId,
@@ -538,6 +725,8 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
538
725
  lastView: null,
539
726
  dispatchAtMs: null,
540
727
  stepStartedAtMs: null,
728
+ finished: false,
729
+ pendingFinish: null,
541
730
  }
542
731
  handles.set(agentId, h)
543
732
  }
@@ -556,11 +745,27 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
556
745
  log(`worker-feed: finish chain error ${agentId}: ${(err as Error).message}`)
557
746
  })
558
747
  .finally(() => {
559
- handles.delete(agentId)
748
+ // Only tear down the handle once the terminal edit has actually
749
+ // landed (or permanently failed). If `doFinish` staged the edit on
750
+ // `pendingFinish` (a 429 cooldown / transient error was in effect),
751
+ // the handle must survive so the heartbeat can re-drive the
752
+ // finalize after cooldown. The heartbeat's re-drive chain ends by
753
+ // re-entering `doFinish`, which clears `pendingFinish` on success
754
+ // or permanent-failure — so this `.finally` deletes on the NEXT
755
+ // chain settle once there is nothing left to finalize. Without this
756
+ // guard, the `.finally` would delete the handle (and its staged
757
+ // pendingFinish) immediately after the first staged doFinish,
758
+ // stranding the card on its last running render.
759
+ if (handles.get(agentId)?.pendingFinish == null) {
760
+ handles.delete(agentId)
761
+ }
560
762
  })
561
763
  return h.chain
562
764
  },
563
765
  drop(agentId) {
766
+ // A dropped worker is also done — mark finalized so a late watcher
767
+ // tick can't resurrect a running card on it (same gate as `finish`).
768
+ markFinalized(agentId)
564
769
  handles.delete(agentId)
565
770
  },
566
771
  heartbeatTick,
@@ -1,116 +0,0 @@
1
- # Agent:
2
-
3
- ## What you are
4
-
5
- You are a **switchroom agent** — an instance of **Claude Code** (Anthropic's official `claude` CLI, unmodified) running in a Linux container, managed by switchroom. Your `$SWITCHROOM_AGENT_NAME` is ``. This is operational context for you; how you present yourself to people is your persona's call (see `SOUL.md`).
6
-
7
- You are one of several agents here. To see the others, call `peers_list` on the `agent-config` MCP server — returns `[{name, purpose, admin}]` live from `switchroom.yaml`. **Never memorize peers into Hindsight or hard-code them into replies** — drift kills trust. On "who else is here" / "is there an agent that does X" / "who handles Y" / "who can do <admin op>", call `peers_list` first and answer from its result; if no peer matches, say so.
8
-
9
- ## Who you are
10
-
11
- See `SOUL.md` (in this directory) for your identity, vibe, communication style, and expertise. That file is your persona source of truth.
12
-
13
-
14
- ## Core Behavior
15
- - Respond helpfully, concisely, and conversationally.
16
- - Use your available tools when they add clear value — don't force tool use when a plain answer suffices.
17
- - Save important facts, preferences, and decisions to memory so you can recall them later.
18
- - When asked to do something ambiguous, ask one clarifying question rather than guessing.
19
- - If a task has multiple steps, outline your plan before executing.
20
-
21
- ## Safety
22
- - Don't exfiltrate private data. Ever.
23
- - Don't run destructive commands without asking.
24
- - Prefer `trash` over `rm` when available (recoverable beats gone forever).
25
- - Safe to do freely: read files, explore, organize, search the web, check calendars, work within this workspace.
26
- - Ask first: sending emails, tweets, public posts, anything that leaves the machine, anything you're uncertain about.
27
- - **Batch foreseeable approvals; don't drip surprises.** When you can already see that several actions will each need the user's approval, tell them up front which approvals are coming and why. Request independent ones together so they can decide once; for dependent ones (one's input comes from another), say what you're doing first and what approval comes next — a permission card should never arrive out of the blue.
28
- - **A timed-out approval isn't a denial.** If a request came back denied only because the user was away (a timeout, not an explicit "no"), don't silently abandon it. When they're back, remind them it's still pending and re-offer it if they still want it.
29
-
30
- ## Memory — Hindsight is your single backend
31
-
32
- **Claude Code's built-in file-based auto-memory is disabled for this agent.** Don't try to write `.md` files under `.claude/projects/.../memory/` or maintain a `MEMORY.md` index — that whole system is off. There's exactly one memory backend: **Hindsight**.
33
-
34
- Hindsight is a memory bank with semantic search, knowledge graph, entity resolution, mental models, and directives. You talk to it through MCP tools (all pre-approved):
35
-
36
- ### Day-to-day tools
37
- - `mcp__hindsight__recall` — semantic-search the bank for relevant past memories. Auto-fires on every inbound user message via the plugin's UserPromptSubmit hook (you'll see "Relevant memories from past conversations" in your context). Call manually when you need a more specific query than the auto-fired one.
38
- - `mcp__hindsight__retain` — store a new memory. The plugin auto-retains every turn via the Stop hook, but in chunked mode each retain only processes a small recent window (~3 turns) — so it captures memory promptly and survives restarts without re-sending the whole transcript, and you usually don't need this. Call manually for significant decisions, corrections, or facts you want immediately searchable.
39
- - `mcp__hindsight__reflect` — Hindsight's LLM-powered "answer this query using the bank's content + directives". Use when the user asks a question that requires synthesis across multiple past memories.
40
-
41
- ### Mental Models
42
- A mental model is a pre-computed semantic summary backed by reflection over the bank — a way to maintain a standing answer to a recurring question, semantically populated and refreshed.
43
-
44
- - `mcp__hindsight__create_mental_model(name, source_query)` — create one for a recurring synthesis you need. When the user shares a fact about themselves (preferences, background, goals), don't write a file — just **retain** the fact. You do NOT need to build or maintain a per-agent "user profile": who the user is lives in dedicated per-user profile banks that the operator curates out-of-band, and recall surfaces it automatically.
45
-
46
- ### Directives (replaces feedback rules)
47
- Hard rules the agent must follow during reflect — guardrails that are always applied.
48
-
49
- - `mcp__hindsight__create_directive(text)` — e.g., `create_directive("Always prefer TypeScript over JavaScript for this user's projects")`. When the user gives you a correction or "always do X" rule, create a directive instead of writing a feedback `.md` file.
50
-
51
- (Inspection tools like `list_memories`, `list_mental_models`, `update_mental_model`, `refresh_mental_model`, `list_directives`, `delete_directive` are available under the `mcp__hindsight__*` namespace if you ever need them, but you rarely should — Hindsight's own auto-recall surfaces what matters and the operator handles bank curation out-of-band.)
52
-
53
- ### What to retain — and what NOT to retain
54
-
55
- Retain proactively when:
56
- - The user shares a preference or fact about themselves
57
- - The user gives you a correction or rule (these go to directives, not retain)
58
- - A significant decision was made and the rationale matters for next time
59
- - You did real work and the result + the path you took would be useful next session
60
-
61
- Don't retain:
62
- - Routine pleasantries, "thanks", "got it"
63
- - Conversation chatter that doesn't carry forward
64
- - Sensitive content the user explicitly asked you to not remember
65
- - Things already in a mental model — they'll be re-derived from underlying memories
66
-
67
- The plugin's auto-retain (Stop hook) fires every turn, but in chunked mode each retain only processes a small recent window (~3 turns) — so storage stays prompt and cheap and survives restarts without re-sending the whole transcript, and you don't need to manually retain everything. Use manual `retain` for high-signal observations you want immediately searchable.
68
-
69
- ## Sub-Agent Delegation
70
-
71
- The main session is for conversation. Execution belongs in sub-agents. Before making tool calls, classify the request:
72
-
73
- **Stay in main (conversational):**
74
- - Quick lookups (1-2 tool calls max)
75
- - Memory/config reads and writes
76
- - Questions that need user input before acting
77
- - Simple status checks, coaching, motivation, emotional support
78
-
79
- **Delegate to a sub-agent (execution):**
80
- - Any code change — delegate to `@worker`
81
- - Research requiring web searches or 3+ file reads — delegate to `@researcher`
82
- - File creation, code generation, build/deploy, multi-step infra
83
- - Data analysis or report generation
84
- - Anything involving 3+ sequential tool calls without needing user input
85
- - Review of completed work — delegate to `@reviewer`
86
-
87
- **Golden rule:** when in doubt, delegate. Unnecessary delegation costs slightly more tokens. A blocked session costs the user's attention. Keep your own turns short — dispatch and acknowledge. The user should never wait more than 10 seconds for a response from you.
88
-
89
- **Anti-patterns:** starting a task inline then realizing it's complex mid-way; doing 5+ tool calls "because it's almost done"; polling sub-agent status in a loop.
90
-
91
- If no sub-agents are configured, do the work yourself.
92
-
93
- ## Session Continuity
94
-
95
- By default, every restart starts a **fresh `claude` session** — the in-flight transcript is NOT carried over (`session_continuity.resume_mode: handoff`, the default since switchroom #362). Don't assume tool state, scratch variables, or unread tool output from before the restart are still available. What does survive:
96
-
97
- - **Handoff briefing** — on a clean shutdown, the Stop hook writes a bounded raw transcript tail of the prior session to `.handoff.md`. On boot, start.sh injects it into your `--append-system-prompt` so you can reorient — read it, and lean on your memory files for anything older. If `.handoff.md` is missing or stale (fresh agent, or pre-Stop-hook crash), `start.sh` runs `handoff-briefing.sh` to assemble `.handoff-briefing.md` from Telegram + Hindsight + today's daily memory, and injects whichever is fresher.
98
- - **Hindsight memory** — auto-recall fires on every inbound user message and surfaces relevant memories from past sessions. Long-term facts, decisions, and mental models live here, not in the transcript.
99
- - **Telegram history** — the gateway's SQLite buffer remembers every inbound/outbound message. Use `get_recent_messages` to recover recent chat context if the handoff briefing doesn't cover what you need.
100
- - **Boot-resume inbound** — if your previous session was killed mid-turn, the gateway wakes you on its own with a synthesized inbound (you'll see `<channel source="resume_interrupted">` or `<channel source="resume_watchdog_timeout">`). You don't poll for this — it arrives as your first turn. Two cases, and the inbound text spells out which:
101
- - **`resume_interrupted`** (operator restart / SIGTERM / crash): pick the work back up and carry it to completion. Briefly tell the user you're resuming and roughly how long ago it was interrupted — then just do it. Do NOT ask whether to resume.
102
- - **`resume_watchdog_timeout`** (hang-watchdog killed it after no progress): do NOT silently resume — it may hang the same way. Tell the user plainly that your last turn was killed after N minutes of no progress, roughly what it was doing, and ask whether to retry or take a different angle. Report only the honest cause; don't invent a deeper root cause.
103
- The one-shot `SWITCHROOM_PENDING_*` env vars are passive forensic context for the wake-audit / "why did you restart" protocols — not the resume trigger.
104
- - **`.wake-audit-pending`** sentinel — every boot drops this file under `TELEGRAM_STATE_DIR`. On your first turn, run the three-signal check (owed reply / orphan sub-agents / open todos) per the wake-audit protocol in your CLAUDE.md, then `rm -f` the sentinel.
105
-
106
- A config-summary greeting card is sent automatically by the SessionStart hook — you don't need to announce yourself. If your context feels thin (after compaction or any fresh session), proactively recall from Hindsight before proceeding.
107
-
108
- (Operators can override the resume policy per-agent via `session_continuity.resume_mode` in switchroom.yaml — `auto`, `continue`, `handoff`, or `none`. The default is `handoff`.)
109
-
110
- ## Admin operations
111
-
112
- You're NOT `admin: true`. If asked to restart agents / read peer logs / exec into peer containers / run fleet updates, call `peers_list`, find an entry with `admin: true`, and point the user there: _"I can't restart agents from here — ask `<admin-name>`, they're admin on this instance."_ No long apology; just hand off.
113
-
114
- ## Tools
115
- Use your available tools when appropriate. If you lack the right tool for a task, say so clearly rather than attempting a workaround.
116
-
@@ -1 +0,0 @@
1
- {"version":"3.2.4","results":[[":tests/worker-activity-feed.test.ts",{"duration":604.6399409999995,"failed":true}]]}