switchroom 0.5.0 → 0.7.9

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 (89) hide show
  1. package/README.md +142 -121
  2. package/bin/autoaccept.exp +29 -6
  3. package/dist/agent-scheduler/index.js +12261 -0
  4. package/dist/cli/autoaccept-poll.js +10 -0
  5. package/dist/cli/switchroom.js +27250 -25324
  6. package/dist/vault/approvals/kernel-server.js +12709 -0
  7. package/dist/vault/broker/server.js +15724 -0
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +133 -0
  10. package/profiles/_shared/telegram-style.md.hbs +3 -3
  11. package/profiles/default/CLAUDE.md +3 -3
  12. package/profiles/default/CLAUDE.md.hbs +2 -2
  13. package/profiles/default/workspace/CLAUDE.md.hbs +9 -0
  14. package/skills/docx/VENDORED.md +1 -1
  15. package/skills/mcp-builder/VENDORED.md +1 -1
  16. package/skills/pdf/VENDORED.md +1 -1
  17. package/skills/pptx/VENDORED.md +1 -1
  18. package/skills/skill-creator/VENDORED.md +1 -1
  19. package/skills/switchroom-architecture/SKILL.md +8 -7
  20. package/skills/switchroom-cli/SKILL.md +23 -15
  21. package/skills/switchroom-health/SKILL.md +7 -7
  22. package/skills/switchroom-install/SKILL.md +36 -39
  23. package/skills/switchroom-manage/SKILL.md +4 -4
  24. package/skills/switchroom-status/SKILL.md +1 -1
  25. package/skills/webapp-testing/VENDORED.md +1 -1
  26. package/skills/xlsx/VENDORED.md +1 -1
  27. package/telegram-plugin/admin-commands/dispatch.test.ts +119 -1
  28. package/telegram-plugin/admin-commands/index.ts +71 -0
  29. package/telegram-plugin/ask-user.ts +1 -0
  30. package/telegram-plugin/card-event-log.ts +138 -0
  31. package/telegram-plugin/dist/bridge/bridge.js +178 -31
  32. package/telegram-plugin/dist/foreman/foreman.js +6875 -6526
  33. package/telegram-plugin/dist/gateway/gateway.js +13862 -11834
  34. package/telegram-plugin/dist/server.js +202 -40
  35. package/telegram-plugin/fleet-state.ts +25 -10
  36. package/telegram-plugin/foreman/foreman.ts +38 -3
  37. package/telegram-plugin/gateway/approval-callback.ts +126 -0
  38. package/telegram-plugin/gateway/approval-card.test.ts +90 -0
  39. package/telegram-plugin/gateway/approval-card.ts +127 -0
  40. package/telegram-plugin/gateway/approvals-commands.ts +126 -0
  41. package/telegram-plugin/gateway/boot-card.ts +31 -6
  42. package/telegram-plugin/gateway/boot-probes.ts +510 -72
  43. package/telegram-plugin/gateway/gateway.ts +822 -94
  44. package/telegram-plugin/gateway/ipc-protocol.ts +34 -1
  45. package/telegram-plugin/gateway/ipc-server.ts +35 -0
  46. package/telegram-plugin/gateway/startup-mutex.ts +110 -2
  47. package/telegram-plugin/hooks/hooks.json +19 -0
  48. package/telegram-plugin/hooks/tool-label-pretool.mjs +216 -0
  49. package/telegram-plugin/hooks/tool-label-stop.mjs +63 -0
  50. package/telegram-plugin/package.json +4 -1
  51. package/telegram-plugin/plugin-logger.ts +20 -1
  52. package/telegram-plugin/progress-card-driver.ts +202 -13
  53. package/telegram-plugin/progress-card.ts +2 -2
  54. package/telegram-plugin/quota-check.ts +1 -0
  55. package/telegram-plugin/registry/subagents-schema.ts +37 -0
  56. package/telegram-plugin/registry/subagents.test.ts +64 -0
  57. package/telegram-plugin/session-tail.ts +58 -5
  58. package/telegram-plugin/shared/bot-runtime.ts +48 -2
  59. package/telegram-plugin/subagent-watcher.ts +139 -7
  60. package/telegram-plugin/tests/_progress-card-harness.ts +4 -0
  61. package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +201 -0
  62. package/telegram-plugin/tests/boot-card-probe-target.test.ts +10 -34
  63. package/telegram-plugin/tests/boot-card-render.test.ts +6 -5
  64. package/telegram-plugin/tests/boot-probes.test.ts +564 -0
  65. package/telegram-plugin/tests/card-event-log.test.ts +145 -0
  66. package/telegram-plugin/tests/gateway-startup-mutex.test.ts +102 -0
  67. package/telegram-plugin/tests/ipc-server-validate-inject-inbound.test.ts +134 -0
  68. package/telegram-plugin/tests/progress-card-delay-842.test.ts +160 -0
  69. package/telegram-plugin/tests/quota-check.test.ts +37 -1
  70. package/telegram-plugin/tests/subagent-registry-bugs.test.ts +5 -0
  71. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +104 -1
  72. package/telegram-plugin/tests/subagent-watcher.test.ts +5 -0
  73. package/telegram-plugin/tests/tool-label-sidecar.test.ts +114 -0
  74. package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +5 -3
  75. package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +10 -0
  76. package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +58 -14
  77. package/telegram-plugin/tests/welcome-text.test.ts +57 -0
  78. package/telegram-plugin/tool-label-sidecar.ts +140 -0
  79. package/telegram-plugin/tool-labels.ts +55 -0
  80. package/telegram-plugin/two-zone-card.ts +27 -7
  81. package/telegram-plugin/uat/SETUP.md +160 -0
  82. package/telegram-plugin/uat/assertions.ts +140 -0
  83. package/telegram-plugin/uat/driver.ts +174 -0
  84. package/telegram-plugin/uat/harness.ts +161 -0
  85. package/telegram-plugin/uat/login.ts +134 -0
  86. package/telegram-plugin/uat/port-allocator.ts +71 -0
  87. package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +61 -0
  88. package/telegram-plugin/welcome-text.ts +44 -2
  89. package/bin/bridge-watchdog.sh +0 -967
@@ -13,8 +13,17 @@ import { join } from 'path'
13
13
 
14
14
  import {
15
15
  probeAgentProcess,
16
+ probeScheduler,
17
+ probeBroker,
18
+ probeKernel,
19
+ probeSkills,
16
20
  probeQuota,
17
21
  watchAgentProcess,
22
+ findAgentProcessInContainer,
23
+ uptimeMsForStarttime,
24
+ type ProcFsImpl,
25
+ type SchedulerFsImpl,
26
+ type SkillsFsImpl,
18
27
  } from '../gateway/boot-probes.js'
19
28
  import { readQuotaCache, RATE_LIMIT_TTL_MS } from '../gateway/quota-cache.js'
20
29
 
@@ -448,4 +457,559 @@ describe('watchAgentProcess — #296: re-poll after window expiry', () => {
448
457
  expect(yields[0].status).toBe('ok')
449
458
  expect(extraCalls).toBe(1) // only the initial probe; no follow-up
450
459
  })
460
+
461
+ // ── docker mode: skip systemctl, use /proc walk ───────────────────────────
462
+
463
+ describe('probeAgentProcess — docker mode skips systemctl', () => {
464
+ it('probeAgentProcess(dockerMode) returns the injected /proc result without execing', async () => {
465
+ let execFileCalls = 0
466
+ const execFileImpl: ExecFileFn = async () => {
467
+ execFileCalls++
468
+ throw new Error('systemctl should never be called under dockerMode')
469
+ }
470
+ const dockerProbeImpl = () => ({
471
+ status: 'ok' as const,
472
+ label: 'Agent',
473
+ detail: 'PID 42 · up 3.0s · 128 MB',
474
+ })
475
+ const result = await probeAgentProcess('clerk', {
476
+ dockerMode: true,
477
+ dockerProbeImpl,
478
+ execFileImpl: execFileImpl as never,
479
+ sleepImpl: noopSleep,
480
+ retryIntervalMs: 1,
481
+ retryMaxMs: 5,
482
+ })
483
+ expect(result.status).toBe('ok')
484
+ expect(result.label).toBe('Agent')
485
+ expect(result.detail).toBe('PID 42 · up 3.0s · 128 MB')
486
+ expect(execFileCalls).toBe(0)
487
+ })
488
+
489
+ it('probeAgentProcess(dockerMode) surfaces fail when no claude process found', async () => {
490
+ const dockerProbeImpl = () => ({
491
+ status: 'fail' as const,
492
+ label: 'Agent',
493
+ detail: 'claude process not found',
494
+ })
495
+ const result = await probeAgentProcess('clerk', {
496
+ dockerMode: true,
497
+ dockerProbeImpl,
498
+ })
499
+ expect(result.status).toBe('fail')
500
+ expect(result.detail).toBe('claude process not found')
501
+ })
502
+
503
+ it('watchAgentProcess(dockerMode) yields the /proc result once and exits', async () => {
504
+ let execFileCalls = 0
505
+ const execFileImpl: ExecFileFn = async () => {
506
+ execFileCalls++
507
+ return { stdout: '', stderr: '' }
508
+ }
509
+ const dockerProbeImpl = () => ({
510
+ status: 'ok' as const,
511
+ label: 'Agent',
512
+ detail: 'PID 42 · up 5.0s · 200 MB',
513
+ })
514
+ const yields: Array<{ status: string; detail: string }> = []
515
+ const gen = watchAgentProcess('clerk', {
516
+ dockerMode: true,
517
+ dockerProbeImpl,
518
+ execFileImpl: execFileImpl as never,
519
+ sleepImpl: noopSleep,
520
+ liveWindowMs: 1000,
521
+ pollIntervalMs: 10,
522
+ followupRepollMs: 0,
523
+ })
524
+ for await (const r of gen) yields.push({ status: r.status, detail: r.detail })
525
+ expect(yields).toHaveLength(1)
526
+ expect(yields[0].status).toBe('ok')
527
+ expect(execFileCalls).toBe(0)
528
+ })
529
+
530
+ })
531
+
532
+ // ── probeScheduler — in-container agent-scheduler (Phase 4 cron-fold-in) ──
533
+
534
+ function makeSchedulerFs(files: Record<string, { content?: string; mtimeMs?: number }>): SchedulerFsImpl {
535
+ return {
536
+ readFile: (p) => {
537
+ const f = files[p]
538
+ if (!f || f.content === undefined) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
539
+ return f.content
540
+ },
541
+ mtimeMs: (p) => {
542
+ const f = files[p]
543
+ if (!f || f.mtimeMs === undefined) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
544
+ return f.mtimeMs
545
+ },
546
+ exists: (p) => p in files,
547
+ }
548
+ }
549
+
550
+ describe('probeScheduler', () => {
551
+ it('returns ok n/a when not in dockerMode (Phase 4 deleted host-side scheduler)', async () => {
552
+ const result = await probeScheduler('clerk', { dockerMode: false })
553
+ expect(result.status).toBe('ok')
554
+ expect(result.label).toBe('Scheduler')
555
+ expect(result.detail).toContain('non-docker')
556
+ })
557
+
558
+ it('fails when lockfile is missing (sidecar never started or supervisor gave up)', async () => {
559
+ const fs = makeSchedulerFs({})
560
+ const result = await probeScheduler('clerk', {
561
+ dockerMode: true,
562
+ fs,
563
+ isAlive: () => true,
564
+ // Disable settle softening so the verdict is the hard fail —
565
+ // the "boot still settling" case is covered by the dedicated
566
+ // test below.
567
+ containerBootTimeMs: null,
568
+ })
569
+ expect(result.status).toBe('fail')
570
+ expect(result.detail).toContain('no lockfile')
571
+ })
572
+
573
+ it('softens fail to degraded when container booted recently (still-settling window)', async () => {
574
+ // No lockfile, but container PID 1 started 5 s ago — the supervisor
575
+ // is still bringing the scheduler up. /status hit at this instant
576
+ // should NOT show 🔴 for a non-issue.
577
+ const now = 1_700_000_000_000
578
+ const fs = makeSchedulerFs({})
579
+ const result = await probeScheduler('clerk', {
580
+ dockerMode: true,
581
+ fs,
582
+ isAlive: () => true,
583
+ now: () => now,
584
+ containerBootTimeMs: now - 5_000,
585
+ })
586
+ expect(result.status).toBe('degraded')
587
+ expect(result.detail).toContain('still settling')
588
+ })
589
+
590
+ it('does NOT soften when container booted long ago (real wedge, not a flap)', async () => {
591
+ // Container has been up for an hour; missing lockfile is a real
592
+ // problem, not a settle window. Hard fail expected.
593
+ const now = 1_700_000_000_000
594
+ const fs = makeSchedulerFs({})
595
+ const result = await probeScheduler('clerk', {
596
+ dockerMode: true,
597
+ fs,
598
+ isAlive: () => true,
599
+ now: () => now,
600
+ containerBootTimeMs: now - 60 * 60_000,
601
+ })
602
+ expect(result.status).toBe('fail')
603
+ expect(result.detail).not.toContain('still settling')
604
+ })
605
+
606
+ it('honors SWITCHROOM_AGENT_SCHEDULER_LOCK env override', async () => {
607
+ const customLock = '/custom/sched.lock'
608
+ const fs = makeSchedulerFs({
609
+ [customLock]: { content: '1234' },
610
+ })
611
+ const oldEnv = process.env.SWITCHROOM_AGENT_SCHEDULER_LOCK
612
+ process.env.SWITCHROOM_AGENT_SCHEDULER_LOCK = customLock
613
+ try {
614
+ const result = await probeScheduler('clerk', {
615
+ dockerMode: true,
616
+ fs,
617
+ isAlive: (pid) => pid === 1234,
618
+ containerBootTimeMs: null,
619
+ })
620
+ expect(result.status).toBe('ok')
621
+ expect(result.detail).toContain('pid 1234')
622
+ } finally {
623
+ if (oldEnv === undefined) delete process.env.SWITCHROOM_AGENT_SCHEDULER_LOCK
624
+ else process.env.SWITCHROOM_AGENT_SCHEDULER_LOCK = oldEnv
625
+ }
626
+ })
627
+
628
+ it('honors SWITCHROOM_AGENT_SCHEDULER_JSONL env override for freshness hint', async () => {
629
+ const lockPath = '/state/agent/scheduler.lock'
630
+ const customJsonl = '/custom/audit.jsonl'
631
+ const now = 1_700_000_000_000
632
+ const fs = makeSchedulerFs({
633
+ [lockPath]: { content: '5555' },
634
+ [customJsonl]: { content: '{}\n', mtimeMs: now - 90_000 },
635
+ })
636
+ const oldEnv = process.env.SWITCHROOM_AGENT_SCHEDULER_JSONL
637
+ process.env.SWITCHROOM_AGENT_SCHEDULER_JSONL = customJsonl
638
+ try {
639
+ const result = await probeScheduler('clerk', {
640
+ dockerMode: true,
641
+ fs,
642
+ isAlive: () => true,
643
+ now: () => now,
644
+ containerBootTimeMs: null,
645
+ })
646
+ expect(result.status).toBe('ok')
647
+ expect(result.detail).toContain('last fire')
648
+ } finally {
649
+ if (oldEnv === undefined) delete process.env.SWITCHROOM_AGENT_SCHEDULER_JSONL
650
+ else process.env.SWITCHROOM_AGENT_SCHEDULER_JSONL = oldEnv
651
+ }
652
+ })
653
+
654
+ it('returns ok with last-fire age when lock is held by a live PID', async () => {
655
+ const lockPath = '/state/agent/scheduler.lock'
656
+ const jsonlPath = '/state/agent/scheduler.jsonl'
657
+ const now = 1_700_000_000_000
658
+ const fs = makeSchedulerFs({
659
+ [lockPath]: { content: '4242\n' },
660
+ [jsonlPath]: { content: '{}\n', mtimeMs: now - 5 * 60_000 },
661
+ })
662
+ const result = await probeScheduler('clerk', {
663
+ dockerMode: true,
664
+ fs,
665
+ isAlive: (pid) => pid === 4242,
666
+ now: () => now,
667
+ })
668
+ expect(result.status).toBe('ok')
669
+ expect(result.detail).toContain('pid 4242')
670
+ expect(result.detail).toContain('last fire')
671
+ })
672
+
673
+ it('degraded when lockfile holder PID is not alive (mid-restart)', async () => {
674
+ const lockPath = '/state/agent/scheduler.lock'
675
+ const fs = makeSchedulerFs({
676
+ [lockPath]: { content: '99\n' },
677
+ })
678
+ const result = await probeScheduler('clerk', {
679
+ dockerMode: true,
680
+ fs,
681
+ isAlive: () => false,
682
+ })
683
+ expect(result.status).toBe('degraded')
684
+ expect(result.detail).toContain('pid 99 not alive')
685
+ })
686
+
687
+ it('degraded when lockfile contents are not a valid PID', async () => {
688
+ const lockPath = '/state/agent/scheduler.lock'
689
+ const fs = makeSchedulerFs({
690
+ [lockPath]: { content: 'garbage' },
691
+ })
692
+ const result = await probeScheduler('clerk', {
693
+ dockerMode: true,
694
+ fs,
695
+ isAlive: () => true,
696
+ })
697
+ expect(result.status).toBe('degraded')
698
+ expect(result.detail).toContain('invalid')
699
+ })
700
+
701
+ it('returns ok without freshness hint when scheduler.jsonl has never been written', async () => {
702
+ const lockPath = '/state/agent/scheduler.lock'
703
+ const fs = makeSchedulerFs({
704
+ [lockPath]: { content: '5555' },
705
+ })
706
+ const result = await probeScheduler('clerk', {
707
+ dockerMode: true,
708
+ fs,
709
+ isAlive: () => true,
710
+ })
711
+ expect(result.status).toBe('ok')
712
+ expect(result.detail).toContain('pid 5555')
713
+ expect(result.detail).not.toContain('last fire')
714
+ })
715
+ })
716
+
717
+ // ── probeBroker / probeKernel — UDS reachability ─────────────────────────
718
+
719
+ describe('probeBroker / probeKernel', () => {
720
+ it('probeBroker returns ok n/a when not in dockerMode', async () => {
721
+ const result = await probeBroker('/some/path', { dockerMode: false })
722
+ expect(result.status).toBe('ok')
723
+ expect(result.detail).toContain('non-docker')
724
+ })
725
+
726
+ it('probeBroker fails when no socket path is configured', async () => {
727
+ // SWITCHROOM_VAULT_BROKER_SOCK is the canonical client-side env
728
+ // var, matches what src/vault/broker/client.ts and the
729
+ // secret-guard hook read. (Pre-fix the probe used the wrong name
730
+ // SWITCHROOM_BROKER_SOCKET — the broker server's bind-path env —
731
+ // which compose was setting in agent containers but no client
732
+ // ever read.)
733
+ const oldEnv = process.env.SWITCHROOM_VAULT_BROKER_SOCK
734
+ delete process.env.SWITCHROOM_VAULT_BROKER_SOCK
735
+ try {
736
+ const result = await probeBroker(undefined, { dockerMode: true })
737
+ expect(result.status).toBe('fail')
738
+ expect(result.detail).toContain('not configured')
739
+ } finally {
740
+ if (oldEnv !== undefined) process.env.SWITCHROOM_VAULT_BROKER_SOCK = oldEnv
741
+ }
742
+ })
743
+
744
+ it('probeBroker reports ok when connect resolves', async () => {
745
+ const result = await probeBroker('/run/switchroom/broker/clerk/sock', {
746
+ dockerMode: true,
747
+ connectImpl: async () => { /* connect ok */ },
748
+ })
749
+ expect(result.status).toBe('ok')
750
+ expect(result.label).toBe('Broker')
751
+ expect(result.detail).toBe('reachable')
752
+ })
753
+
754
+ it('probeBroker reports fail with ENOENT detail when socket missing', async () => {
755
+ const result = await probeBroker('/run/switchroom/broker/clerk/sock', {
756
+ dockerMode: true,
757
+ connectImpl: async () => {
758
+ const err = new Error('ENOENT') as NodeJS.ErrnoException
759
+ err.code = 'ENOENT'
760
+ throw err
761
+ },
762
+ })
763
+ expect(result.status).toBe('fail')
764
+ expect(result.detail).toContain('socket missing')
765
+ })
766
+
767
+ it('probeBroker reports fail with ECONNREFUSED detail when daemon down', async () => {
768
+ const result = await probeBroker('/run/switchroom/broker/clerk/sock', {
769
+ dockerMode: true,
770
+ connectImpl: async () => {
771
+ const err = new Error('ECONNREFUSED') as NodeJS.ErrnoException
772
+ err.code = 'ECONNREFUSED'
773
+ throw err
774
+ },
775
+ })
776
+ expect(result.status).toBe('fail')
777
+ expect(result.detail).toContain('connection refused')
778
+ })
779
+
780
+ it('probeKernel mirrors probeBroker shape with Kernel label', async () => {
781
+ const result = await probeKernel('/run/switchroom/kernel/clerk/sock', {
782
+ dockerMode: true,
783
+ connectImpl: async () => { /* connect ok */ },
784
+ })
785
+ expect(result.status).toBe('ok')
786
+ expect(result.label).toBe('Kernel')
787
+ expect(result.detail).toBe('reachable')
788
+ })
789
+ })
790
+
791
+ // ── probeSkills — symlink validity ───────────────────────────────────────
792
+
793
+ function makeSkillsFs(entries: Record<string, string[]>, files: Set<string>): SkillsFsImpl {
794
+ return {
795
+ readdir: (p) => {
796
+ if (!(p in entries)) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
797
+ return entries[p]
798
+ },
799
+ exists: (p) => files.has(p) || p in entries,
800
+ }
801
+ }
802
+
803
+ describe('probeSkills', () => {
804
+ const agentDir = '/state/agent'
805
+ const skillsDir = '/state/agent/.claude/skills'
806
+
807
+ it('returns ok when no skills dir exists (no skills configured is normal)', async () => {
808
+ const fs = makeSkillsFs({}, new Set())
809
+ const result = await probeSkills(agentDir, { fs })
810
+ expect(result.status).toBe('ok')
811
+ expect(result.detail).toContain('no skills dir')
812
+ })
813
+
814
+ it('returns ok with count when every skill resolves', async () => {
815
+ const fs = makeSkillsFs(
816
+ { [skillsDir]: ['simplify', 'review'] },
817
+ new Set([
818
+ `${skillsDir}/simplify`, `${skillsDir}/simplify/SKILL.md`,
819
+ `${skillsDir}/review`, `${skillsDir}/review/SKILL.md`,
820
+ ]),
821
+ )
822
+ const result = await probeSkills(agentDir, { fs })
823
+ expect(result.status).toBe('ok')
824
+ expect(result.detail).toContain('2 resolved')
825
+ })
826
+
827
+ it('degraded when at least one symlink dangles, names them up to cap', async () => {
828
+ const fs = makeSkillsFs(
829
+ { [skillsDir]: ['simplify', 'gone-skill', 'also-gone', 'and-this', 'review'] },
830
+ new Set([
831
+ `${skillsDir}/simplify`, `${skillsDir}/simplify/SKILL.md`,
832
+ `${skillsDir}/review`, `${skillsDir}/review/SKILL.md`,
833
+ // gone-skill, also-gone, and-this NOT in files set → dangling
834
+ ]),
835
+ )
836
+ const result = await probeSkills(agentDir, { fs, maxNamesShown: 2 })
837
+ expect(result.status).toBe('degraded')
838
+ expect(result.detail).toContain('3/5 dangling')
839
+ expect(result.detail).toContain('gone-skill')
840
+ expect(result.detail).toContain('also-gone')
841
+ expect(result.detail).toContain('+1 more')
842
+ expect(result.detail).not.toContain('and-this')
843
+ })
844
+
845
+ it('returns ok when entries dir is empty', async () => {
846
+ const fs = makeSkillsFs({ [skillsDir]: [] }, new Set([skillsDir]))
847
+ // Make exists() report true for the dir itself
848
+ const wrappedFs: SkillsFsImpl = {
849
+ readdir: fs.readdir,
850
+ exists: (p) => p === skillsDir || fs.exists(p),
851
+ }
852
+ const result = await probeSkills(agentDir, { fs: wrappedFs })
853
+ expect(result.status).toBe('ok')
854
+ expect(result.detail).toBe('0 skills')
855
+ })
856
+ })
857
+
858
+ // ── /proc parser unit tests (synthetic fs) ────────────────────────────────
859
+
860
+ /** Build a /proc/<pid>/stat string for tests. */
861
+ function makeStat(pid: number, comm: string, starttime: number): string {
862
+ // Layout: pid (comm) state ppid pgrp session tty_nr tpgid flags
863
+ // minflt cminflt majflt cmajflt utime stime cutime cstime
864
+ // priority nice num_threads itrealvalue starttime ...
865
+ // We need starttime at field 22 (1-indexed). Pad fields 4..21 with zeros.
866
+ const middle = new Array(18).fill('0').join(' ')
867
+ return `${pid} (${comm}) S ${middle} ${starttime} 0 0 0 0\n`
868
+ }
869
+
870
+ function makeProcFs(
871
+ procs: Array<{ pid: number; comm: string; rssKb: number; starttime: number }>,
872
+ extraFiles: Record<string, string> = {},
873
+ ): ProcFsImpl {
874
+ const files: Record<string, string> = { ...extraFiles }
875
+ const dirEntries = ['1', '2', 'self', 'uptime', 'meminfo']
876
+ for (const p of procs) {
877
+ files[`/proc/${p.pid}/comm`] = `${p.comm}\n`
878
+ files[`/proc/${p.pid}/status`] = `Name:\t${p.comm}\nVmRSS:\t ${p.rssKb} kB\n`
879
+ files[`/proc/${p.pid}/stat`] = makeStat(p.pid, p.comm, p.starttime)
880
+ if (!dirEntries.includes(String(p.pid))) dirEntries.push(String(p.pid))
881
+ }
882
+ return {
883
+ readdir: (path: string) => {
884
+ if (path === '/proc') return dirEntries
885
+ throw new Error(`unexpected readdir: ${path}`)
886
+ },
887
+ readFile: (path: string) => {
888
+ if (path in files) return files[path]
889
+ throw new Error(`ENOENT: ${path}`)
890
+ },
891
+ }
892
+ }
893
+
894
+ describe('findAgentProcessInContainer — /proc parser', () => {
895
+ it('picks heaviest claude process across multiple candidates', () => {
896
+ const fs = makeProcFs([
897
+ { pid: 100, comm: 'claude', rssKb: 50_000, starttime: 1000 },
898
+ { pid: 101, comm: 'claude', rssKb: 200_000, starttime: 1100 },
899
+ { pid: 102, comm: 'bash', rssKb: 5_000, starttime: 900 },
900
+ { pid: 103, comm: 'tmux', rssKb: 2_000, starttime: 800 },
901
+ ])
902
+ const found = findAgentProcessInContainer(fs)
903
+ expect(found).not.toBeNull()
904
+ expect(found!.pid).toBe(101)
905
+ expect(found!.rssKb).toBe(200_000)
906
+ expect(found!.starttime).toBe(1100)
907
+ })
908
+
909
+ it('falls back to heaviest non-wrapper node when no claude exists', () => {
910
+ const fs = makeProcFs([
911
+ { pid: 200, comm: 'node', rssKb: 80_000, starttime: 500 },
912
+ { pid: 201, comm: 'node', rssKb: 30_000, starttime: 600 },
913
+ { pid: 202, comm: 'bash', rssKb: 5_000, starttime: 400 },
914
+ ])
915
+ const found = findAgentProcessInContainer(fs)
916
+ expect(found!.pid).toBe(200)
917
+ })
918
+
919
+ it('returns null when only wrappers exist', () => {
920
+ const fs = makeProcFs([
921
+ { pid: 1, comm: 'tini', rssKb: 500, starttime: 100 },
922
+ { pid: 50, comm: 'bash', rssKb: 1000, starttime: 200 },
923
+ { pid: 51, comm: 'tmux', rssKb: 800, starttime: 250 },
924
+ ])
925
+ const found = findAgentProcessInContainer(fs)
926
+ expect(found).toBeNull()
927
+ })
928
+
929
+ it('handles a comm containing parens via lastIndexOf(\")\")', () => {
930
+ // Tests are the only protection against off-by-one when comm is funky.
931
+ const procs = [{ pid: 300, comm: '(weird)comm', rssKb: 90_000, starttime: 1234 }]
932
+ const fs = makeProcFs(procs)
933
+ // Override stat with a path comm has parens — comm content goes inside (...)
934
+ // Note: the kernel actually wraps comm in single parens in /proc/<pid>/stat,
935
+ // so a comm of `(weird)comm` lands as `300 ((weird)comm) S ...` — making
936
+ // lastIndexOf the only safe parse anchor.
937
+ const fsWithFunky: ProcFsImpl = {
938
+ readdir: () => ['300', 'uptime'],
939
+ readFile: (path: string) => {
940
+ if (path === '/proc/300/comm') return '(weird)comm\n'
941
+ if (path === '/proc/300/status') return 'VmRSS:\t90000 kB\n'
942
+ if (path === '/proc/300/stat') return `300 ((weird)comm) S ${new Array(18).fill('0').join(' ')} 1234 0 0 0\n`
943
+ throw new Error(`ENOENT: ${path}`)
944
+ },
945
+ }
946
+ // Funky comm is neither 'claude' nor 'node', so it's filtered out.
947
+ expect(findAgentProcessInContainer(fsWithFunky)).toBeNull()
948
+ void fs; void procs
949
+ })
950
+
951
+ it('handles a claude comm with trailing parens correctly', () => {
952
+ const fs: ProcFsImpl = {
953
+ readdir: () => ['400', 'uptime'],
954
+ readFile: (path: string) => {
955
+ if (path === '/proc/400/comm') return 'claude\n'
956
+ if (path === '/proc/400/status') return 'VmRSS:\t 150000 kB\n'
957
+ // Use a `claude` comm followed by 18 zero pad fields then starttime=9999.
958
+ if (path === '/proc/400/stat') return `400 (claude) S ${new Array(18).fill('0').join(' ')} 9999 0 0 0\n`
959
+ throw new Error(`ENOENT: ${path}`)
960
+ },
961
+ }
962
+ const found = findAgentProcessInContainer(fs)
963
+ expect(found).not.toBeNull()
964
+ expect(found!.pid).toBe(400)
965
+ expect(found!.starttime).toBe(9999)
966
+ expect(found!.rssKb).toBe(150_000)
967
+ })
968
+
969
+ it('skips entries that fail to read', () => {
970
+ const fs: ProcFsImpl = {
971
+ readdir: () => ['1', '500'],
972
+ readFile: (path: string) => {
973
+ if (path === '/proc/500/comm') return 'claude\n'
974
+ if (path === '/proc/500/status') return 'VmRSS:\t10000 kB\n'
975
+ if (path === '/proc/500/stat') return `500 (claude) S ${new Array(18).fill('0').join(' ')} 7000 0 0 0\n`
976
+ throw new Error(`ENOENT: ${path}`) // PID 1 reads fail → skipped
977
+ },
978
+ }
979
+ const found = findAgentProcessInContainer(fs)
980
+ expect(found!.pid).toBe(500)
981
+ })
982
+ })
983
+
984
+ describe('uptimeMsForStarttime', () => {
985
+ it('computes uptime in ms from starttime ticks and /proc/uptime', () => {
986
+ const fs: ProcFsImpl = {
987
+ readdir: () => [],
988
+ readFile: (path: string) => {
989
+ if (path === '/proc/uptime') return '1234.56 1000.00\n'
990
+ throw new Error(`unexpected: ${path}`)
991
+ },
992
+ }
993
+ // boot uptime = 1234.56 s, starttime = 1000 ticks → 10 s in.
994
+ // Process uptime = 1234.56 - 10 = 1224.56 s = 1_224_560 ms.
995
+ expect(uptimeMsForStarttime(1000, fs)).toBe(1_224_560)
996
+ })
997
+
998
+ it('returns null if /proc/uptime is unreadable', () => {
999
+ const fs: ProcFsImpl = {
1000
+ readdir: () => [],
1001
+ readFile: () => { throw new Error('ENOENT') },
1002
+ }
1003
+ expect(uptimeMsForStarttime(1000, fs)).toBeNull()
1004
+ })
1005
+
1006
+ it('returns null when computed uptime is negative', () => {
1007
+ // starttime in the future relative to boot uptime → invalid.
1008
+ const fs: ProcFsImpl = {
1009
+ readdir: () => [],
1010
+ readFile: () => '5.0 0.0\n',
1011
+ }
1012
+ expect(uptimeMsForStarttime(99999999, fs)).toBeNull()
1013
+ })
1014
+ })
451
1015
  })