switchroom 0.19.18 → 0.19.22

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +56 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +5242 -2239
  7. package/dist/host-control/main.js +12241 -11375
  8. package/dist/vault/approvals/kernel-server.js +113 -7
  9. package/dist/vault/broker/server.js +259 -76
  10. package/package.json +6 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/skills/switchroom-release/SKILL.md +103 -20
  13. package/telegram-plugin/bridge/bridge.ts +14 -0
  14. package/telegram-plugin/card-format.ts +92 -3
  15. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
  17. package/telegram-plugin/dist/server.js +13 -0
  18. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  19. package/telegram-plugin/format.ts +19 -7
  20. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  21. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  23. package/telegram-plugin/gateway/gateway.ts +66 -56
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  25. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  26. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  27. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  28. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  29. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  30. package/telegram-plugin/gateway/store-file.ts +244 -0
  31. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  32. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  33. package/telegram-plugin/retry-api-call.ts +15 -2
  34. package/telegram-plugin/send-gate.ts +1 -1
  35. package/telegram-plugin/status-no-truncate.ts +64 -1
  36. package/telegram-plugin/status-pin-driver.ts +50 -27
  37. package/telegram-plugin/status-pin.ts +43 -5
  38. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  40. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  41. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  42. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  43. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  44. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  45. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  46. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  47. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  48. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  49. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  50. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  51. package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
  52. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  53. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  54. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  55. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  56. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  57. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  58. package/telegram-plugin/tool-activity-summary.ts +85 -13
  59. package/telegram-plugin/worker-activity-feed.ts +56 -2
  60. package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
  61. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  62. package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
  63. package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
  64. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  65. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  66. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  67. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  73. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
  76. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -10,10 +10,11 @@
10
10
  */
11
11
 
12
12
  import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
13
- import { mkdtempSync, mkdirSync, rmSync } from 'fs'
13
+ import { mkdtempSync, mkdirSync, rmSync, readFileSync } from 'fs'
14
14
  import { tmpdir } from 'os'
15
15
  import { join } from 'path'
16
16
  import { spawnSync } from 'child_process'
17
+ import { recordSubagentEnd, reapStuckRunningRows } from '../registry/subagents-schema.js'
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // Helpers
@@ -546,6 +547,635 @@ describe('subagent-tracker-posttool', () => {
546
547
  })
547
548
  })
548
549
 
550
+ // ───────────────────────────────────────────────────────────────────────────
551
+ // #3667 — the PRODUCTION async-launch payload
552
+ // ───────────────────────────────────────────────────────────────────────────
553
+ //
554
+ // Every posttool test above feeds a hand-written `{ content: [...] }` or
555
+ // `{ result: '...' }` shape. Claude Code's Agent tool has never returned
556
+ // either of those for a dispatch: it returns a STRUCTURED object with no text
557
+ // anywhere on it. Because the prose ACK tiers read '' from that object, the
558
+ // hook classified 314/318 real dispatches as foreground completions and
559
+ // stamped `status='completed', ended_at≈started_at` ~0.2s after launch, while
560
+ // `last_activity_at` kept climbing for the worker's real lifetime.
561
+ //
562
+ // So these tests pin the REAL payload, captured verbatim from a live parent
563
+ // transcript (`toolUseResult`, claude-code 2.1.219). Update it only against a
564
+ // freshly captured transcript, never by hand.
565
+ const PROD_ASYNC_LAUNCH_RESPONSE = {
566
+ isAsync: true,
567
+ status: 'async_launched',
568
+ agentId: 'aec3ddbae614f85f5',
569
+ description: 'Fix subagent rows terminalizing early',
570
+ resolvedModel: 'claude-opus-5',
571
+ prompt: 'Work in /share/code/switchroom. Read the repo conventions first...',
572
+ outputFile: '/tmp/claude-x/tasks/aec3ddbae614f85f5.output',
573
+ canReadOutputFile: true,
574
+ }
575
+
576
+ describe('subagent-tracker-posttool — async launch is not a completion (#3667)', () => {
577
+ it('leaves a still-running worker non-terminal on the production async-launch payload', () => {
578
+ // Pretool sees the production tool_input: NO run_in_background key (the
579
+ // runtime auto-backgrounds and does not echo the flag) → background=0.
580
+ expect(runHook(PRETOOL_SCRIPT, {
581
+ session_id: 's-3667',
582
+ tool_name: 'Agent',
583
+ tool_use_id: 'toolu_3667a',
584
+ tool_input: { subagent_type: 'worker', description: 'Fix subagent rows terminalizing early' },
585
+ }).status).toBe(0)
586
+
587
+ const db = openDb()
588
+ const before = db
589
+ .prepare('SELECT background, status, started_at FROM subagents WHERE id = ?')
590
+ .get('toolu_3667a') as { background: number; status: string; started_at: number }
591
+ expect(before.background).toBe(0)
592
+ expect(before.status).toBe('running')
593
+
594
+ const postResult = runHook(POSTTOOL_SCRIPT, {
595
+ tool_name: 'Agent',
596
+ tool_use_id: 'toolu_3667a',
597
+ tool_response: PROD_ASYNC_LAUNCH_RESPONSE,
598
+ })
599
+ expect(postResult.status).toBe(0)
600
+
601
+ const after = db
602
+ .prepare('SELECT background, status, ended_at, last_activity_at, result_summary FROM subagents WHERE id = ?')
603
+ .get('toolu_3667a') as {
604
+ background: number
605
+ status: string
606
+ ended_at: number | null
607
+ last_activity_at: number
608
+ result_summary: string | null
609
+ }
610
+
611
+ // THE failure shape: a launched-but-still-running worker must not read as
612
+ // terminal, and must carry no end timestamp at all.
613
+ expect(after.status).toBe('running')
614
+ expect(after.ended_at).toBeNull()
615
+ // Promoted off the mis-recorded foreground flag, so the watcher/card path
616
+ // treats it as the background worker it actually is.
617
+ expect(after.background).toBe(1)
618
+ // Liveness still recorded.
619
+ expect(after.last_activity_at).toBeGreaterThanOrEqual(before.started_at)
620
+ // The launch payload embeds the whole dispatch prompt — it must never be
621
+ // mistaken for the worker's result.
622
+ expect(after.result_summary).toBeNull()
623
+
624
+ // No handback nudge: nothing has been handed back yet.
625
+ expect(postResult.stdout).not.toContain('additionalContext')
626
+ })
627
+
628
+ it('records a truthful duration when the worker actually finishes later', () => {
629
+ // The other half of the failure shape: `ended_at` must reflect REAL
630
+ // completion, not the launch ACK. After the ACK the watcher's
631
+ // JSONL-driven turn_end write is what terminalizes the row.
632
+ expect(runHook(PRETOOL_SCRIPT, {
633
+ session_id: 's-3667b',
634
+ tool_name: 'Agent',
635
+ tool_use_id: 'toolu_3667b',
636
+ tool_input: { subagent_type: 'worker', description: 'Long job' },
637
+ }).status).toBe(0)
638
+
639
+ expect(runHook(POSTTOOL_SCRIPT, {
640
+ tool_name: 'Agent',
641
+ tool_use_id: 'toolu_3667b',
642
+ tool_response: PROD_ASYNC_LAUNCH_RESPONSE,
643
+ }).status).toBe(0)
644
+
645
+ const db = openDb()
646
+ const row = db
647
+ .prepare('SELECT started_at, ended_at FROM subagents WHERE id = ?')
648
+ .get('toolu_3667b') as { started_at: number; ended_at: number | null }
649
+ expect(row.ended_at).toBeNull()
650
+
651
+ // Watcher's terminal write, 5 minutes after dispatch. Uses the REAL
652
+ // recordSubagentEnd the watcher's turn_end path calls, so this test can't
653
+ // pass against a hand-copied approximation of it.
654
+ const realEnd = row.started_at + 300_000
655
+ recordSubagentEnd(db as unknown as Parameters<typeof recordSubagentEnd>[0], {
656
+ id: 'toolu_3667b',
657
+ endedAt: realEnd,
658
+ status: 'completed',
659
+ })
660
+
661
+ const done = db
662
+ .prepare('SELECT status, ended_at, started_at FROM subagents WHERE id = ?')
663
+ .get('toolu_3667b') as { status: string; ended_at: number; started_at: number }
664
+ expect(done.status).toBe('completed')
665
+ // The regression signature was ended_at - started_at < ~1s on a job that
666
+ // ran for minutes. Assert the recorded duration is the real one.
667
+ expect(done.ended_at - done.started_at).toBe(300_000)
668
+ })
669
+
670
+ it('does NOT terminalize on an unrecognised tool_response shape', () => {
671
+ // Drift guard: if claude-code changes the payload again, the hook must
672
+ // decline to guess rather than declare a live worker finished. It warns on
673
+ // stderr instead, and the watcher stays in charge of the terminal write.
674
+ expect(runHook(PRETOOL_SCRIPT, {
675
+ session_id: 's-3667c',
676
+ tool_name: 'Agent',
677
+ tool_use_id: 'toolu_3667c',
678
+ tool_input: { subagent_type: 'worker', description: 'Drifted payload' },
679
+ }).status).toBe(0)
680
+
681
+ const postResult = runHook(POSTTOOL_SCRIPT, {
682
+ tool_name: 'Agent',
683
+ tool_use_id: 'toolu_3667c',
684
+ tool_response: { someFutureField: 42, launchToken: 'abc' },
685
+ })
686
+ expect(postResult.status).toBe(0)
687
+ expect(postResult.stdout).not.toContain('additionalContext')
688
+ expect(postResult.stderr).toContain('unrecognised Agent tool_response shape')
689
+ // Shape only — never values (the payload can embed the dispatch prompt).
690
+ expect(postResult.stderr).not.toContain('abc')
691
+
692
+ const db = openDb()
693
+ const row = db
694
+ .prepare('SELECT status, ended_at, background, last_activity_at, started_at FROM subagents WHERE id = ?')
695
+ .get('toolu_3667c') as {
696
+ status: string
697
+ ended_at: number | null
698
+ background: number
699
+ last_activity_at: number | null
700
+ started_at: number
701
+ }
702
+ expect(row.status).toBe('running')
703
+ expect(row.ended_at).toBeNull()
704
+ // background = 0 is a CONTRACT ("PostToolUse terminalizes this row"), not
705
+ // a free-form flag, and it is what reapStuckRunningRows filters on. A row
706
+ // this hook refuses to terminalize must be handed to the watcher/reaper,
707
+ // or under a shape drift EVERY row would sit `running` forever with no
708
+ // owner at all.
709
+ expect(row.background).toBe(1)
710
+
711
+ // Prove the hand-off is real, not just a flag: the reaper must actually
712
+ // sweep this row once it goes stale. Would FAIL if the row stayed
713
+ // background = 0 (the reaper's SELECT requires background = 1).
714
+ const reaped = reapStuckRunningRows(
715
+ db as unknown as Parameters<typeof reapStuckRunningRows>[0],
716
+ { now: (row.last_activity_at ?? row.started_at) + 3_600_001, ttlMs: 3_600_000 },
717
+ )
718
+ expect(reaped.ids).toContain('toolu_3667c')
719
+ const swept = db
720
+ .prepare('SELECT status, result_summary FROM subagents WHERE id = ?')
721
+ .get('toolu_3667c') as { status: string; result_summary: string | null }
722
+ expect(swept.status).toBe('stalled')
723
+ expect(swept.result_summary).toContain('reaped')
724
+ })
725
+
726
+ it('terminalizes an explicitly terminal async envelope instead of calling it a launch', () => {
727
+ // Boundary: `isAsync: true` means "this dispatch is asynchronous", not
728
+ // "still running". If a future claude-code reuses the envelope to report
729
+ // an OUTCOME, the machine-readable `status` is authoritative — the whole
730
+ // point of #3667 is to read structured fields instead of guessing.
731
+ expect(runHook(PRETOOL_SCRIPT, {
732
+ session_id: 's-3667g',
733
+ tool_name: 'Agent',
734
+ tool_use_id: 'toolu_3667g',
735
+ tool_input: { subagent_type: 'worker', description: 'Terminal envelope' },
736
+ }).status).toBe(0)
737
+
738
+ const postResult = runHook(POSTTOOL_SCRIPT, {
739
+ tool_name: 'Agent',
740
+ tool_use_id: 'toolu_3667g',
741
+ tool_response: { isAsync: true, status: 'completed', agentId: 'zz1' },
742
+ })
743
+ expect(postResult.status).toBe(0)
744
+ // Recognised, so no drift alarm.
745
+ expect(postResult.stderr).not.toContain('unrecognised Agent tool_response shape')
746
+
747
+ const db = openDb()
748
+ const row = db
749
+ .prepare('SELECT status, background, ended_at FROM subagents WHERE id = ?')
750
+ .get('toolu_3667g') as { status: string; background: number; ended_at: number | null }
751
+ expect(row.status).toBe('completed')
752
+ expect(row.ended_at).not.toBeNull()
753
+ })
754
+
755
+ it('records a terminal async envelope with a FAILED status as failed, not completed', () => {
756
+ // The status is not just a liveness signal — it carries the outcome. A
757
+ // classifier that terminalized on `status` but read the outcome from
758
+ // is_error/text alone would file every failure as a success.
759
+ expect(runHook(PRETOOL_SCRIPT, {
760
+ session_id: 's-3667h',
761
+ tool_name: 'Agent',
762
+ tool_use_id: 'toolu_3667h',
763
+ tool_input: { subagent_type: 'worker', description: 'Failing envelope' },
764
+ }).status).toBe(0)
765
+
766
+ const postResult = runHook(POSTTOOL_SCRIPT, {
767
+ tool_name: 'Agent',
768
+ tool_use_id: 'toolu_3667h',
769
+ tool_response: { isAsync: true, status: 'failed', agentId: 'zz2' },
770
+ })
771
+ expect(postResult.status).toBe(0)
772
+ // A failure is not a handback.
773
+ expect(postResult.stdout).not.toContain('additionalContext')
774
+
775
+ const db = openDb()
776
+ const row = db
777
+ .prepare('SELECT status, ended_at FROM subagents WHERE id = ?')
778
+ .get('toolu_3667h') as { status: string; ended_at: number | null }
779
+ expect(row.status).toBe('failed')
780
+ expect(row.ended_at).not.toBeNull()
781
+ })
782
+
783
+ it('does NOT read a still-in-flight async status as terminal', () => {
784
+ // Only the terminal statuses count. A non-terminal `status` on an
785
+ // isAsync envelope is still a launch — the row must stay alive.
786
+ expect(runHook(PRETOOL_SCRIPT, {
787
+ session_id: 's-3667i',
788
+ tool_name: 'Agent',
789
+ tool_use_id: 'toolu_3667i',
790
+ tool_input: { subagent_type: 'worker', description: 'In flight' },
791
+ }).status).toBe(0)
792
+
793
+ expect(runHook(POSTTOOL_SCRIPT, {
794
+ tool_name: 'Agent',
795
+ tool_use_id: 'toolu_3667i',
796
+ tool_response: { isAsync: true, status: 'running', agentId: 'zz3' },
797
+ }).status).toBe(0)
798
+
799
+ const db = openDb()
800
+ const row = db
801
+ .prepare('SELECT status, background, ended_at FROM subagents WHERE id = ?')
802
+ .get('toolu_3667i') as { status: string; background: number; ended_at: number | null }
803
+ expect(row.status).toBe('running')
804
+ expect(row.background).toBe(1)
805
+ expect(row.ended_at).toBeNull()
806
+ })
807
+
808
+ it('does NOT terminalize when tool_response is absent', () => {
809
+ expect(runHook(PRETOOL_SCRIPT, {
810
+ session_id: 's-3667d',
811
+ tool_name: 'Agent',
812
+ tool_use_id: 'toolu_3667d',
813
+ tool_input: { subagent_type: 'worker', description: 'No response' },
814
+ }).status).toBe(0)
815
+
816
+ const postResult = runHook(POSTTOOL_SCRIPT, {
817
+ tool_name: 'Agent',
818
+ tool_use_id: 'toolu_3667d',
819
+ })
820
+ expect(postResult.status).toBe(0)
821
+
822
+ const db = openDb()
823
+ const row = db
824
+ .prepare('SELECT status, ended_at FROM subagents WHERE id = ?')
825
+ .get('toolu_3667d') as { status: string; ended_at: number | null }
826
+ expect(row.status).toBe('running')
827
+ expect(row.ended_at).toBeNull()
828
+ })
829
+
830
+ it('terminalizes a bare-string dispatch error as failed', () => {
831
+ // Also observed in production: the Agent tool returns a bare string when
832
+ // the dispatch itself fails, so there is no `is_error` flag to read. No
833
+ // worker exists, so the watcher will never terminalize this row — the hook
834
+ // must, and it must not call the failure a success.
835
+ expect(runHook(PRETOOL_SCRIPT, {
836
+ session_id: 's-3667e',
837
+ tool_name: 'Agent',
838
+ tool_use_id: 'toolu_3667e',
839
+ tool_input: { subagent_type: 'worker', description: 'Doomed dispatch' },
840
+ }).status).toBe(0)
841
+
842
+ const postResult = runHook(POSTTOOL_SCRIPT, {
843
+ tool_name: 'Agent',
844
+ tool_use_id: 'toolu_3667e',
845
+ tool_response:
846
+ 'Error: Cannot create agent worktree: not in a git repository and no '
847
+ + 'WorktreeCreate hooks are configured.',
848
+ })
849
+ expect(postResult.status).toBe(0)
850
+ // A failed dispatch is not a handback.
851
+ expect(postResult.stdout).not.toContain('additionalContext')
852
+
853
+ const db = openDb()
854
+ const row = db
855
+ .prepare('SELECT status, ended_at, result_summary FROM subagents WHERE id = ?')
856
+ .get('toolu_3667e') as { status: string; ended_at: number | null; result_summary: string | null }
857
+ expect(row.status).toBe('failed')
858
+ expect(row.ended_at).not.toBeNull()
859
+ expect(row.result_summary).toContain('Cannot create agent worktree')
860
+ })
861
+
862
+ it('terminalizes a prefixed bare-string dispatch error (InputValidationError) as failed', () => {
863
+ // Also observed in production (1 of the 54 bare-string results): the
864
+ // dispatch failure does NOT start with a literal "Error:". A probe
865
+ // anchored on that exact prefix filed this as a SUCCESSFUL sub-agent
866
+ // completion with the validation error stored as its result_summary —
867
+ // and nothing else ever revisits the row, because no worker was created.
868
+ expect(runHook(PRETOOL_SCRIPT, {
869
+ session_id: 's-3667j',
870
+ tool_name: 'Agent',
871
+ tool_use_id: 'toolu_3667j',
872
+ tool_input: { subagent_type: 'worker', description: 'Bad input' },
873
+ }).status).toBe(0)
874
+
875
+ const postResult = runHook(POSTTOOL_SCRIPT, {
876
+ tool_name: 'Agent',
877
+ tool_use_id: 'toolu_3667j',
878
+ tool_response:
879
+ 'InputValidationError: [\n {\n "expected": "string",\n'
880
+ + ' "code": "invalid_type",\n "path": [ "prompt" ]\n }\n]',
881
+ })
882
+ expect(postResult.status).toBe(0)
883
+ expect(postResult.stdout).not.toContain('additionalContext')
884
+
885
+ const db = openDb()
886
+ const row = db
887
+ .prepare('SELECT status, ended_at FROM subagents WHERE id = ?')
888
+ .get('toolu_3667j') as { status: string; ended_at: number | null }
889
+ expect(row.status).toBe('failed')
890
+ expect(row.ended_at).not.toBeNull()
891
+ })
892
+
893
+ it('does not call a worker report that OPENS with "Error:" a failed dispatch', () => {
894
+ // The bare-string probe must not be run over a worker's own report. A
895
+ // report that opens "Error: I could not reproduce…" describes an error,
896
+ // it is not a failed dispatch, and filing it as `failed` misreports a
897
+ // sub-agent that ran to completion.
898
+ expect(runHook(PRETOOL_SCRIPT, {
899
+ session_id: 's-3667k',
900
+ tool_name: 'Agent',
901
+ tool_use_id: 'toolu_3667k',
902
+ tool_input: { subagent_type: 'worker', description: 'Repro attempt', run_in_background: false },
903
+ }).status).toBe(0)
904
+
905
+ expect(runHook(POSTTOOL_SCRIPT, {
906
+ tool_name: 'Agent',
907
+ tool_use_id: 'toolu_3667k',
908
+ tool_response: {
909
+ content: [{ type: 'text', text: 'Error: could not be reproduced — here is what I checked instead.' }],
910
+ },
911
+ }).status).toBe(0)
912
+
913
+ const db = openDb()
914
+ const row = db
915
+ .prepare('SELECT status FROM subagents WHERE id = ?')
916
+ .get('toolu_3667k') as { status: string }
917
+ expect(row.status).toBe('completed')
918
+ })
919
+
920
+ it('terminalizes the production SYNCHRONOUS completion envelope', () => {
921
+ // The PR body claimed "Zero synchronous completions" from a 318-dispatch
922
+ // sample. Rescanning every agent's transcripts (2692 Agent/Task results)
923
+ // found 3 real ones, all on agent `marko`, running 164s-490s. They are a
924
+ // THIRD shape with its own machine-readable `status` — pinned here so the
925
+ // classifier is verified against it rather than assumed to be unreachable.
926
+ expect(runHook(PRETOOL_SCRIPT, {
927
+ session_id: 's-3667l',
928
+ tool_name: 'Agent',
929
+ tool_use_id: 'toolu_3667l',
930
+ tool_input: { subagent_type: 'worker', description: 'Sync reconcile', run_in_background: false },
931
+ }).status).toBe(0)
932
+
933
+ const postResult = runHook(POSTTOOL_SCRIPT, {
934
+ tool_name: 'Agent',
935
+ tool_use_id: 'toolu_3667l',
936
+ tool_response: {
937
+ status: 'completed',
938
+ prompt: 'Reconcile yesterday…',
939
+ agentId: 'a1b2c3',
940
+ agentType: 'general-purpose',
941
+ content: [{ type: 'text', text: 'Analysis complete. 15 contacts checked.' }],
942
+ resolvedModel: 'claude-opus-5',
943
+ totalDurationMs: 490_293,
944
+ totalTokens: 120_000,
945
+ totalToolUseCount: 42,
946
+ usage: {},
947
+ toolStats: {},
948
+ },
949
+ })
950
+ expect(postResult.status).toBe(0)
951
+ expect(postResult.stderr).not.toContain('unrecognised Agent tool_response shape')
952
+ expect(postResult.stdout).toContain('additionalContext')
953
+
954
+ const db = openDb()
955
+ const row = db
956
+ .prepare('SELECT status, ended_at, result_summary FROM subagents WHERE id = ?')
957
+ .get('toolu_3667l') as { status: string; ended_at: number | null; result_summary: string | null }
958
+ expect(row.status).toBe('completed')
959
+ expect(row.ended_at).not.toBeNull()
960
+ expect(row.result_summary).toContain('15 contacts checked')
961
+ })
962
+
963
+ it('reads the structured status BEFORE the prose ACK, so a report about async launches still terminalizes', () => {
964
+ // Self-referential trap: isAsyncLaunchAck() pattern-matches the flattened
965
+ // response TEXT, and on a real completion that text is the worker's own
966
+ // report. A worker reporting on sub-agent dispatch — e.g. one reviewing
967
+ // this very hook — quotes the ACK phrase verbatim, which classified the
968
+ // completed worker as a fresh LAUNCH: promoted to background, never
969
+ // terminalized, no handback. The structured `status` must settle it first.
970
+ expect(runHook(PRETOOL_SCRIPT, {
971
+ session_id: 's-3667m',
972
+ tool_name: 'Agent',
973
+ tool_use_id: 'toolu_3667m',
974
+ tool_input: { subagent_type: 'worker', description: 'Review the ACK detector', run_in_background: false },
975
+ }).status).toBe(0)
976
+
977
+ const postResult = runHook(POSTTOOL_SCRIPT, {
978
+ tool_name: 'Agent',
979
+ tool_use_id: 'toolu_3667m',
980
+ tool_response: {
981
+ status: 'completed',
982
+ totalDurationMs: 61_000,
983
+ content: [{
984
+ type: 'text',
985
+ text: 'Reviewed the detector. The canonical ACK is "Async agent launched '
986
+ + 'successfully", followed by\nagentId: aec3ddbae614f85f5\nand a line saying '
987
+ + 'the agent is working in the background.',
988
+ }],
989
+ },
990
+ })
991
+ expect(postResult.status).toBe(0)
992
+
993
+ const db = openDb()
994
+ const row = db
995
+ .prepare('SELECT status, ended_at, background FROM subagents WHERE id = ?')
996
+ .get('toolu_3667m') as { status: string; ended_at: number | null; background: number }
997
+ expect(row.status).toBe('completed')
998
+ expect(row.ended_at).not.toBeNull()
999
+ expect(row.background).toBe(0)
1000
+ })
1001
+
1002
+ it('still terminalizes a genuine foreground completion (control)', () => {
1003
+ // The fix must not swing the other way: a real sync completion still ends
1004
+ // the row, with its result summary and a handback nudge.
1005
+ expect(runHook(PRETOOL_SCRIPT, {
1006
+ session_id: 's-3667f',
1007
+ tool_name: 'Agent',
1008
+ tool_use_id: 'toolu_3667f',
1009
+ tool_input: { subagent_type: 'worker', description: 'Sync task', run_in_background: false },
1010
+ }).status).toBe(0)
1011
+
1012
+ const postResult = runHook(POSTTOOL_SCRIPT, {
1013
+ tool_name: 'Agent',
1014
+ tool_use_id: 'toolu_3667f',
1015
+ tool_response: { content: [{ type: 'text', text: 'All done: 3 files changed.' }] },
1016
+ })
1017
+ expect(postResult.status).toBe(0)
1018
+ expect(postResult.stdout).toContain('additionalContext')
1019
+
1020
+ const db = openDb()
1021
+ const row = db
1022
+ .prepare('SELECT status, ended_at, background, result_summary FROM subagents WHERE id = ?')
1023
+ .get('toolu_3667f') as {
1024
+ status: string
1025
+ ended_at: number | null
1026
+ background: number
1027
+ result_summary: string | null
1028
+ }
1029
+ expect(row.status).toBe('completed')
1030
+ expect(row.ended_at).not.toBeNull()
1031
+ expect(row.background).toBe(0)
1032
+ expect(row.result_summary).toContain('3 files changed')
1033
+ })
1034
+ })
1035
+
1036
+ describe('subagent-tracker-posttool — never throws on a malformed event', () => {
1037
+ // This hook runs on EVERY PostToolUse across the fleet. An uncaught throw
1038
+ // exits non-zero, and bin/run-hook.sh turns a non-zero hook exit into a red
1039
+ // issue card — so a payload the hook was always going to ignore must not be
1040
+ // able to manufacture one. `JSON.parse('null')` in particular parses fine
1041
+ // and then `null.tool_name` threw a TypeError (pre-existing on main).
1042
+ const BAD_BODIES = ['null', '42', '"a string"', 'true', '[]', '[1,2,3]', '{}', 'not json at all', '']
1043
+
1044
+ for (const body of BAD_BODIES) {
1045
+ it(`exits 0 with no output on stdin ${JSON.stringify(body)}`, () => {
1046
+ const result = spawnSync(process.execPath, [POSTTOOL_SCRIPT], {
1047
+ input: body,
1048
+ encoding: 'utf8',
1049
+ env: { ...process.env, SWITCHROOM_AGENT_DIR: agentDir },
1050
+ timeout: 15_000,
1051
+ })
1052
+ expect(result.status).toBe(0)
1053
+ expect(result.stdout).toBe('')
1054
+ expect(result.stderr).not.toContain('TypeError')
1055
+ })
1056
+ }
1057
+
1058
+ it('exits 0 when tool_use_id is missing or falsy', () => {
1059
+ for (const event of [
1060
+ { tool_name: 'Agent' },
1061
+ { tool_name: 'Agent', tool_use_id: null },
1062
+ { tool_name: 'Agent', tool_use_id: '', tool_response: { result: 'x' } },
1063
+ ]) {
1064
+ const result = runHook(POSTTOOL_SCRIPT, event)
1065
+ expect(result.status).toBe(0)
1066
+ expect(result.stderr).not.toContain('TypeError')
1067
+ }
1068
+ })
1069
+ })
1070
+
1071
+ describe('subagent-tracker-posttool — concurrent dispatch (#3667)', () => {
1072
+ it('promotes every row correctly when 12 posttools race on one DB', () => {
1073
+ // A parent fanning out workers fires many PostToolUse hooks against the
1074
+ // same registry.db within milliseconds. Each must land: SQLITE_BUSY
1075
+ // swallowed as a lost update would silently leave rows unpromoted and
1076
+ // back in the #3667 failure mode.
1077
+ const ids = Array.from({ length: 12 }, (_, i) => `toolu_conc_${i}`)
1078
+ for (const id of ids) {
1079
+ expect(runHook(PRETOOL_SCRIPT, {
1080
+ session_id: 's-conc',
1081
+ tool_name: 'Agent',
1082
+ tool_use_id: id,
1083
+ tool_input: { subagent_type: 'worker', description: id },
1084
+ }).status).toBe(0)
1085
+ }
1086
+
1087
+ const results = ids.map((id) => runHook(POSTTOOL_SCRIPT, {
1088
+ tool_name: 'Agent',
1089
+ tool_use_id: id,
1090
+ tool_response: { ...PROD_ASYNC_LAUNCH_RESPONSE, agentId: id },
1091
+ }))
1092
+ for (const r of results) expect(r.status).toBe(0)
1093
+
1094
+ const db = openDb()
1095
+ const rows = db
1096
+ .prepare("SELECT status, background, ended_at FROM subagents WHERE id LIKE 'toolu_conc_%'")
1097
+ .all() as Array<{ status: string; background: number; ended_at: number | null }>
1098
+ expect(rows.length).toBe(ids.length)
1099
+ for (const row of rows) {
1100
+ expect(row.status).toBe('running')
1101
+ expect(row.background).toBe(1)
1102
+ expect(row.ended_at).toBeNull()
1103
+ }
1104
+ })
1105
+
1106
+ it('is idempotent when the same row gets several posttool invocations', () => {
1107
+ // Repeat delivery must not flip a promoted row back, nor terminalize it.
1108
+ expect(runHook(PRETOOL_SCRIPT, {
1109
+ session_id: 's-idem',
1110
+ tool_name: 'Agent',
1111
+ tool_use_id: 'toolu_idem',
1112
+ tool_input: { subagent_type: 'worker', description: 'repeat' },
1113
+ }).status).toBe(0)
1114
+
1115
+ for (let i = 0; i < 4; i++) {
1116
+ expect(runHook(POSTTOOL_SCRIPT, {
1117
+ tool_name: 'Agent',
1118
+ tool_use_id: 'toolu_idem',
1119
+ tool_response: PROD_ASYNC_LAUNCH_RESPONSE,
1120
+ }).status).toBe(0)
1121
+ }
1122
+
1123
+ const db = openDb()
1124
+ const row = db
1125
+ .prepare('SELECT status, background, ended_at FROM subagents WHERE id = ?')
1126
+ .get('toolu_idem') as { status: string; background: number; ended_at: number | null }
1127
+ expect(row.status).toBe('running')
1128
+ expect(row.background).toBe(1)
1129
+ expect(row.ended_at).toBeNull()
1130
+ })
1131
+ })
1132
+
1133
+ describe('subagent-tracker-posttool — runtime-path parity (#3667)', () => {
1134
+ // updateRow has TWO implementations of the same decision: a node:sqlite /
1135
+ // bun:sqlite branch and a sqlite3-CLI fallback. The tests above only ever
1136
+ // exercise the first (bun always resolves a sync binding), so a divergence
1137
+ // in the fallback is invisible to behavioural testing — which is how it got
1138
+ // there: the CLI copy of the conditional fell through to the TERMINAL
1139
+ // update for any `kind` it did not enumerate, while the sync copy fell
1140
+ // through to the non-terminal one. A future fourth kind would have
1141
+ // terminalized live rows on the fallback runtime only.
1142
+ //
1143
+ // Static guard: both branches must dispatch on the shared chooseUpdate()
1144
+ // helper, and neither may re-derive the choice from `kind` inline.
1145
+ const SOURCE = readFileSync(POSTTOOL_SCRIPT, 'utf8')
1146
+
1147
+ it('defines chooseUpdate as the single source of truth for the update path', () => {
1148
+ expect(SOURCE).toContain('function chooseUpdate(')
1149
+ // Once for the sync branch, once for the CLI fallback — and nowhere else.
1150
+ const callSites = SOURCE.match(/const choice = chooseUpdate\(/g) ?? []
1151
+ expect(callSites.length).toBe(2)
1152
+ })
1153
+
1154
+ it('never re-derives the update path from `kind` inside updateRow', () => {
1155
+ const updateRowBody = SOURCE.slice(
1156
+ SOURCE.indexOf('function updateRow('),
1157
+ SOURCE.indexOf('// Foreground handback nudge'),
1158
+ )
1159
+ expect(updateRowBody.length).toBeGreaterThan(0)
1160
+ // No comparison against a KIND_* constant may survive inside updateRow —
1161
+ // that is exactly the duplicated conditional chooseUpdate replaced.
1162
+ expect(updateRowBody).not.toMatch(/snapKind\s*===\s*KIND_/)
1163
+ expect(updateRowBody).not.toMatch(/kind\s*===\s*KIND_/)
1164
+ })
1165
+
1166
+ it('fails SAFE (non-terminal) for a kind neither branch enumerates', () => {
1167
+ // chooseUpdate's contract, asserted on the source: only an explicit
1168
+ // KIND_COMPLETION may reach the terminal update.
1169
+ const body = SOURCE.slice(
1170
+ SOURCE.indexOf('function chooseUpdate('),
1171
+ SOURCE.indexOf('function chooseUpdate(') + 400,
1172
+ )
1173
+ expect(body).toContain("if (kind === KIND_COMPLETION) return 'foreground'")
1174
+ // The trailing arm — everything unrecognised — must NOT be 'foreground'.
1175
+ expect(body).toMatch(/return 'promote'\s*\n\}/)
1176
+ })
1177
+ })
1178
+
549
1179
  describe('agent-dir resolution (RFC §Bug 2)', () => {
550
1180
  // The hooks used to look only at SWITCHROOM_AGENT_DIR and then cwd.
551
1181
  // In production neither matched the path the gateway + watcher used,