claude-code-session-manager 0.62.0 → 0.63.0

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 (68) hide show
  1. package/dist/assets/AgentLibrary-BeJJa_zv.js +1 -0
  2. package/dist/assets/History-mWbqemhZ.js +2 -0
  3. package/dist/assets/Hooks-D_8sciZu.js +3 -0
  4. package/dist/assets/HostBilko-6aUJ7Smz.js +1 -0
  5. package/dist/assets/Library-CG1LDXLw.js +46 -0
  6. package/dist/assets/ListDetail-CIIJOWwI.js +1 -0
  7. package/dist/assets/MarkdownEditor-D0-y8V-f.js +1 -0
  8. package/dist/assets/McpServers-Kc-djiVi.js +2 -0
  9. package/dist/assets/Memory-B60DGRTW.js +8 -0
  10. package/dist/assets/Panel-CbTPsYHq.js +1 -0
  11. package/dist/assets/Permissions-C8KrSfrd.js +3 -0
  12. package/dist/assets/Plugins-BT8EumHb.js +2 -0
  13. package/dist/assets/ProvenanceBadge-cEqPpFsT.js +1 -0
  14. package/dist/assets/Scheduler-DbKPd0or.js +14 -0
  15. package/dist/assets/ScopeSwitcher-BNesCJ_t.js +1 -0
  16. package/dist/assets/Settings-Bjt7AQ6G.js +3 -0
  17. package/dist/assets/SkillReferenceGraph-DzuaPDM-.js +46 -0
  18. package/dist/assets/Skills-CVL_4X3T.js +3 -0
  19. package/dist/assets/SystemPrompt-Ct__nFF1.js +1 -0
  20. package/dist/assets/TagLibrary-DmrTfw4D.js +1 -0
  21. package/dist/assets/{TiptapBody-Bn-LMce4.js → TiptapBody-DaS0M_Ni.js} +1 -1
  22. package/dist/assets/Toggle-C0a-6xV7.js +1 -0
  23. package/dist/assets/index-CO_7DroC.js +3066 -0
  24. package/dist/assets/{index-DOXvph-Z.css → index-LlWpj2VJ.css} +1 -1
  25. package/dist/assets/listSkills-QORduPIk.js +1 -0
  26. package/dist/assets/settingsSchema-B7dMJaix.js +3 -0
  27. package/dist/assets/skillFrontmatter-Dif5JIg7.js +10 -0
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -3
  30. package/src/main/__tests__/config-readText-bounded.test.cjs +84 -0
  31. package/src/main/__tests__/heapSnapshot.test.cjs +121 -0
  32. package/src/main/__tests__/historyAggregatorIntraday.test.cjs +313 -0
  33. package/src/main/__tests__/runLogRetention.test.cjs +343 -0
  34. package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +215 -0
  35. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
  36. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
  37. package/src/main/__tests__/transcripts-batch-flush.test.cjs +249 -0
  38. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
  39. package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
  40. package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
  41. package/src/main/config.cjs +32 -29
  42. package/src/main/files.cjs +20 -0
  43. package/src/main/health.cjs +32 -0
  44. package/src/main/heapSnapshot.cjs +122 -0
  45. package/src/main/historyAggregator.cjs +176 -36
  46. package/src/main/index.cjs +10 -63
  47. package/src/main/ipcSchemas.cjs +20 -209
  48. package/src/main/lib/opsOwnership.cjs +0 -2
  49. package/src/main/lib/runLogRetention.cjs +358 -0
  50. package/src/main/lib/summarize.cjs +0 -1
  51. package/src/main/runVerify.cjs +55 -6
  52. package/src/main/scheduler.cjs +46 -7
  53. package/src/main/transcripts.cjs +29 -5
  54. package/src/preload/api.d.ts +18 -199
  55. package/src/preload/index.cjs +12 -102
  56. package/dist/assets/index-BfiMRkln.js +0 -3225
  57. package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
  58. package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
  59. package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
  60. package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
  61. package/src/main/browserAgentServer.cjs +0 -224
  62. package/src/main/browserCapture.cjs +0 -728
  63. package/src/main/browserView.cjs +0 -916
  64. package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
  65. package/src/main/lib/browserAgentActions.cjs +0 -114
  66. package/src/main/lib/e2eStateMachine.cjs +0 -39
  67. package/src/main/webRemote.cjs +0 -1367
  68. package/src/preload/browserViewPreload.cjs +0 -118
@@ -25,17 +25,6 @@ const ptyTabId = z.object({ tabId: z.string().min(1).max(128) });
25
25
  // have a live PTY? Bounded so the renderer can't ask about unbounded lists.
26
26
  const ptyAlive = z.object({ tabIds: z.array(z.string().min(1).max(128)).max(500) });
27
27
 
28
- // v2 mobile: subscribe to a session's live state + summary. cwd is needed to
29
- // locate the transcript JSONL (transcriptPath); validated against home-dir boundary
30
- // in webRemote before any fs access.
31
- const sessionSubscribe = z.object({
32
- // tabId becomes a transcript FILENAME (`<tabId>.jsonl`) — restrict to a
33
- // session-id charset (no '/', no '.', so it can't traverse out of the project
34
- // transcript dir). sessionId is a UUID, which satisfies this.
35
- tabId: z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/),
36
- cwd: z.string().min(1).max(4096),
37
- });
38
-
39
28
  // 64 KiB cap per pty:write — typewriter input is bounded; a renderer firing
40
29
  // megabytes per call is either a bug or an attack. Block it at the boundary.
41
30
  const PTY_WRITE_MAX_BYTES = 64 * 1024;
@@ -50,115 +39,12 @@ const ptyResize = z.object({
50
39
  rows: z.number().int().min(3).max(1000),
51
40
  });
52
41
 
53
- // ──────────────────────────────────────────── Browser (WebContentsView embed)
54
- // viewId is a renderer-generated identifier; restrict to a safe charset (no
55
- // '/', no '.') since it keys the in-process Map<viewId, WebContentsView> —
56
- // not used as a filesystem path, but kept consistent with tabId conventions.
57
- const BROWSER_VIEW_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
58
- const browserViewId = z.object({
59
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
60
- });
61
-
62
- const browserCreate = z.object({
63
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
64
- // Non-persistent partition string (PRD 400 run-mode isolation). No leading
65
- // 'persist:' enforced here — callers choose persistence explicitly.
66
- partition: z.string().min(1).max(256),
67
- });
68
-
69
- const BOUNDS_INT = z.number().int().min(0).max(100000);
70
- const browserSetBounds = z.object({
71
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
72
- x: BOUNDS_INT,
73
- y: BOUNDS_INT,
74
- width: BOUNDS_INT,
75
- height: BOUNDS_INT,
76
- });
77
-
78
- const browserNavigate = z.object({
79
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
80
- url: z.string().min(1).max(8192),
81
- });
82
-
83
- // PRD 407: DOM/text capture from the active browser sub-tab.
84
- const browserCaptureDom = z.object({
85
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
86
- kind: z.enum(['text', 'html']),
87
- });
88
-
89
- // PRD 404: filter -> prune -> summarize -> chunk capture of a picked
90
- // selection (browser:capture). selectors comes from the PRD 403 picker.
91
- const browserCaptureSelection = z.object({
92
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
93
- selectors: z.array(z.string().min(1).max(2048)).min(1).max(50),
94
- mode: z.enum(['agent', 'html', 'a11y', 'selector']),
95
- });
96
-
97
- // PRD 407: clipboard image write (browser:copy-image). dataUrl is a PNG data
98
- // URL from webContents.capturePage() — capped well above any realistic
99
- // screenshot so a malformed/huge payload can't wedge the IPC channel.
100
- const browserCopyImage = z.object({
101
- dataUrl: z.string().min(1).max(50_000_000),
102
- });
103
-
104
42
  // Recorder export (PRD 412): write arbitrary recorded-flow text to the OS
105
- // clipboard, separate from the image-only browserCopyImage above.
43
+ // clipboard.
106
44
  const clipboardWriteText = z.object({
107
45
  text: z.string().max(1_000_000),
108
46
  });
109
47
 
110
- // Recorder export (PRD 412): native "Save As" dialog write, bypassing the
111
- // config.cjs write-boundary since the path is user-chosen via OS dialog.
112
- const browserSaveRecording = z.object({
113
- defaultName: z.string().min(1).max(255),
114
- text: z.string().max(1_000_000),
115
- });
116
-
117
- // PRD 407: binary-safe atomic write (browser:save-binary) for screenshot
118
- // captures — config:write-text is utf8-only.
119
- const browserSaveBinary = z.object({
120
- path: z.string().min(1).max(4096),
121
- base64: z.string().min(1).max(50_000_000),
122
- // Single-writer law (lib/opsOwnership.cjs) — required for ops-root paths.
123
- writer: z.string().min(1).max(64).optional(),
124
- });
125
-
126
- // PRD 410: replay a recorded step list against a live view. The renderer
127
- // owns the step list (main never persists recorded steps), so every call is
128
- // self-contained. `select` is accepted for forward-compat even though the
129
- // live recorder engine doesn't emit it yet.
130
- const browserReplayStep = z.object({
131
- n: z.number().int().min(1),
132
- verb: z.enum(['navigate', 'click', 'type', 'select', 'wait-for']),
133
- target: z.string().max(2000),
134
- value: z.string().max(2000).optional(),
135
- variable: z.string().max(64).nullable().optional(),
136
- kind: z.enum(['nav', 'assert']).optional(),
137
- masked: z.boolean().optional(),
138
- variableSuggestion: z.string().max(64).optional(),
139
- });
140
-
141
- const browserReplay = z.object({
142
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
143
- steps: z.array(browserReplayStep).max(500),
144
- values: z.record(z.string().max(64), z.string().max(2000)).optional(),
145
- continueOnError: z.boolean().optional(),
146
- });
147
-
148
- // PRD 402: address-bar zoom control. factor is clamped again in
149
- // browserView.cjs's setZoom — this just bounds the wire payload.
150
- const browserSetZoom = z.object({
151
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
152
- factor: z.number().min(0.1).max(10),
153
- });
154
-
155
- // PRD 402: Cmd/Ctrl+F find bar.
156
- const browserFind = z.object({
157
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
158
- text: z.string().max(2000),
159
- forward: z.boolean().optional(),
160
- });
161
-
162
48
  // ──────────────────────────────────────────── Transcripts
163
49
  const SESSION_UUID_RE = /^[a-zA-Z0-9-]{1,64}$/;
164
50
 
@@ -203,6 +89,14 @@ const transcriptReadRef = z.object({
203
89
  // ──────────────────────────────────────────── Config
204
90
  const configPath = z.object({ path: z.string().min(1).max(4096) });
205
91
 
92
+ // maxBytes: optional bounded prefix-read length in bytes. Capped at 8 MiB —
93
+ // generous for any legitimate "peek at the start of a file" caller while
94
+ // still blocking a renderer from asking for an effectively-unbounded read.
95
+ const configReadText = z.object({
96
+ path: z.string().min(1).max(4096),
97
+ maxBytes: z.number().int().min(1).max(8 * 1024 * 1024).optional(),
98
+ });
99
+
206
100
  // `writer` carries the renderer's declared owner id for the single-writer law
207
101
  // (lib/opsOwnership.cjs). Optional on the wire because most writes target
208
102
  // ~/.claude, not a project ops root; writes INSIDE the ops root are refused
@@ -680,6 +574,15 @@ const exchangesList = z.object({
680
574
  // this one lives here per PRD 638 so it's reusable without importing files.cjs.
681
575
  const filesDuplicate = z.object({ path: z.string().min(1).max(4096) });
682
576
 
577
+ // files:save-binary — binary-safe atomic write (formerly browser:save-binary,
578
+ // re-homed off the Browser tab). Mirrors the retired browserSaveBinary shape.
579
+ const filesSaveBinary = z.object({
580
+ path: z.string().min(1).max(4096),
581
+ base64: z.string().min(1).max(50_000_000),
582
+ // Single-writer law (lib/opsOwnership.cjs) — required for ops-root paths.
583
+ writer: z.string().min(1).max(64).optional(),
584
+ });
585
+
683
586
  // ──────────────────────────────────────────── Doc Edit (PRD 638 rewrite runner)
684
587
  // docedit:run — consumed by docEdit.cjs's registerDocEditHandlers.
685
588
  const docEditRun = z.object({
@@ -744,23 +647,6 @@ const chatExternalSend = z.object({
744
647
  ),
745
648
  });
746
649
 
747
- // ──────────────────────────────────────────── Web Remote
748
- // OTP is 8 uppercase alphanumeric chars (case-insensitive entry, normalised to upper in handler).
749
- const WEB_REMOTE_OTP_RE = /^[A-Z0-9]{8}$/i;
750
- const DEVICE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
751
-
752
- const webRemotePair = z.object({
753
- otp: z.string().regex(WEB_REMOTE_OTP_RE),
754
- }).strict();
755
-
756
- const webRemoteRevokeDevice = z.object({
757
- deviceId: z.string().regex(DEVICE_ID_RE),
758
- }).strict();
759
-
760
- const webRemoteAuditTail = z.object({
761
- lines: z.number().int().min(1).max(500).optional(),
762
- }).strict();
763
-
764
650
  // ──────────────────────────────────────────── History
765
651
  const DATE_YYYY_MM_DD = /^\d{4}-\d{2}-\d{2}$/;
766
652
 
@@ -892,96 +778,19 @@ function validated(schema, handler) {
892
778
  };
893
779
  }
894
780
 
895
- // ──────────────────────────────────────────── Web Remote command allowlist
896
- // Commands are split into three tiers:
897
- // READ_COMMANDS — return data; allowed when remoteEnabled=true.
898
- // SAS_GATED_READS — return sensitive user data (sessions, PRDs, logs,
899
- // transcript summaries); additionally require
900
- // _e2eAuthenticated=true (SAS confirmed by user).
901
- // A compromised relay cannot exfiltrate this data from
902
- // a session that has not been SAS-confirmed.
903
- // MUTATE_COMMANDS — write files, spawn processes, or mutate persisted
904
- // state; gated behind remoteControlEnabled=true AND
905
- // _e2eAuthenticated=true.
906
- // ALLOWED_COMMANDS is the union, kept for existing import compatibility.
907
- //
908
- // Ungated READ_COMMANDS (justify each):
909
- // cmd:app:version — exposes only the app semver string; no user data.
910
- // cmd:session:unsubscribe — teardown lifecycle; returns nothing sensitive.
911
- const READ_COMMANDS = new Set([
912
- 'cmd:app:version',
913
- // v2 mobile: unsubscribe is a teardown lifecycle call with no data payload.
914
- 'cmd:session:unsubscribe',
915
- ]);
916
-
917
- // Sensitive reads — return user data; require SAS confirmation same as MUTATE.
918
- const SAS_GATED_READS = new Set([
919
- 'cmd:sessions:load',
920
- 'cmd:history:aggregate',
921
- // subscribe initiates a live stream of session state/summary — sensitive.
922
- 'cmd:session:subscribe',
923
- // NOTE: cmd:exchanges:list is intentionally NOT allowlisted — webRemote.cjs has
924
- // no dispatch handler for it, so an allowlist entry would only fail closed with
925
- // an opaque reject. Re-add here together with the handler when remote exchanges
926
- // are wired, so the allowlist always mirrors an actual capability.
927
- //
928
- // Scheduler/Epics commands (cmd:schedule:*) were removed here deliberately
929
- // (core scheduler/Epics redesign) — Remote no longer reaches into scheduler
930
- // internals at all; a future Remote rebuild will talk to Scheduler/Epics
931
- // through whatever higher-level surface that redesign lands on, not this
932
- // per-command allowlist.
933
- ]);
934
-
935
- const MUTATE_COMMANDS = new Set([
936
- 'cmd:sessions:save',
937
- 'cmd:pty:spawn',
938
- 'cmd:pty:write',
939
- // pty:kill terminates a live session; pty:resize drives the geometry of the
940
- // user's interactive PTY — both write live process state, so they are gated
941
- // behind remoteControlEnabled + SAS like every other mutation. A read-only
942
- // mobile mirror has no business killing or resizing the desktop's session.
943
- 'cmd:pty:kill',
944
- 'cmd:pty:resize',
945
- // Pushes a prompt into an open tab's chat queue — a real mutation of that
946
- // tab's session, same tier as pty:write.
947
- 'cmd:chat:send',
948
- ]);
949
-
950
- const ALLOWED_COMMANDS = new Set([...READ_COMMANDS, ...SAS_GATED_READS, ...MUTATE_COMMANDS]);
951
-
952
781
  module.exports = {
953
782
  // Centralized slug regex — used by scheduler.cjs and queueOps.cjs for
954
783
  // direct test()/match() containment checks alongside the zod parses.
955
784
  SCHEDULE_SLUG_RE,
956
785
  SCHEDULE_RUN_ID_RE,
957
786
  PRD_CREATE_SLUG_RE,
958
- READ_COMMANDS,
959
- SAS_GATED_READS,
960
- MUTATE_COMMANDS,
961
- ALLOWED_COMMANDS,
962
787
  schemas: {
963
- webRemotePair,
964
- webRemoteRevokeDevice,
965
- webRemoteAuditTail,
966
788
  ptySpawn,
967
789
  ptyTabId,
968
790
  ptyAlive,
969
791
  ptyWrite,
970
792
  ptyResize,
971
- sessionSubscribe,
972
- browserViewId,
973
- browserCreate,
974
- browserSetBounds,
975
- browserNavigate,
976
- browserCaptureDom,
977
- browserCaptureSelection,
978
- browserCopyImage,
979
- browserSaveBinary,
980
793
  clipboardWriteText,
981
- browserSaveRecording,
982
- browserReplay,
983
- browserSetZoom,
984
- browserFind,
985
794
  transcriptSubscribe,
986
795
  transcriptTabId,
987
796
  transcriptPath,
@@ -989,6 +798,7 @@ module.exports = {
989
798
  transcriptPage,
990
799
  transcriptReadRef,
991
800
  configPath,
801
+ configReadText,
992
802
  configWriteJson,
993
803
  configWriteText,
994
804
  configListDir,
@@ -1049,6 +859,7 @@ module.exports = {
1049
859
  watchersRemove,
1050
860
  watchersKillTab,
1051
861
  filesDuplicate,
862
+ filesSaveBinary,
1052
863
  docEditRun,
1053
864
  docEditRunInSession,
1054
865
  chatRun,
@@ -48,8 +48,6 @@ const OWNERS = Object.freeze({
48
48
  'scheduler': 'scheduler',
49
49
  // Project Home owns the synthesized Brief (generate + hand-edit).
50
50
  'project-brief': 'project-home',
51
- // Browser tab scratch saves (DOM captures, screenshots, recorded flows).
52
- 'browser': 'browser',
53
51
  // Structured per-tab error log lines (JSONL), tagged for tracing/analysis.
54
52
  'logs': 'logs',
55
53
  // Host on Bilko.run tab's deterministic bundle prep (dist/index.html +
@@ -0,0 +1,358 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * runLogRetention.cjs — computes (and, only when explicitly opted in,
5
+ * applies) a retention policy over `scheduled-plans/runs/`.
6
+ *
7
+ * That directory is a machine-level Session-Manager runtime artifact store
8
+ * (execution logs of the scheduler's own `claude -p` runs), not anything
9
+ * governed by the per-project single-writer law in opsOwnership.cjs — see
10
+ * queueStore.cjs's header comment, which draws the same line for
11
+ * scheduler-machine.json.
12
+ *
13
+ * Directory shape: one subdirectory per tick (`RUNS_DIR/<iso-ts>/`, minted by
14
+ * scheduler.cjs's pickRunDir), which commonly holds MANY different PRD
15
+ * slugs' artifacts side by side — `<slug>.log`, `<slug>.meta.json`, and an
16
+ * optional `root-cause-<slug>.md` (rcaReport.cjs) per slug. Retention is
17
+ * therefore computed per (runId, slug) ENTRY, not per directory: a directory
18
+ * is only "fully removable" once every entry it holds is independently
19
+ * eligible AND every file physically present in it is claimed by one of
20
+ * those entries. Definition-of-done reports
21
+ * (`definition-of-done-<batchKey>.md`, definitionOfDone.cjs) are keyed by a
22
+ * hash of a job batch, not a single slug, so they are deliberately left
23
+ * unclaimed by any entry — their presence in a directory blocks that
24
+ * directory from being fully removed, but never blocks removal of a claimed
25
+ * slug's own files.
26
+ *
27
+ * SAFETY MODEL (the point of this module — see the PRD that added it):
28
+ * - Nothing is ever deleted unless the caller passes settings with
29
+ * `schedulerRunLogRetention.enabled === true` AND a non-empty policy.
30
+ * Everywhere else (computeReport, and applyRetention with no opt-in)
31
+ * this module only READS the filesystem.
32
+ * - An entry belonging to a job that is currently live — status `pending`
33
+ * (queued), `running`, `needs_review`, or `investigating` (the exact
34
+ * literals scheduler.cjs uses; see LIVE_STATUSES) — is never eligible,
35
+ * regardless of age or count policy.
36
+ * - The most recent entry for any given slug is never eligible, even past
37
+ * an age cap — every PRD keeps at least its latest evidence.
38
+ * - Age and count policies are COMBINABLE and combine conservatively
39
+ * (AND): when both are set, an entry must clear both floors to be
40
+ * eligible. A policy is a safety floor, not a removal trigger — setting
41
+ * one dimension should never make the other one weaker.
42
+ */
43
+
44
+ const fs = require('node:fs');
45
+ const os = require('node:os');
46
+ const path = require('node:path');
47
+
48
+ const DEFAULT_RUNS_DIR = path.join(
49
+ os.homedir(),
50
+ '.claude', 'session-manager', 'scheduled-plans', 'runs'
51
+ );
52
+
53
+ // Any status that is not yet a terminal outcome. Mirrors the status literals
54
+ // used throughout scheduler.cjs (see e.g. its DOD_SLUG_RE-adjacent status
55
+ // checks) — 'completed' and 'failed' are the only terminal ones.
56
+ const LIVE_STATUSES = new Set(['pending', 'running', 'needs_review', 'investigating']);
57
+
58
+ const META_SUFFIX = '.meta.json';
59
+ const DAY_MS = 24 * 60 * 60 * 1000;
60
+
61
+ function isLiveJob(job) {
62
+ return !!job && LIVE_STATUSES.has(job.status);
63
+ }
64
+
65
+ /**
66
+ * Build the `${slug}|${runId}` protection set from a scheduler job list
67
+ * (queueStore.readMergedSync().jobs, or any array with the same shape). Jobs
68
+ * with no runId yet (never started) have no run directory to protect.
69
+ *
70
+ * A `needs_review` job can lose its `runId` (an old queue-schema gap —
71
+ * scheduler.cjs's own `resolveRunId` backfill exists for the same reason)
72
+ * while a run directory for its slug still exists on disk. When `runsDir` is
73
+ * given, every run directory containing `<slug>.log` is protected for such a
74
+ * job — not just scheduler.cjs's single newest match, since this module has
75
+ * no way to know which one the job actually corresponds to and protecting
76
+ * too many is always safe here, never protecting too few.
77
+ */
78
+ function liveKeysFromJobs(jobs, opts) {
79
+ const runsDir = opts && opts.runsDir;
80
+ const keys = new Set();
81
+ for (const job of jobs || []) {
82
+ if (!isLiveJob(job) || !job.slug) continue;
83
+ if (job.runId) {
84
+ keys.add(`${job.slug}|${job.runId}`);
85
+ continue;
86
+ }
87
+ if (!runsDir) continue;
88
+ let dirs;
89
+ try {
90
+ dirs = fs.readdirSync(runsDir);
91
+ } catch {
92
+ continue;
93
+ }
94
+ for (const d of dirs) {
95
+ try {
96
+ if (fs.existsSync(path.join(runsDir, d, `${job.slug}.log`))) keys.add(`${job.slug}|${d}`);
97
+ } catch {
98
+ // skip
99
+ }
100
+ }
101
+ }
102
+ return keys;
103
+ }
104
+
105
+ /**
106
+ * Scan RUNS_DIR into one entry per (runId, slug) pair found via its
107
+ * `<slug>.meta.json` file. Read-only; never throws on a missing runsDir.
108
+ */
109
+ function scanRunEntries(runsDir) {
110
+ let dirEntries;
111
+ try {
112
+ dirEntries = fs.readdirSync(runsDir, { withFileTypes: true });
113
+ } catch (e) {
114
+ if (e && e.code === 'ENOENT') return [];
115
+ throw e;
116
+ }
117
+
118
+ const entries = [];
119
+ for (const de of dirEntries) {
120
+ if (!de.isDirectory()) continue;
121
+ const runId = de.name;
122
+ const dir = path.join(runsDir, runId);
123
+ let files;
124
+ try {
125
+ files = fs.readdirSync(dir);
126
+ } catch {
127
+ continue; // vanished between readdir calls
128
+ }
129
+
130
+ const metaFiles = files.filter((f) => f.endsWith(META_SUFFIX));
131
+ for (const metaFile of metaFiles) {
132
+ const slug = metaFile.slice(0, -META_SUFFIX.length);
133
+ const relatedNames = files.filter(
134
+ (f) => f === metaFile || f === `${slug}.log` || f === `root-cause-${slug}.md`
135
+ );
136
+
137
+ let sizeBytes = 0;
138
+ let mtimeMs = null;
139
+ const filePaths = [];
140
+ for (const name of relatedNames) {
141
+ const fp = path.join(dir, name);
142
+ filePaths.push(fp);
143
+ try {
144
+ const st = fs.statSync(fp);
145
+ sizeBytes += st.size;
146
+ if (mtimeMs === null || st.mtimeMs > mtimeMs) mtimeMs = st.mtimeMs;
147
+ } catch {
148
+ // file vanished mid-scan — skip its contribution
149
+ }
150
+ }
151
+
152
+ let recordedAtMs = null;
153
+ try {
154
+ const meta = JSON.parse(fs.readFileSync(path.join(dir, metaFile), 'utf8'));
155
+ const candidate = meta.finishedAt ?? meta.startedAt;
156
+ if (typeof candidate === 'number' && Number.isFinite(candidate)) recordedAtMs = candidate;
157
+ } catch {
158
+ // corrupt/unreadable meta.json — fall back to file mtime below
159
+ }
160
+
161
+ entries.push({
162
+ runId,
163
+ slug,
164
+ dir,
165
+ files: filePaths,
166
+ sizeBytes,
167
+ timeMs: recordedAtMs ?? mtimeMs ?? 0,
168
+ });
169
+ }
170
+ }
171
+ return entries;
172
+ }
173
+
174
+ /**
175
+ * Evaluate eligibility for each entry against a policy. Returns entries
176
+ * augmented with { ageDays, rank, isMostRecent, isLive, eligible }.
177
+ *
178
+ * @param {Array} entries Output of scanRunEntries().
179
+ * @param {{maxAgeDays?: number, keepPerSlug?: number}} policy
180
+ * @param {{now?: number, liveKeys?: Set<string>}} opts
181
+ */
182
+ function computeEligibility(entries, policy, opts) {
183
+ const { maxAgeDays, keepPerSlug } = policy || {};
184
+ const now = (opts && opts.now) ?? Date.now();
185
+ const liveKeys = (opts && opts.liveKeys) ?? new Set();
186
+ const hasPolicy = maxAgeDays != null || keepPerSlug != null;
187
+
188
+ const bySlug = new Map();
189
+ for (const e of entries) {
190
+ if (!bySlug.has(e.slug)) bySlug.set(e.slug, []);
191
+ bySlug.get(e.slug).push(e);
192
+ }
193
+ const rankOf = new Map();
194
+ for (const list of bySlug.values()) {
195
+ list.sort((a, b) => b.timeMs - a.timeMs);
196
+ list.forEach((e, i) => rankOf.set(e, i));
197
+ }
198
+
199
+ return entries.map((e) => {
200
+ const rank = rankOf.get(e) ?? 0;
201
+ const isMostRecent = rank === 0;
202
+ const isLive = liveKeys.has(`${e.slug}|${e.runId}`);
203
+ const ageDays = (now - e.timeMs) / DAY_MS;
204
+ const ageOk = maxAgeDays == null || ageDays > maxAgeDays;
205
+ const countOk = keepPerSlug == null || rank >= keepPerSlug;
206
+ const eligible = hasPolicy && !isLive && !isMostRecent && ageOk && countOk;
207
+ return { ...e, ageDays, rank, isMostRecent, isLive, eligible };
208
+ });
209
+ }
210
+
211
+ /**
212
+ * Read-only report: current usage against runsDir, plus what the given
213
+ * policy WOULD remove. Never writes or deletes anything.
214
+ */
215
+ function computeReport(runsDir, policy, opts) {
216
+ const now = (opts && opts.now) ?? Date.now();
217
+ const liveKeys = (opts && opts.liveKeys) ?? new Set();
218
+ const entries = scanRunEntries(runsDir);
219
+ const evaluated = computeEligibility(entries, policy || {}, { now, liveKeys });
220
+
221
+ const totalBytes = entries.reduce((sum, e) => sum + e.sizeBytes, 0);
222
+ const dirSet = new Set(entries.map((e) => e.dir));
223
+ const oldestRunAt = entries.length ? Math.min(...entries.map((e) => e.timeMs)) : null;
224
+
225
+ const eligible = evaluated.filter((e) => e.eligible);
226
+ const eligibleBytes = eligible.reduce((sum, e) => sum + e.sizeBytes, 0);
227
+
228
+ // A directory is fully removable only when every file physically present
229
+ // in it belongs to an eligible entry — an unclaimed file (a DoD report, or
230
+ // a slug entry that isn't eligible) keeps the directory itself alive even
231
+ // though the eligible entries' own files can still be unlinked.
232
+ const removableDirs = [];
233
+ for (const dir of dirSet) {
234
+ let actualFiles;
235
+ try {
236
+ actualFiles = fs.readdirSync(dir);
237
+ } catch {
238
+ continue;
239
+ }
240
+ if (actualFiles.length === 0) continue;
241
+ const claimedEligible = new Set();
242
+ for (const e of evaluated) {
243
+ if (e.dir !== dir || !e.eligible) continue;
244
+ for (const fp of e.files) claimedEligible.add(path.basename(fp));
245
+ }
246
+ if (actualFiles.every((f) => claimedEligible.has(f))) removableDirs.push(dir);
247
+ }
248
+
249
+ return {
250
+ generatedAt: now,
251
+ runsDir,
252
+ policy: policy || null,
253
+ usage: {
254
+ totalBytes,
255
+ dirCount: dirSet.size,
256
+ runCount: entries.length,
257
+ oldestRunAt,
258
+ },
259
+ eligible,
260
+ eligibleSummary: { count: eligible.length, bytes: eligibleBytes },
261
+ removableDirs,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * True only when settings carry an explicit, well-formed opt-in:
267
+ * `schedulerRunLogRetention.enabled === true` plus a non-empty policy.
268
+ */
269
+ function isRetentionEnabled(settings) {
270
+ const cfg = settings && settings.schedulerRunLogRetention;
271
+ if (!cfg || cfg.enabled !== true) return false;
272
+ const policy = cfg.policy;
273
+ return !!policy && (policy.maxAgeDays != null || policy.keepPerSlug != null);
274
+ }
275
+
276
+ /**
277
+ * Resolve the live-job protection set for applyRetention. Prefers whatever
278
+ * the caller supplied (opts.liveKeys, or opts.jobs to derive it from); if
279
+ * neither is given AND deletion is actually about to happen, falls back to
280
+ * reading the real scheduler queue itself via queueStore.cjs (plain Node, no
281
+ * Electron deps — safe to require here) rather than silently treating no
282
+ * jobs as live. This is deliberately defense-in-depth: a future caller that
283
+ * forgets to thread live-job info through must not thereby lose live-job
284
+ * protection.
285
+ */
286
+ function resolveLiveKeysForApply(runsDir, opts, enabled) {
287
+ if (opts && opts.liveKeys) return opts.liveKeys;
288
+ if (opts && opts.jobs) return liveKeysFromJobs(opts.jobs, { runsDir });
289
+ if (!enabled) return new Set(); // dry-run path never deletes; no live read needed
290
+ try {
291
+ const queueStore = require('./queueStore.cjs');
292
+ const state = queueStore.readMergedSync();
293
+ return liveKeysFromJobs(state.jobs || [], { runsDir });
294
+ } catch {
295
+ return new Set();
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Compute the report, and — ONLY when isRetentionEnabled(settings) — delete
301
+ * the eligible files and rmdir any directory left fully empty. With no
302
+ * opt-in (the default), this is exactly computeReport(): read-only,
303
+ * `deleted: false`, nothing removed.
304
+ */
305
+ function applyRetention(runsDir, settings, opts) {
306
+ const cfg = (settings && settings.schedulerRunLogRetention) || null;
307
+ const policy = (cfg && cfg.policy) || {};
308
+ const enabled = isRetentionEnabled(settings);
309
+ const liveKeys = resolveLiveKeysForApply(runsDir, opts, enabled);
310
+ const report = computeReport(runsDir, policy, { now: opts && opts.now, liveKeys });
311
+
312
+ if (!enabled) {
313
+ return {
314
+ deleted: false,
315
+ reason: 'dry-run: schedulerRunLogRetention.enabled is not true (or has no policy)',
316
+ report,
317
+ };
318
+ }
319
+
320
+ let removedFiles = 0;
321
+ let freedBytes = 0;
322
+ const errors = [];
323
+
324
+ for (const entry of report.eligible) {
325
+ for (const fp of entry.files) {
326
+ try {
327
+ const st = fs.statSync(fp);
328
+ fs.unlinkSync(fp);
329
+ removedFiles += 1;
330
+ freedBytes += st.size;
331
+ } catch (e) {
332
+ if (e && e.code !== 'ENOENT') errors.push({ path: fp, error: e.message });
333
+ }
334
+ }
335
+ }
336
+
337
+ for (const dir of report.removableDirs) {
338
+ try {
339
+ if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
340
+ } catch (e) {
341
+ if (e && e.code !== 'ENOENT') errors.push({ path: dir, error: e.message });
342
+ }
343
+ }
344
+
345
+ return { deleted: true, removedFiles, freedBytes, errors, report };
346
+ }
347
+
348
+ module.exports = {
349
+ DEFAULT_RUNS_DIR,
350
+ LIVE_STATUSES,
351
+ isLiveJob,
352
+ liveKeysFromJobs,
353
+ scanRunEntries,
354
+ computeEligibility,
355
+ computeReport,
356
+ isRetentionEnabled,
357
+ applyRetention,
358
+ };
@@ -5,7 +5,6 @@
5
5
  *
6
6
  * Single source of truth for the Anthropic summarization call. Used by:
7
7
  * - exchanges.cjs (records durable per-exchange summaries)
8
- * - webRemote.cjs (mobile summary push — refactored to import from here)
9
8
  *
10
9
  * Degrade contracts (never throw — always return a record):
11
10
  * no API key → { summary: text.slice(0,600), model:'raw', degraded:'no_api_key' }