switchroom 0.18.3 → 0.18.7

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 (156) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +3 -1
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +386 -128
  6. package/dist/host-control/main.js +4 -2
  7. package/dist/vault/approvals/kernel-server.js +3 -1
  8. package/dist/vault/broker/server.js +38 -8
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +35 -16
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-release/SKILL.md +78 -0
  14. package/telegram-plugin/auth-snapshot-format.ts +15 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
  17. package/telegram-plugin/dist/server.js +24 -0
  18. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  19. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  20. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  21. package/telegram-plugin/gateway/gateway.ts +1331 -151
  22. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  23. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  24. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  25. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  27. package/telegram-plugin/gateway/model-command.ts +212 -51
  28. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  29. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  30. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  31. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  32. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  33. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  34. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  35. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  36. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  37. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  38. package/telegram-plugin/model-label.ts +69 -0
  39. package/telegram-plugin/operator-events.ts +45 -0
  40. package/telegram-plugin/pending-work-progress.ts +42 -7
  41. package/telegram-plugin/permission-diff.ts +128 -0
  42. package/telegram-plugin/quota-bar-format.ts +360 -0
  43. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  44. package/telegram-plugin/registry/subagents.test.ts +90 -0
  45. package/telegram-plugin/session-tail.ts +28 -0
  46. package/telegram-plugin/silent-end.ts +49 -4
  47. package/telegram-plugin/subagent-watcher.ts +249 -46
  48. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  49. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
  51. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  52. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  53. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  54. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
  55. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  56. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  57. package/telegram-plugin/tests/model-command.test.ts +202 -42
  58. package/telegram-plugin/tests/model-label.test.ts +64 -0
  59. package/telegram-plugin/tests/operator-events.test.ts +17 -0
  60. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  61. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  62. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  63. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  64. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  65. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  66. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  67. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  68. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  69. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  70. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  71. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  72. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  73. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  74. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  75. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  76. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  77. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  78. package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
  79. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  80. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  81. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  82. package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
  83. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  84. package/telegram-plugin/tool-activity-summary.ts +22 -2
  85. package/telegram-plugin/typing-wrap.ts +72 -25
  86. package/telegram-plugin/worker-activity-feed.ts +229 -15
  87. package/profiles/default/CLAUDE.md +0 -116
  88. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  89. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  97. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  99. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
@@ -70,6 +70,14 @@ export interface WorkerActivityView {
70
70
  /** Wall-clock since dispatch, ms. */
71
71
  elapsedMs: number
72
72
  state: WorkerActivityState
73
+ /**
74
+ * Live model the worker is running, as a raw resolved model id sourced from
75
+ * its transcript (`message.model`) or, before its first assistant line, the
76
+ * dispatch-time `tool_input.model` persisted on the registry row. Rendered as
77
+ * a short friendly tag on the worker card's metrics line. Omitted when
78
+ * unknown — never guessed from config.
79
+ */
80
+ model?: string
73
81
  }
74
82
 
75
83
  export interface BotApiForWorkerFeed {
@@ -133,6 +141,7 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
133
141
  elapsedMs: v.elapsedMs,
134
142
  toolCount: v.toolCount,
135
143
  state: v.state,
144
+ model: v.model,
136
145
  }
137
146
 
138
147
  // Terminal: latestSummary carries the worker's final result text (gateway
@@ -220,6 +229,28 @@ interface WorkerHandle {
220
229
  chain: Promise<void>
221
230
  /** Last view rendered into the message (drives the heartbeat re-render). */
222
231
  lastView: WorkerActivityView | null
232
+ /**
233
+ * A terminal (`finish`) view whose edit could not land yet — most often
234
+ * because a 429 cooldown was in effect when `doFinish` ran. The heartbeat
235
+ * re-drives `doFinish` with this view once the cooldown expires so a
236
+ * transport hiccup can't leave the card stuck on its last running render
237
+ * ("worker done, card says running"). Cleared on a successful terminal
238
+ * edit, on a permanent failure (message gone), or when the handle is
239
+ * deleted. Null when no finalize is pending.
240
+ */
241
+ pendingFinish: WorkerActivityView | null
242
+ /**
243
+ * Latched in `doFinish` before the terminal edit. A late watcher
244
+ * `onProgress` tick that arrives after `finish()` queued its chain (but
245
+ * before the `.finally(handles.delete)` microtask drains) must NOT
246
+ * resurrect the handle and paint a fresh `running` message on an
247
+ * already-finalized worker. The heartbeat's orphan-paint guard
248
+ * (`if (!handles.has(h.agentId)) continue`) only covers the heartbeat
249
+ * tick — this flag covers the `update` entry point. Set synchronously
250
+ * inside `doFinish` (runs on the chain), checked synchronously in
251
+ * `update` before handle creation.
252
+ */
253
+ finished: boolean
223
254
  /**
224
255
  * Wall-clock ms the worker was dispatched, derived from `now - view.elapsedMs`
225
256
  * on the first update. The heartbeat computes a live elapsed from this so the
@@ -246,6 +277,47 @@ function extractRetryAfterSecs(err: unknown): number | null {
246
277
  return null
247
278
  }
248
279
 
280
+ /**
281
+ * Classify a card-edit transport error. Card edits are a best-effort
282
+ * liveness surface — a recoverable hiccup must never freeze the card, and
283
+ * a permanent failure must never log a scary warning for something with
284
+ * nothing to update.
285
+ *
286
+ * 'not_modified' — content identical to what's already shown. The card
287
+ * already reads correctly; treat as SUCCESS (no retry, no warning).
288
+ * 'rate_limited' — 429 with retry_after. Back off; the heartbeat re-drives
289
+ * the edit after cooldown (running renders + deferred terminal edits).
290
+ * 'gone' — message/chat deleted or edit window expired. Nothing to
291
+ * update; drop the handle silently (no warning — there is no card).
292
+ * 'transient' — anything else (network blip, 5xx). Retry on the next
293
+ * heartbeat tick; don't spam stderr.
294
+ */
295
+ type EditOutcome = 'not_modified' | 'rate_limited' | 'gone' | 'transient'
296
+ function classifyEditError(err: unknown): EditOutcome {
297
+ const retryAfter = extractRetryAfterSecs(err)
298
+ if (retryAfter != null) return 'rate_limited'
299
+ const desc =
300
+ err instanceof Error ? err.message : err != null && typeof err === 'object' && 'description' in err
301
+ ? String((err as { description?: unknown }).description)
302
+ : String(err)
303
+ const low = desc.toLowerCase()
304
+ // "message is not modified" / "message was not modified" — Telegram's
305
+ // identical-content signal. The card already shows the right thing.
306
+ if (low.includes('not modified')) return 'not_modified'
307
+ // Message or chat no longer exists, or the edit window (48h) has closed.
308
+ // "message to edit not found" / "message to delete not found" / "chat not
309
+ // found" / "message can't be edited". Nothing to update.
310
+ if (
311
+ low.includes('not found') ||
312
+ low.includes("can't be edited") ||
313
+ low.includes('cannot be edited') ||
314
+ low.includes('not enough rights')
315
+ ) {
316
+ return 'gone'
317
+ }
318
+ return 'transient'
319
+ }
320
+
249
321
  /**
250
322
  * Manager owning one live message per background worker. Keyed by jsonl
251
323
  * agent id. The gateway calls `update` on each watcher activity cue and
@@ -294,6 +366,28 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
294
366
  })
295
367
  const clearIntervalFn = opts.clearInterval ?? ((handle: unknown) => clearInterval(handle as ReturnType<typeof setInterval>))
296
368
  const handles = new Map<string, WorkerHandle>()
369
+ /**
370
+ * Agent ids that have been finalized (`doFinish` latched). Survives handle
371
+ * deletion so a LATE watcher `onProgress` tick — which can arrive after
372
+ * `finish()`'s chain has fully settled and the handle was deleted — cannot
373
+ * resurrect a fresh handle and paint a running card on a worker that is
374
+ * already done. The per-handle `finished` flag only covers the narrow
375
+ * window between latch and delete; this set is the durable gate. A late
376
+ * tick arrives within seconds of finish (watcher poll cadence), so the set
377
+ * only needs to cover recent finalizations — capped at FINALIZED_CAP and
378
+ * trimmed FIFO to stay bounded across a long gateway lifetime.
379
+ */
380
+ const finalized = new Set<string>()
381
+ const FINALIZED_CAP = 256
382
+ function markFinalized(agentId: string): void {
383
+ if (finalized.has(agentId)) return
384
+ finalized.add(agentId)
385
+ if (finalized.size > FINALIZED_CAP) {
386
+ // Map-free FIFO trim: Set iterates in insertion order; drop the oldest.
387
+ const oldest = finalized.values().next().value
388
+ if (oldest != null) finalized.delete(oldest)
389
+ }
390
+ }
297
391
  let heartbeatTimer: unknown = null
298
392
 
299
393
  function sendOptsFor(h: WorkerHandle): Record<string, unknown> {
@@ -384,39 +478,99 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
384
478
  `thread=${h.threadId ?? '-'} msgId=${h.messageId} bytes=${body.length}`,
385
479
  )
386
480
  } 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
481
+ const outcome = classifyEditError(err)
482
+ if (outcome === 'rate_limited') {
483
+ noteRateLimited(h, err, 'edit')
484
+ return
485
+ }
486
+ if (outcome === 'not_modified') {
487
+ // Card already shows this body — record it as landed and move on.
488
+ h.lastBody = body
489
+ h.lastEditAt = nowFn()
490
+ return
491
+ }
492
+ if (outcome === 'gone') {
493
+ // Message/chat deleted or edit window closed — there is no card to
494
+ // update. Drop the handle silently; a fresh first-paint on the next
495
+ // running tick re-establishes one if the worker is still active. No
496
+ // warning: "no card" is not a liveness-logic error.
497
+ h.messageId = null
498
+ h.lastBody = null
499
+ return
500
+ }
501
+ // 'transient' — network blip / 5xx. Leave the handle intact; the
502
+ // heartbeat re-attempts on its next tick. Log at debug, not stderr-warn:
503
+ // a transport hiccup on a best-effort card is not "shit code", it's a
504
+ // retryable blip the framework rides out deterministically.
505
+ log(`worker-feed: edit transient error agent=${h.agentId}: ${(err as Error).message}`)
394
506
  }
395
507
  }
396
508
 
397
509
  async function doFinish(h: WorkerHandle, view: WorkerActivityView): Promise<void> {
510
+ // Latch FIRST, before any early return. A `running`-cue tick arriving
511
+ // after `finish()` queued this chain (but before its `.finally(delete)`
512
+ // drains) would otherwise resurrect a handle via `update()` and paint a
513
+ // fresh running message on a finalized worker. Setting this synchronously
514
+ // on the chain — ahead of the cooldown/no-message guards — makes the
515
+ // gate in `update()` authoritative regardless of which guard path runs.
516
+ // The durable `finalized` set survives the subsequent handle deletion so
517
+ // a tick arriving AFTER the full settle still can't resurrect.
518
+ h.finished = true
519
+ markFinalized(h.agentId)
398
520
  // No message ever posted → nothing to finalize. The worker's result
399
521
  // reaches the user via the handback reply; a bare "done" recap with
400
522
  // no preceding activity would be noise.
401
- if (h.messageId == null) return
523
+ if (h.messageId == null) {
524
+ h.pendingFinish = null
525
+ return
526
+ }
402
527
  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.
528
+ // Honour the flood-wait; a terminal edit isn't worth a ban. But
529
+ // unlike the prior "stale but harmless" surrender, STAGE the terminal
530
+ // view so the heartbeat re-drives the finalize edit the instant the
531
+ // cooldown expires — a transport hiccup can no longer leave a
532
+ // finished worker's card stuck on its last running render.
533
+ h.pendingFinish = view
405
534
  return
406
535
  }
407
536
  const body = renderWorkerActivity({ ...view, narrativeLines: h.narrative })
408
- if (body === h.lastBody) return
537
+ if (body === h.lastBody) {
538
+ h.pendingFinish = null
539
+ return
540
+ }
409
541
  try {
410
542
  await opts.bot.editMessageText(h.chatId, h.messageId, body, sendOptsFor(h))
411
543
  h.lastBody = body
412
544
  h.lastEditAt = nowFn()
545
+ h.pendingFinish = null
413
546
  log(
414
547
  `worker-feed: finish agent=${h.agentId} chat=${h.chatId} ` +
415
548
  `thread=${h.threadId ?? '-'} msgId=${h.messageId} state=${view.state} bytes=${body.length}`,
416
549
  )
417
550
  } catch (err) {
418
- noteRateLimited(h, err, 'finish')
419
- log(`worker-feed: finish edit failed: ${(err as Error).message}`)
551
+ const outcome = classifyEditError(err)
552
+ if (outcome === 'rate_limited') {
553
+ noteRateLimited(h, err, 'finish')
554
+ // Re-stage for the heartbeat to re-drive after cooldown.
555
+ h.pendingFinish = view
556
+ return
557
+ }
558
+ if (outcome === 'not_modified') {
559
+ // Card already shows the finalized body — terminal edit succeeded.
560
+ h.lastBody = body
561
+ h.lastEditAt = nowFn()
562
+ h.pendingFinish = null
563
+ return
564
+ }
565
+ if (outcome === 'gone') {
566
+ // Message/chat gone — no card to finalize. Drop silently; the
567
+ // handback reply carries the result regardless.
568
+ h.pendingFinish = null
569
+ return
570
+ }
571
+ // 'transient' — re-stage for a heartbeat retry; log at debug.
572
+ h.pendingFinish = view
573
+ log(`worker-feed: finish transient error agent=${h.agentId}: ${(err as Error).message}`)
420
574
  }
421
575
  }
422
576
 
@@ -456,6 +610,36 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
456
610
  // (which would orphan a card that never finalizes). Restores the
457
611
  // structural safety the pre-first-paint `messageId == null` skip gave.
458
612
  if (!handles.has(h.agentId)) continue
613
+
614
+ // Deferred-finalize re-drive: a terminal edit that hit a 429 cooldown
615
+ // (or a transient error) was staged on `pendingFinish` by `doFinish`.
616
+ // Re-drive it once the cooldown has expired so a finished worker's card
617
+ // can't get stuck on its last running render. This is the deterministic
618
+ // backstop that replaces the old "stale but harmless" surrender — the
619
+ // framework owns ALIVE-and-done, wall-clock driven, no model in the loop.
620
+ // (The handle is still in the map because `finish()`'s `.finally(delete)`
621
+ // is chained AFTER `doFinish` and won't drain while a re-drive keeps the
622
+ // chain busy; once the terminal edit lands, `pendingFinish` is cleared
623
+ // and the `.finally` runs on the next chain settle.)
624
+ if (h.pendingFinish != null && now >= h.cooldownUntil) {
625
+ const view = h.pendingFinish
626
+ h.chain = h.chain
627
+ .then(() => doFinish(h, view))
628
+ .catch((err) => {
629
+ log(`worker-feed: heartbeat finalize re-drive error ${h.agentId}: ${(err as Error).message}`)
630
+ })
631
+ .finally(() => {
632
+ // Mirror `finish()`'s teardown: once the re-driven `doFinish`
633
+ // clears `pendingFinish` (terminal edit landed OR permanently
634
+ // failed), drop the handle. If it re-staged (another 429), the
635
+ // handle survives for the next heartbeat tick to retry.
636
+ if (handles.get(h.agentId)?.pendingFinish == null) {
637
+ handles.delete(h.agentId)
638
+ }
639
+ })
640
+ continue
641
+ }
642
+
459
643
  if (h.lastView == null) continue
460
644
  if (h.lastView.state !== 'running') continue
461
645
  if (now < h.cooldownUntil) continue
@@ -523,7 +707,19 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
523
707
  // No chat to post to (owner DM unconfigured) — don't create a
524
708
  // handle that would retry a failing send('') every tick.
525
709
  if (chatId.length === 0) return Promise.resolve()
526
- let h = handles.get(agentId)
710
+ // Resurrection guard: a worker that has already been finalized
711
+ // (`doFinish` latched `finalized`) must not get a fresh running cue.
712
+ // A late watcher `onProgress` tick can arrive after `finish()`'s chain
713
+ // has fully settled and the handle was deleted — without this durable
714
+ // gate the tick would create a brand-new handle and paint a fresh
715
+ // `running` message on an already-done worker (the card lies). The
716
+ // heartbeat's orphan-paint guard covers the heartbeat tick only; the
717
+ // per-handle `finished` flag covers the pre-delete window; this set
718
+ // covers the post-delete window.
719
+ if (finalized.has(agentId)) return Promise.resolve()
720
+ const existing = handles.get(agentId)
721
+ if (existing?.finished === true) return Promise.resolve()
722
+ let h = existing
527
723
  if (h == null) {
528
724
  h = {
529
725
  agentId,
@@ -538,6 +734,8 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
538
734
  lastView: null,
539
735
  dispatchAtMs: null,
540
736
  stepStartedAtMs: null,
737
+ finished: false,
738
+ pendingFinish: null,
541
739
  }
542
740
  handles.set(agentId, h)
543
741
  }
@@ -556,11 +754,27 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
556
754
  log(`worker-feed: finish chain error ${agentId}: ${(err as Error).message}`)
557
755
  })
558
756
  .finally(() => {
559
- handles.delete(agentId)
757
+ // Only tear down the handle once the terminal edit has actually
758
+ // landed (or permanently failed). If `doFinish` staged the edit on
759
+ // `pendingFinish` (a 429 cooldown / transient error was in effect),
760
+ // the handle must survive so the heartbeat can re-drive the
761
+ // finalize after cooldown. The heartbeat's re-drive chain ends by
762
+ // re-entering `doFinish`, which clears `pendingFinish` on success
763
+ // or permanent-failure — so this `.finally` deletes on the NEXT
764
+ // chain settle once there is nothing left to finalize. Without this
765
+ // guard, the `.finally` would delete the handle (and its staged
766
+ // pendingFinish) immediately after the first staged doFinish,
767
+ // stranding the card on its last running render.
768
+ if (handles.get(agentId)?.pendingFinish == null) {
769
+ handles.delete(agentId)
770
+ }
560
771
  })
561
772
  return h.chain
562
773
  },
563
774
  drop(agentId) {
775
+ // A dropped worker is also done — mark finalized so a late watcher
776
+ // tick can't resurrect a running card on it (same gate as `finish`).
777
+ markFinalized(agentId)
564
778
  handles.delete(agentId)
565
779
  },
566
780
  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}]]}