switchroom 0.18.8 → 0.18.10

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 (93) hide show
  1. package/README.md +2 -2
  2. package/dist/agent-scheduler/index.js +1 -0
  3. package/dist/auth-broker/index.js +198 -13
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +28 -4
  6. package/dist/host-control/main.js +3 -2
  7. package/dist/vault/approvals/kernel-server.js +2 -1
  8. package/dist/vault/broker/server.js +2 -1
  9. package/package.json +1 -1
  10. package/profiles/_base/start.sh.hbs +119 -37
  11. package/profiles/_shared/dev-protocol.md.hbs +42 -0
  12. package/skills/dev-protocol/SKILL.md +131 -0
  13. package/telegram-plugin/README.md +2 -1
  14. package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
  15. package/telegram-plugin/admin-commands/index.ts +6 -1
  16. package/telegram-plugin/bridge/bridge.ts +23 -1
  17. package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
  18. package/telegram-plugin/chat-lock.ts +13 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +24 -1
  20. package/telegram-plugin/dist/gateway/gateway.js +80236 -77465
  21. package/telegram-plugin/dist/server.js +29 -2
  22. package/telegram-plugin/fallback-card-collapse.ts +131 -0
  23. package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
  24. package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
  25. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
  26. package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
  27. package/telegram-plugin/gateway/effort-command.ts +47 -3
  28. package/telegram-plugin/gateway/gateway.ts +2051 -3180
  29. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
  30. package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
  31. package/telegram-plugin/gateway/model-command.ts +94 -8
  32. package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
  33. package/telegram-plugin/gateway/pending-session-command.ts +365 -0
  34. package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
  35. package/telegram-plugin/gateway/permission-timeout.ts +25 -0
  36. package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
  37. package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
  38. package/telegram-plugin/gateway/session-model-file.ts +166 -23
  39. package/telegram-plugin/gateway/stop-command.ts +56 -0
  40. package/telegram-plugin/photo-precheck.ts +201 -0
  41. package/telegram-plugin/quota-watch.ts +141 -2
  42. package/telegram-plugin/registry/subagents-schema.ts +26 -3
  43. package/telegram-plugin/registry/subagents.test.ts +67 -0
  44. package/telegram-plugin/retry-api-call.ts +31 -0
  45. package/telegram-plugin/subagent-watcher.ts +392 -1
  46. package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
  47. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
  48. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
  49. package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
  50. package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
  51. package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
  52. package/telegram-plugin/tests/effort-command.test.ts +59 -2
  53. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
  54. package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
  55. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
  56. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
  57. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
  58. package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
  59. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
  60. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
  61. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
  62. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
  63. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
  64. package/telegram-plugin/tests/model-command.test.ts +46 -3
  65. package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
  66. package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
  67. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
  68. package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
  69. package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
  70. package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
  71. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
  72. package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
  73. package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
  74. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
  75. package/telegram-plugin/tests/quota-watch.test.ts +225 -0
  76. package/telegram-plugin/tests/session-model-file.test.ts +101 -2
  77. package/telegram-plugin/tests/stop-command.test.ts +234 -0
  78. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
  79. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
  80. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
  81. package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
  82. package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
  83. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
  84. package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
  85. package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
  86. package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
  87. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
  88. package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
  89. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
  90. package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
  91. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
  92. package/telegram-plugin/welcome-text.ts +4 -3
  93. package/telegram-plugin/worker-activity-feed.ts +27 -0
@@ -41,10 +41,14 @@ import { readFileSync } from 'node:fs'
41
41
  import { resolve } from 'node:path'
42
42
  import { resolveVaultApprovalPosture } from '../vault-approval-posture.js'
43
43
 
44
- const gatewaySrc = readFileSync(
45
- resolve(__dirname, '..', 'gateway', 'gateway.ts'),
46
- 'utf-8',
47
- )
44
+ // #2996 Phase 5: the callback-query handler families moved verbatim to
45
+ // gateway/callback-query-handlers.ts; these pins read the gateway source
46
+ // COMBINED with that module so the wiring assertions keep covering the
47
+ // same runtime source text.
48
+ const gatewaySrc =
49
+ readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
50
+ '\n' +
51
+ readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
48
52
 
49
53
  function sliceAccessApproveBlock(): string {
50
54
  const fn =
@@ -73,7 +77,10 @@ describe('vault grant approval posture — module-level wiring', () => {
73
77
  describe('handleVaultRequestAccessCallback — posture branch', () => {
74
78
  it('mints via posture attestation (NOT in-memory passphrase) when posture is telegram-id', () => {
75
79
  const approveBlock = sliceAccessApproveBlock()
76
- expect(approveBlock).toMatch(/VAULT_APPROVAL_AUTH_MODE === ['"]telegram-id['"]/)
80
+ // #2996 Phase 5: inside the extracted handler module the mutable gateway
81
+ // `let` is read via the injected getter (gateway wires it as
82
+ // `getVaultApprovalAuthMode: () => VAULT_APPROVAL_AUTH_MODE`).
83
+ expect(approveBlock).toMatch(/getVaultApprovalAuthMode\(\) === ['"]telegram-id['"]/)
77
84
  // Pinned: the call shape MUST be `{ kind: 'posture' }`. If the
78
85
  // gateway ever reverts to passing a real passphrase here, the
79
86
  // bypass surface returns.
@@ -138,7 +145,8 @@ describe('handleVaultRequestSaveCallback — posture-attested broker put (#1115
138
145
  // calls `defaultVaultWritePosture` (posture-attested broker put,
139
146
  // no passphrase). Under passphrase mode it keeps the legacy
140
147
  // cached-passphrase + shell-out path.
141
- expect(fnBlock).toMatch(/VAULT_APPROVAL_AUTH_MODE === 'telegram-id'/)
148
+ // #2996 Phase 5: getter read of the gateway's mutable posture `let`.
149
+ expect(fnBlock).toMatch(/getVaultApprovalAuthMode\(\) === 'telegram-id'/)
142
150
  expect(fnBlock).toMatch(/defaultVaultWritePosture\(/)
143
151
  // Passphrase-mode branch still present.
144
152
  expect(fnBlock).toMatch(/vaultPassphraseCache\.get\(pending\.chat_id\)/)
@@ -203,7 +211,7 @@ describe('allowlist is the first gate in every vault callback handler', () => {
203
211
  'mintGrantViaBroker',
204
212
  'performVaultAccessApproval',
205
213
  'pendingVaultOps.set',
206
- "VAULT_APPROVAL_AUTH_MODE === 'telegram-id'",
214
+ "getVaultApprovalAuthMode() === 'telegram-id'",
207
215
  'attest_via_posture',
208
216
  ]) {
209
217
  expect(
@@ -23,10 +23,14 @@ import { describe, it, expect } from "vitest";
23
23
  import { readFileSync } from "node:fs";
24
24
  import { resolve } from "node:path";
25
25
 
26
- const gatewaySrc = readFileSync(
27
- resolve(__dirname, "..", "gateway", "gateway.ts"),
28
- "utf-8",
29
- );
26
+ // #2996 Phase 5: the callback-query handler families moved verbatim to
27
+ // gateway/callback-query-handlers.ts; these pins read the gateway source
28
+ // COMBINED with that module so the wiring assertions keep covering the
29
+ // same runtime source text.
30
+ const gatewaySrc =
31
+ readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8") +
32
+ "\n" +
33
+ readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8");
30
34
 
31
35
  function extractPerformBlock(): string {
32
36
  const start = gatewaySrc.indexOf("async function performVaultAccessApproval");
@@ -22,10 +22,14 @@ import { describe, it, expect } from "vitest";
22
22
  import { readFileSync } from "node:fs";
23
23
  import { resolve } from "node:path";
24
24
 
25
- const gatewaySrc = readFileSync(
26
- resolve(__dirname, "..", "gateway", "gateway.ts"),
27
- "utf-8",
28
- );
25
+ // #2996 Phase 5: the callback-query handler families moved verbatim to
26
+ // gateway/callback-query-handlers.ts; these pins read the gateway source
27
+ // COMBINED with that module so the wiring assertions keep covering the
28
+ // same runtime source text.
29
+ const gatewaySrc =
30
+ readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8") +
31
+ "\n" +
32
+ readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8");
29
33
 
30
34
  function extractPerformBlock(): string {
31
35
  const start = gatewaySrc.indexOf("async function performVaultAccessApproval");
@@ -40,7 +40,14 @@ function readSrc(rel: string): string {
40
40
  // `telegram-plugin/docs/gateway-server-split.md` for the F4 cleanup notes.
41
41
 
42
42
  describe('/vault grant inline-keyboard wizard — gateway (#227, #262, #265)', () => {
43
- const gatewaySrc = readSrc('telegram-plugin/gateway/gateway.ts')
43
+ // #2996 Phase 5: the callback-query handler families moved verbatim to
44
+ // gateway/callback-query-handlers.ts; these pins read the gateway source
45
+ // COMBINED with that module so the wiring assertions keep covering the
46
+ // same runtime source text.
47
+ const gatewaySrc =
48
+ readSrc('telegram-plugin/gateway/gateway.ts') +
49
+ '\n' +
50
+ readSrc('telegram-plugin/gateway/callback-query-handlers.ts')
44
51
 
45
52
  it('gateway.ts: dispatches /vault grant to the wizard entry', () => {
46
53
  expect(gatewaySrc).toMatch(/\/vault grant/i)
@@ -33,7 +33,14 @@ import { join, dirname } from 'node:path'
33
33
  const __dir = dirname(fileURLToPath(import.meta.url))
34
34
  const pluginDir = join(__dir, '..')
35
35
 
36
- const gatewaySrc = readFileSync(join(pluginDir, 'gateway', 'gateway.ts'), 'utf8')
36
+ // #2996 Phase 5: the callback-query handler families moved verbatim to
37
+ // gateway/callback-query-handlers.ts; these pins read the gateway source
38
+ // COMBINED with that module so the wiring assertions keep covering the
39
+ // same runtime source text.
40
+ const gatewaySrc =
41
+ readFileSync(join(pluginDir, 'gateway', 'gateway.ts'), 'utf8') +
42
+ '\n' +
43
+ readFileSync(join(pluginDir, 'gateway', 'callback-query-handlers.ts'), 'utf8')
37
44
 
38
45
  // ─── helpers ─────────────────────────────────────────────────────────────────
39
46
 
@@ -27,10 +27,14 @@ import { describe, it, expect } from "vitest";
27
27
  import { readFileSync } from "node:fs";
28
28
  import { resolve } from "node:path";
29
29
 
30
- const gatewaySrc = readFileSync(
31
- resolve(__dirname, "..", "gateway", "gateway.ts"),
32
- "utf-8",
33
- );
30
+ // #2996 Phase 5: the callback-query handler families moved verbatim to
31
+ // gateway/callback-query-handlers.ts; these pins read the gateway source
32
+ // COMBINED with that module so the wiring assertions keep covering the
33
+ // same runtime source text.
34
+ const gatewaySrc =
35
+ readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8") +
36
+ "\n" +
37
+ readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8");
34
38
 
35
39
  /** The exported regex literal — what the gateway actually validates against. */
36
40
  function extractVaultKeyRegex(): RegExp {
@@ -23,10 +23,14 @@ const bridgeSrc = readFileSync(
23
23
  resolve(__dirname, '..', 'bridge', 'bridge.ts'),
24
24
  'utf-8',
25
25
  )
26
- const gatewaySrc = readFileSync(
27
- resolve(__dirname, '..', 'gateway', 'gateway.ts'),
28
- 'utf-8',
29
- )
26
+ // #2996 Phase 5: the callback-query handler families moved verbatim to
27
+ // gateway/callback-query-handlers.ts; these pins read the gateway source
28
+ // COMBINED with that module so the wiring assertions keep covering the
29
+ // same runtime source text.
30
+ const gatewaySrc =
31
+ readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
32
+ '\n' +
33
+ readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
30
34
 
31
35
  describe('vault_request_access (#1012)', () => {
32
36
  it('bridge advertises the tool to MCP clients', () => {
@@ -22,10 +22,14 @@ import { describe, it, expect } from 'vitest'
22
22
  import { readFileSync } from 'node:fs'
23
23
  import { resolve } from 'node:path'
24
24
 
25
- const gatewaySrc = readFileSync(
26
- resolve(__dirname, '..', 'gateway', 'gateway.ts'),
27
- 'utf-8',
28
- )
25
+ // #2996 Phase 5: the callback-query handler families moved verbatim to
26
+ // gateway/callback-query-handlers.ts; these pins read the gateway source
27
+ // COMBINED with that module so the wiring assertions keep covering the
28
+ // same runtime source text.
29
+ const gatewaySrc =
30
+ readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
31
+ '\n' +
32
+ readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
29
33
 
30
34
  describe('vault_request_access — tap-to-unlock-and-approve UX', () => {
31
35
  it('declares the passphrase-for-access-approve PendingVaultOp variant', () => {
@@ -986,6 +986,43 @@ describe('createWorkerActivityFeed — resurrection guard + deferred finalize',
986
986
  expect(feed.size).toBe(0)
987
987
  })
988
988
 
989
+ it('#3023: resurrect() reopens the paint path after a FALSE finish so a fresh cue repaints', async () => {
990
+ const bot = makeFakeBot()
991
+ let clock = 10_000
992
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
993
+ await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'step one' }))
994
+ expect(bot.sent).toHaveLength(1)
995
+
996
+ // The card is FALSELY finalized (silent-stall synthesis fired while the
997
+ // worker was actually still alive).
998
+ await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'premature done' }))
999
+ expect(feed.has('w1')).toBe(false)
1000
+
1001
+ // Without resurrect(), the finalized gate would swallow any further cue
1002
+ // (the #3 case above). Prove that first: a plain late tick is a no-op.
1003
+ clock = 12_000
1004
+ await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'still going' }))
1005
+ expect(bot.sent).toHaveLength(1)
1006
+
1007
+ // The watcher detected the false finish and resurrected the worker.
1008
+ feed.resurrect('w1')
1009
+
1010
+ // Now a running cue repaints a live card — active work is visible again.
1011
+ clock = 13_000
1012
+ await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'back to work' }))
1013
+ expect(bot.sent).toHaveLength(2) // a fresh card was painted
1014
+ expect(feed.has('w1')).toBe(true)
1015
+ expect(bot.sent[1].text).toContain('back to work')
1016
+ })
1017
+
1018
+ it('#3023: resurrect() on a never-finalized worker is a harmless no-op', () => {
1019
+ const bot = makeFakeBot()
1020
+ const feed = createWorkerActivityFeed({ bot, now: () => 10_000 })
1021
+ // No throw, nothing painted.
1022
+ feed.resurrect('never-seen')
1023
+ expect(bot.sent).toHaveLength(0)
1024
+ })
1025
+
989
1026
  it('#2: a 429 on the finish edit stages pendingFinish; the heartbeat re-drives it after cooldown', async () => {
990
1027
  const bot = makeFakeBot()
991
1028
  let clock = 10_000
@@ -158,6 +158,7 @@ function makeHarness() {
158
158
  stallThresholdMs: 60_000, // tight active-loop threshold
159
159
  silentSynthesisStallThresholdMs: 300_000, // widened window (also long-runner window)
160
160
  silentStallTerminalMs: 300_000, // post-stall terminal-synthesis window (unchanged)
161
+ inflightTerminalCapMs: 900_000, // died-mid-tool cap (15 min, compressed from the 45-min default)
161
162
  rescanMs: 500,
162
163
  now: () => currentTime,
163
164
  setInterval: sharedSetInterval,
@@ -278,10 +279,23 @@ describe('prose-silent background worker — end-to-end visibility harness (PR 2
278
279
  expect(h.stallCalls[0].agentId).toBe(h.agentId)
279
280
  expect(h.stallTerminalCalls).toHaveLength(0)
280
281
 
281
- // (d) TERMINAL GATE RELEASE: 300s past the stall, the silent-stall terminal
282
- // synthesis fires — onStallTerminal + onFinish — and the feed finalizes.
283
- // This is the untouched, load-bearing completion path.
284
- h.advance(310_000)
282
+ // (d) IN-FLIGHT DEFERRAL: 300s past the stall the OLD contract
283
+ // (#2777/#2782) synthesised terminal here. But this worker's transcript
284
+ // ends in a Bash tool_use with NO tool_result — it may still be inside
285
+ // the tool call (incident 2026-07-10: exactly this shape finalised a
286
+ // LIVE worker's card mid-Bash). Synthesis is now deferred while the
287
+ // tool call is in flight...
288
+ h.advance(310_000) // total idle ~672s — past silentStallTerminalMs, under the 900s cap
289
+ await h.flush()
290
+ expect(h.stallTerminalCalls).toHaveLength(0)
291
+ expect(h.finishCalls).toHaveLength(0)
292
+
293
+ // (e) TERMINAL GATE RELEASE AT THE CAP: ...but not forever. A worker
294
+ // that died mid-tool (JSONL frozen, tool_result never coming) must not
295
+ // wedge — past inflightTerminalCapMs of total idle the synthesis
296
+ // proceeds and the completion gate releases. The load-bearing release
297
+ // from #2777/#2782 survives, bounded instead of at the raw synth window.
298
+ h.advance(300_000) // total idle ~972s ≥ 900s cap
285
299
  await h.flush()
286
300
  expect(h.stallTerminalCalls).toHaveLength(1)
287
301
  expect(h.finishCalls).toHaveLength(1)
@@ -279,9 +279,9 @@ export function statusUnpairedText(): string {
279
279
  */
280
280
  export const switchroomHelpCommandNames = [
281
281
  // Session & approvals
282
- "new", "compact", "clear", "approve", "deny", "pending", "interrupt",
282
+ "new", "compact", "clear", "approve", "deny", "pending", "interrupt", "stop",
283
283
  // Agents
284
- "agents", "agentstart", "stop", "restart", "logs", "memory",
284
+ "agents", "agentstart", "agentstop", "restart", "logs", "memory",
285
285
  // Auth & config — consolidated onto the `/auth` dashboard.
286
286
  "auth", "model",
287
287
  "topics", "update", "version", "whoami",
@@ -380,11 +380,12 @@ export function switchroomHelpText(agentName: string): string {
380
380
  `\`/deny [id]\` — deny pending tool permission`,
381
381
  `\`/pending\` — list pending permission prompts`,
382
382
  `\`/interrupt [name]\` — interrupt an agent turn`,
383
+ `\`/stop\` — cancel my in-flight turn (bare "stop" works too)`,
383
384
  ``,
384
385
  `**Agents**`,
385
386
  `\`/agents\` — list all agents`,
386
387
  `\`/agentstart [name]\` — start an agent`,
387
- `\`/stop [name]\` — stop an agent`,
388
+ `\`/agentstop [name]\` — stop an agent's container`,
388
389
  `\`/logs [name] [lines]\` — show agent logs`,
389
390
  `\`/memory <query>\` — search agent memory`,
390
391
  ``,
@@ -342,6 +342,16 @@ export interface WorkerActivityFeed {
342
342
  finish(agentId: string, view: WorkerActivityView): Promise<void>
343
343
  /** Forget a worker's state without editing (e.g. error path). */
344
344
  drop(agentId: string): void
345
+ /**
346
+ * Issue #3023 (card resurrection). Undo a finalization: clear the durable
347
+ * `finalized` gate (and any lingering per-handle `finished` latch) so a
348
+ * worker whose card was FALSELY finalized can be painted/edited again. The
349
+ * watcher calls this (via the gateway's `onResurrect` wiring) when a
350
+ * falsely-finalized worker's JSONL resumes growing. A fresh `running` cue
351
+ * after this repaints a live card — the operator invariant that active work
352
+ * stays visible. Idempotent; a no-op if the worker was never finalized.
353
+ */
354
+ resurrect(agentId: string): void
345
355
  /** Clear the heartbeat interval (gateway shutdown). Idempotent. */
346
356
  stop(): void
347
357
  /** Manually fire one heartbeat tick (test hook). */
@@ -777,6 +787,23 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
777
787
  markFinalized(agentId)
778
788
  handles.delete(agentId)
779
789
  },
790
+ resurrect(agentId) {
791
+ // Issue #3023: the worker's card was falsely finalized and its JSONL has
792
+ // resumed. Re-open the paint path: drop the durable finalized gate so a
793
+ // fresh `running` cue creates a new handle and first-paints a live card
794
+ // again, and un-latch any surviving handle (finish deleted it in the
795
+ // common case, but a staged pendingFinish could keep it alive). The next
796
+ // `update` tick from the watcher's replayed progress does the repaint.
797
+ const wasFinalized = finalized.delete(agentId)
798
+ const h = handles.get(agentId)
799
+ if (h != null) {
800
+ h.finished = false
801
+ h.pendingFinish = null
802
+ }
803
+ if (wasFinalized || h != null) {
804
+ log(`worker-feed: resurrect agent=${agentId} — cleared finalized gate; card will repaint on next running cue`)
805
+ }
806
+ },
780
807
  heartbeatTick,
781
808
  stop() {
782
809
  if (heartbeatTimer != null) {