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.
- package/dist/assets/AgentLibrary-BeJJa_zv.js +1 -0
- package/dist/assets/History-mWbqemhZ.js +2 -0
- package/dist/assets/Hooks-D_8sciZu.js +3 -0
- package/dist/assets/HostBilko-6aUJ7Smz.js +1 -0
- package/dist/assets/Library-CG1LDXLw.js +46 -0
- package/dist/assets/ListDetail-CIIJOWwI.js +1 -0
- package/dist/assets/MarkdownEditor-D0-y8V-f.js +1 -0
- package/dist/assets/McpServers-Kc-djiVi.js +2 -0
- package/dist/assets/Memory-B60DGRTW.js +8 -0
- package/dist/assets/Panel-CbTPsYHq.js +1 -0
- package/dist/assets/Permissions-C8KrSfrd.js +3 -0
- package/dist/assets/Plugins-BT8EumHb.js +2 -0
- package/dist/assets/ProvenanceBadge-cEqPpFsT.js +1 -0
- package/dist/assets/Scheduler-DbKPd0or.js +14 -0
- package/dist/assets/ScopeSwitcher-BNesCJ_t.js +1 -0
- package/dist/assets/Settings-Bjt7AQ6G.js +3 -0
- package/dist/assets/SkillReferenceGraph-DzuaPDM-.js +46 -0
- package/dist/assets/Skills-CVL_4X3T.js +3 -0
- package/dist/assets/SystemPrompt-Ct__nFF1.js +1 -0
- package/dist/assets/TagLibrary-DmrTfw4D.js +1 -0
- package/dist/assets/{TiptapBody-Bn-LMce4.js → TiptapBody-DaS0M_Ni.js} +1 -1
- package/dist/assets/Toggle-C0a-6xV7.js +1 -0
- package/dist/assets/index-CO_7DroC.js +3066 -0
- package/dist/assets/{index-DOXvph-Z.css → index-LlWpj2VJ.css} +1 -1
- package/dist/assets/listSkills-QORduPIk.js +1 -0
- package/dist/assets/settingsSchema-B7dMJaix.js +3 -0
- package/dist/assets/skillFrontmatter-Dif5JIg7.js +10 -0
- package/dist/index.html +2 -2
- package/package.json +1 -3
- package/src/main/__tests__/config-readText-bounded.test.cjs +84 -0
- package/src/main/__tests__/heapSnapshot.test.cjs +121 -0
- package/src/main/__tests__/historyAggregatorIntraday.test.cjs +313 -0
- package/src/main/__tests__/runLogRetention.test.cjs +343 -0
- package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +215 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
- package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
- package/src/main/__tests__/transcripts-batch-flush.test.cjs +249 -0
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
- package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
- package/src/main/config.cjs +32 -29
- package/src/main/files.cjs +20 -0
- package/src/main/health.cjs +32 -0
- package/src/main/heapSnapshot.cjs +122 -0
- package/src/main/historyAggregator.cjs +176 -36
- package/src/main/index.cjs +10 -63
- package/src/main/ipcSchemas.cjs +20 -209
- package/src/main/lib/opsOwnership.cjs +0 -2
- package/src/main/lib/runLogRetention.cjs +358 -0
- package/src/main/lib/summarize.cjs +0 -1
- package/src/main/runVerify.cjs +55 -6
- package/src/main/scheduler.cjs +46 -7
- package/src/main/transcripts.cjs +29 -5
- package/src/preload/api.d.ts +18 -199
- package/src/preload/index.cjs +12 -102
- package/dist/assets/index-BfiMRkln.js +0 -3225
- package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
- package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
- package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
- package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
- package/src/main/browserAgentServer.cjs +0 -224
- package/src/main/browserCapture.cjs +0 -728
- package/src/main/browserView.cjs +0 -916
- package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
- package/src/main/lib/browserAgentActions.cjs +0 -114
- package/src/main/lib/e2eStateMachine.cjs +0 -39
- package/src/main/webRemote.cjs +0 -1367
- package/src/preload/browserViewPreload.cjs +0 -118
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -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
|
|
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' }
|