claude-code-session-manager 0.35.16 → 0.35.18
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/{TiptapBody-BD2BTBY-.js → TiptapBody-DgFO_m3g.js} +1 -1
- package/dist/assets/index-4dJkn9nT.js +3559 -0
- package/dist/assets/index-DX2w2YhJ.css +32 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/adminServer.test.cjs +205 -0
- package/src/main/__tests__/chat-queue.test.cjs +27 -0
- package/src/main/__tests__/health-tick-liveness.test.cjs +143 -0
- package/src/main/__tests__/prd-group-allocator.test.cjs +119 -0
- package/src/main/__tests__/prdCreate.test.cjs +82 -0
- package/src/main/__tests__/pty-write-result.test.cjs +46 -0
- package/src/main/__tests__/runVerify.test.cjs +146 -0
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +5 -3
- package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +54 -0
- package/src/main/__tests__/scheduler-transient-failure.test.cjs +141 -0
- package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +181 -0
- package/src/main/adminServer.cjs +87 -2
- package/src/main/browserCapture.cjs +21 -7
- package/src/main/chatRunner.cjs +5 -1
- package/src/main/health.cjs +114 -3
- package/src/main/hives.cjs +2 -1
- package/src/main/ipcSchemas.cjs +33 -4
- package/src/main/lib/kebabCase.cjs +12 -0
- package/src/main/lib/memorySlug.cjs +10 -0
- package/src/main/lib/prdCreate.cjs +73 -0
- package/src/main/memoryAggregate.cjs +1 -1
- package/src/main/memoryTool.cjs +2 -1
- package/src/main/pty.cjs +7 -6
- package/src/main/runVerify.cjs +119 -6
- package/src/main/scheduler/prdParser.cjs +88 -0
- package/src/main/scheduler.cjs +176 -9
- package/src/main/templates/PRD_AUTHORING.md +126 -0
- package/src/main/usage.cjs +4 -0
- package/src/main/webRemote.cjs +70 -24
- package/src/preload/api.d.ts +1 -0
- package/dist/assets/index-BwFHVuSk.css +0 -32
- package/dist/assets/index-C6ep3yfh.js +0 -3559
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* web-remote-e2e-pinning.test.cjs — TOFU pinning for the web-remote E2E handshake.
|
|
5
|
+
*
|
|
6
|
+
* Once a browser's SPKI pubkey is manually SAS-confirmed for a paired device,
|
|
7
|
+
* a later `e2e:hello` presenting the SAME pubkey for that device should
|
|
8
|
+
* auto-authenticate (no `confirm-sas` needed); a DIFFERENT pubkey must still
|
|
9
|
+
* fall through to the manual `pending_sas` flow.
|
|
10
|
+
*
|
|
11
|
+
* Runs against a tmp HOME (config.cjs / webRemote.cjs both resolve paths off
|
|
12
|
+
* os.homedir()) and stubs the `electron` module in require.cache, mirroring
|
|
13
|
+
* the tmp-HOME + require.cache-stub pattern used by exchanges.test.cjs.
|
|
14
|
+
*
|
|
15
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/web-remote-e2e-pinning.test.cjs
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
19
|
+
|
|
20
|
+
const fs = require('node:fs')
|
|
21
|
+
const fsp = require('node:fs/promises')
|
|
22
|
+
const path = require('node:path')
|
|
23
|
+
const os = require('node:os')
|
|
24
|
+
const crypto = require('node:crypto')
|
|
25
|
+
|
|
26
|
+
/** Generate a P-256 keypair in the same encoding webRemote.cjs uses (SPKI/PKCS8, base64url). */
|
|
27
|
+
function genP256KeyPairB64() {
|
|
28
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
|
|
29
|
+
namedCurve: 'P-256',
|
|
30
|
+
publicKeyEncoding: { type: 'spki', format: 'der' },
|
|
31
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'der' },
|
|
32
|
+
})
|
|
33
|
+
return {
|
|
34
|
+
priv: privateKey.toString('base64url'),
|
|
35
|
+
pub: publicKey.toString('base64url'),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let tmpHome
|
|
40
|
+
let origHome
|
|
41
|
+
let webRemote
|
|
42
|
+
let configPath
|
|
43
|
+
let handlers // captured ipcMain.handle(name, fn) map
|
|
44
|
+
|
|
45
|
+
function freshRequire(mod) {
|
|
46
|
+
delete require.cache[require.resolve(mod)]
|
|
47
|
+
return require(mod)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readConfig() {
|
|
51
|
+
const text = await fsp.readFile(configPath, 'utf8')
|
|
52
|
+
return JSON.parse(text)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function writeConfig(cfg) {
|
|
56
|
+
await fsp.mkdir(path.dirname(configPath), { recursive: true })
|
|
57
|
+
await fsp.writeFile(configPath, JSON.stringify(cfg, null, 2) + '\n')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeAll(async () => {
|
|
61
|
+
tmpHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'web-remote-e2e-pinning-'))
|
|
62
|
+
origHome = process.env.HOME
|
|
63
|
+
process.env.HOME = tmpHome
|
|
64
|
+
|
|
65
|
+
// Stub 'electron' before webRemote.cjs (and logs.cjs, transitively) require it —
|
|
66
|
+
// outside a real Electron process, require('electron') just resolves to a path
|
|
67
|
+
// string, so destructuring { ipcMain, app } off it would throw at module load.
|
|
68
|
+
handlers = new Map()
|
|
69
|
+
const electronPath = require.resolve('electron')
|
|
70
|
+
require.cache[electronPath] = {
|
|
71
|
+
id: electronPath,
|
|
72
|
+
filename: electronPath,
|
|
73
|
+
loaded: true,
|
|
74
|
+
exports: {
|
|
75
|
+
ipcMain: {
|
|
76
|
+
handle: (name, fn) => handlers.set(name, fn),
|
|
77
|
+
on: () => {},
|
|
78
|
+
},
|
|
79
|
+
app: {
|
|
80
|
+
getPath: () => tmpHome,
|
|
81
|
+
getVersion: () => '0.0.0-test',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Clear anything already cached under the real HOME so paths re-resolve under tmpHome.
|
|
87
|
+
for (const mod of ['../webRemote.cjs', '../config.cjs', '../logs.cjs', './e2eStateMachine.cjs']) {
|
|
88
|
+
try { delete require.cache[require.resolve(mod)] } catch { /* not yet loaded */ }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
webRemote = freshRequire('../webRemote.cjs')
|
|
92
|
+
configPath = path.join(tmpHome, '.claude', 'session-manager', 'web-remote.json')
|
|
93
|
+
|
|
94
|
+
webRemote.registerRemoteHandlers()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
afterAll(async () => {
|
|
98
|
+
process.env.HOME = origHome
|
|
99
|
+
await fsp.rm(tmpHome, { recursive: true, force: true })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('web-remote E2E SAS pinning', () => {
|
|
103
|
+
const deviceKeys = genP256KeyPairB64() // desktop's own E2E keypair
|
|
104
|
+
const browserA = genP256KeyPairB64() // first browser's keypair
|
|
105
|
+
const browserB = genP256KeyPairB64() // a different browser's keypair
|
|
106
|
+
const deviceId = 'device-under-test'
|
|
107
|
+
|
|
108
|
+
function baseDeviceRow(overrides = {}) {
|
|
109
|
+
return {
|
|
110
|
+
deviceId,
|
|
111
|
+
deviceToken: 'tok-abc',
|
|
112
|
+
e2ePrivateKey: deviceKeys.priv,
|
|
113
|
+
e2ePublicKey: deviceKeys.pub,
|
|
114
|
+
deviceName: 'Test Device',
|
|
115
|
+
issuedAt: new Date(0).toISOString(),
|
|
116
|
+
lastConnectedAt: null,
|
|
117
|
+
verifiedPeerPubKey: null,
|
|
118
|
+
...overrides,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
it('1) first e2e:hello for a never-confirmed device lands in pending_sas', async () => {
|
|
123
|
+
await writeConfig({ remoteEnabled: true, remoteControlEnabled: false, devices: [baseDeviceRow()] })
|
|
124
|
+
|
|
125
|
+
const cfg = await readConfig()
|
|
126
|
+
const device = cfg.devices[0]
|
|
127
|
+
await webRemote._internal.handleMessage(
|
|
128
|
+
JSON.stringify({ type: 'e2e:hello', id: 'm1', payload: { pubKey: browserA.pub } }),
|
|
129
|
+
device
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
expect(webRemote._internal.getE2eState().state).toBe('pending_sas')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('2) a manual confirm-sas persists the connected device\'s verifiedPeerPubKey', async () => {
|
|
136
|
+
const confirmSas = handlers.get('webRemote:confirm-sas')
|
|
137
|
+
expect(typeof confirmSas).toBe('function')
|
|
138
|
+
|
|
139
|
+
const result = await confirmSas()
|
|
140
|
+
expect(result.ok).toBe(true)
|
|
141
|
+
expect(webRemote._internal.getE2eState().state).toBe('authenticated')
|
|
142
|
+
|
|
143
|
+
const cfg = await readConfig()
|
|
144
|
+
const persisted = cfg.devices.find((d) => d.deviceId === deviceId)
|
|
145
|
+
expect(persisted.verifiedPeerPubKey).toBe(browserA.pub)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('3) a second e2e:hello presenting the SAME browserPubKey auto-authenticates (no confirm-sas)', async () => {
|
|
149
|
+
// Simulate a fresh reconnect: new WS session resets E2E state, and the device
|
|
150
|
+
// row is reloaded from disk (as connect() would), now carrying the pinned key.
|
|
151
|
+
webRemote._internal.resetE2e()
|
|
152
|
+
expect(webRemote._internal.getE2eState().state).toBe('idle')
|
|
153
|
+
|
|
154
|
+
const cfg = await readConfig()
|
|
155
|
+
const device = cfg.devices.find((d) => d.deviceId === deviceId)
|
|
156
|
+
expect(device.verifiedPeerPubKey).toBe(browserA.pub)
|
|
157
|
+
|
|
158
|
+
await webRemote._internal.handleMessage(
|
|
159
|
+
JSON.stringify({ type: 'e2e:hello', id: 'm2', payload: { pubKey: browserA.pub } }),
|
|
160
|
+
device
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
expect(webRemote._internal.getE2eState().state).toBe('authenticated')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('4) a e2e:hello presenting a DIFFERENT browserPubKey still lands in pending_sas', async () => {
|
|
167
|
+
webRemote._internal.resetE2e()
|
|
168
|
+
expect(webRemote._internal.getE2eState().state).toBe('idle')
|
|
169
|
+
|
|
170
|
+
const cfg = await readConfig()
|
|
171
|
+
const device = cfg.devices.find((d) => d.deviceId === deviceId)
|
|
172
|
+
expect(device.verifiedPeerPubKey).toBe(browserA.pub) // still pinned to A, not B
|
|
173
|
+
|
|
174
|
+
await webRemote._internal.handleMessage(
|
|
175
|
+
JSON.stringify({ type: 'e2e:hello', id: 'm3', payload: { pubKey: browserB.pub } }),
|
|
176
|
+
device
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
expect(webRemote._internal.getE2eState().state).toBe('pending_sas')
|
|
180
|
+
})
|
|
181
|
+
})
|
package/src/main/adminServer.cjs
CHANGED
|
@@ -14,8 +14,17 @@
|
|
|
14
14
|
* ~/.claude/session-manager/admin-api.json with 0600 perms.
|
|
15
15
|
* - Token compared with crypto.timingSafeEqual to avoid a timing
|
|
16
16
|
* side-channel on the comparison itself.
|
|
17
|
-
* -
|
|
18
|
-
* list)
|
|
17
|
+
* - Three routes: reset-job (flips fields on an already-vetted job),
|
|
18
|
+
* jobs (read-only list), and create-prd (PRD 549 — authors a BRAND-NEW
|
|
19
|
+
* file that the scheduler will later run as `claude -p
|
|
20
|
+
* --dangerously-skip-permissions` in a caller-chosen, home-dir-bounded
|
|
21
|
+
* cwd; a materially larger blast radius than reset-job, justified only
|
|
22
|
+
* because it unblocks external automation with no other queueing path).
|
|
23
|
+
* cwd is validated via config.cjs's validatePath; title/cwd are
|
|
24
|
+
* newline-blocked at the zod boundary (ipcSchemas.cjs) to prevent
|
|
25
|
+
* frontmatter injection into the hand-rolled PRD serializer
|
|
26
|
+
* (prdCreate.cjs). pause/resume/cancel/update remain intentionally NOT
|
|
27
|
+
* exposed here.
|
|
19
28
|
*/
|
|
20
29
|
|
|
21
30
|
'use strict';
|
|
@@ -26,6 +35,9 @@ const path = require('node:path');
|
|
|
26
35
|
const crypto = require('node:crypto');
|
|
27
36
|
const fsp = require('node:fs/promises');
|
|
28
37
|
const config = require('./config.cjs');
|
|
38
|
+
const { expandHome } = require('./lib/expandHome.cjs');
|
|
39
|
+
const { schemas } = require('./ipcSchemas.cjs');
|
|
40
|
+
const prdCreate = require('./lib/prdCreate.cjs');
|
|
29
41
|
|
|
30
42
|
const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.json');
|
|
31
43
|
|
|
@@ -123,6 +135,79 @@ function createAdminServer(remote) {
|
|
|
123
135
|
sendJson(res, 200, result);
|
|
124
136
|
return;
|
|
125
137
|
}
|
|
138
|
+
if (req.method === 'POST' && req.url === '/admin/scheduler/create-prd') {
|
|
139
|
+
const raw = await readBody(req);
|
|
140
|
+
let parsed;
|
|
141
|
+
try {
|
|
142
|
+
parsed = raw ? JSON.parse(raw) : {};
|
|
143
|
+
} catch {
|
|
144
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let input;
|
|
149
|
+
try {
|
|
150
|
+
input = schemas.schedulerCreatePrd.parse(parsed);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
sendJson(res, 400, { ok: false, error: 'invalid PRD payload', details: e?.issues ?? e?.message });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// cwd is untrusted: this is the first *creating* mutation on a
|
|
157
|
+
// token-authed API whose product is "a command that will later run
|
|
158
|
+
// with --dangerously-skip-permissions in a chosen cwd". Route it
|
|
159
|
+
// through config.cjs's validatePath (allowedRoots = home dir) —
|
|
160
|
+
// same boundary every other fs-touching IPC handler uses — never a
|
|
161
|
+
// bespoke check here.
|
|
162
|
+
try {
|
|
163
|
+
config.validatePath(expandHome(input.cwd));
|
|
164
|
+
} catch (e) {
|
|
165
|
+
sendJson(res, 400, { ok: false, error: `cwd rejected: ${e?.message ?? 'outside allowed roots'}` });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const slug = input.slug || prdCreate.deriveSlugFromTitle(input.title);
|
|
170
|
+
if (!slug || !prdCreate.PRD_CREATE_SLUG_RE.test(slug)) {
|
|
171
|
+
sendJson(res, 400, { ok: false, error: 'could not derive a valid kebab-case slug from title; supply "slug" explicitly' });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// NN allocation is delegated to allocateParallelGroup() (PRD 548) via
|
|
176
|
+
// the injected remote — never re-derived here — unless the caller
|
|
177
|
+
// opted into an existing group explicitly.
|
|
178
|
+
const nn = input.parallelGroup ?? await remote.allocateParallelGroup();
|
|
179
|
+
const filenameSlug = `${nn}-${slug}`;
|
|
180
|
+
|
|
181
|
+
// An explicit `parallelGroup` bypasses allocateParallelGroup()'s
|
|
182
|
+
// collision-proof reservation, so re-check for an existing file at
|
|
183
|
+
// this exact destination before writing — remote.writePrd itself has
|
|
184
|
+
// no existence guard (by design, it doubles as the edit-in-place
|
|
185
|
+
// path for Scheduler UI PRD edits), so "create" must not silently
|
|
186
|
+
// clobber an existing job's PRD.
|
|
187
|
+
const existing = await remote.readPrd(filenameSlug);
|
|
188
|
+
if (existing?.ok) {
|
|
189
|
+
sendJson(res, 409, { ok: false, error: `PRD already exists: ${filenameSlug}.md` });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let standardsText;
|
|
194
|
+
try {
|
|
195
|
+
standardsText = await prdCreate.readStandards();
|
|
196
|
+
} catch (e) {
|
|
197
|
+
sendJson(res, 500, { ok: false, error: `could not read engineering standards: ${e?.message}` });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const body = prdCreate.buildPrdBody(input, standardsText);
|
|
202
|
+
const writeResult = await remote.writePrd(filenameSlug, body);
|
|
203
|
+
if (!writeResult?.ok) {
|
|
204
|
+
sendJson(res, 500, { ok: false, error: writeResult?.error ?? 'write failed' });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
sendJson(res, 200, { nn, filename: `${filenameSlug}.md`, status: 'queued' });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
126
211
|
sendJson(res, 404, { ok: false, error: 'not found' });
|
|
127
212
|
} catch (e) {
|
|
128
213
|
sendJson(res, 500, { ok: false, error: e?.message ?? 'internal error' });
|
|
@@ -656,14 +656,21 @@ async function captureSelection({ viewId, selectors, mode }, getView) {
|
|
|
656
656
|
}
|
|
657
657
|
|
|
658
658
|
if (mode === 'a11y') {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
659
|
+
// captureA11y attaches/detaches the CDP debugger per call, so loop
|
|
660
|
+
// sequentially rather than firing selectors in parallel (mirrors the
|
|
661
|
+
// multi-select behavior of the html/selector modes above, which apply to
|
|
662
|
+
// the whole `selectors` array instead of only the first entry).
|
|
663
|
+
const trees = [];
|
|
664
|
+
for (const selector of selectors) {
|
|
665
|
+
const tree = await captureA11y(wc, selector);
|
|
666
|
+
if (tree) trees.push(tree);
|
|
667
|
+
}
|
|
668
|
+
const text = trees.map((t) => formatA11yTree(t)).join('\n\n');
|
|
662
669
|
return { ok: true, mode, text, meta: {} };
|
|
663
670
|
}
|
|
664
671
|
|
|
665
672
|
// mode === 'agent'
|
|
666
|
-
const
|
|
673
|
+
const rawTrees = await wc.executeJavaScript(
|
|
667
674
|
`(function () {
|
|
668
675
|
function serialize(el) {
|
|
669
676
|
if (el.nodeType === 3) {
|
|
@@ -683,11 +690,18 @@ async function captureSelection({ viewId, selectors, mode }, getView) {
|
|
|
683
690
|
}
|
|
684
691
|
return { tag: el.tagName.toLowerCase(), attrs: attrs, style: { display: cs.display, visibility: cs.visibility }, children: children };
|
|
685
692
|
}
|
|
686
|
-
|
|
687
|
-
|
|
693
|
+
return (${JSON.stringify(selectors)}).map(function (sel) {
|
|
694
|
+
var el = document.querySelector(sel);
|
|
695
|
+
return el ? serialize(el) : null;
|
|
696
|
+
}).filter(Boolean);
|
|
688
697
|
})()`,
|
|
689
698
|
);
|
|
690
|
-
const
|
|
699
|
+
const parts = (Array.isArray(rawTrees) ? rawTrees : []).map((t) => buildAgentCapture(capRawTree(t)));
|
|
700
|
+
const text = parts.map((p) => p.text).join('\n\n');
|
|
701
|
+
const meta = {
|
|
702
|
+
chunks: parts.reduce((a, p) => a + (p.meta.chunks || 0), 0),
|
|
703
|
+
tokens: parts.reduce((a, p) => a + (p.meta.tokens || 0), 0),
|
|
704
|
+
};
|
|
691
705
|
return { ok: true, mode, text, meta };
|
|
692
706
|
}
|
|
693
707
|
|
package/src/main/chatRunner.cjs
CHANGED
|
@@ -310,8 +310,12 @@ function pump() {
|
|
|
310
310
|
.catch(() => { /* executeRun never rejects; defensive */ })
|
|
311
311
|
.finally(() => { activeCount -= 1; pump(); });
|
|
312
312
|
}
|
|
313
|
-
// Anyone still waiting gets a 1-based position update.
|
|
313
|
+
// Anyone still waiting gets a 1-based position update. Silent (automated
|
|
314
|
+
// probe) runs must stay invisible to the renderer, same as every other
|
|
315
|
+
// broadcast in executeRun — otherwise a queued probe would flip a tab's
|
|
316
|
+
// `running`/`queuedPosition` UI state for a run the user never initiated.
|
|
314
317
|
waiting.forEach((w, i) => {
|
|
318
|
+
if (w.silent) return;
|
|
315
319
|
broadcast('chat:run:queued', { tabId: w.tabId, sessionId: w.sessionId, position: i + 1 });
|
|
316
320
|
});
|
|
317
321
|
}
|
package/src/main/health.cjs
CHANGED
|
@@ -9,10 +9,27 @@ const fsp = require('node:fs/promises');
|
|
|
9
9
|
const path = require('node:path');
|
|
10
10
|
const os = require('node:os');
|
|
11
11
|
const { execFileSync } = require('node:child_process');
|
|
12
|
+
const { POLL_INTERVAL_MS } = require('./lib/schedulerConfig.cjs');
|
|
12
13
|
|
|
13
14
|
const MAX_LOG_AGE_MS = 5 * 60_000; // 5 min — warn if no logs this old
|
|
14
15
|
const PROJECT_ROOT = path.resolve(__dirname, '../..');
|
|
15
16
|
|
|
17
|
+
// tickQueue() only fires (and updates lastRunAt) when a batch is actually
|
|
18
|
+
// spawned — it does not tick on a fixed cadence — but the poll loop that
|
|
19
|
+
// *invokes* tickQueue backs off starting from POLL_INTERVAL_MS (scheduler.cjs
|
|
20
|
+
// pollLoop). 3x gives the poll loop three chances to notice free capacity +
|
|
21
|
+
// pending work before we call it a stall, absorbing normal jitter (backoff,
|
|
22
|
+
// memory-gate deferrals, boot warmup) without waiting so long that a real
|
|
23
|
+
// outage goes unnoticed for hours (the 2026-07-14 incident sat stalled for
|
|
24
|
+
// 16.5h before anything asserted on it).
|
|
25
|
+
const TICK_STALL_MULTIPLIER = 3;
|
|
26
|
+
const TICK_STALL_THRESHOLD_MS = TICK_STALL_MULTIPLIER * POLL_INTERVAL_MS;
|
|
27
|
+
// A heartbeat line older than this is treated as "the app isn't running /
|
|
28
|
+
// we can't observe live utilization" rather than "utilization is low" —
|
|
29
|
+
// scheduler-heartbeat.log is only appended to while Electron is running, so
|
|
30
|
+
// a stale line here is silent-on-purpose, not evidence of anything.
|
|
31
|
+
const HEARTBEAT_STALE_MS = 5 * 60_000;
|
|
32
|
+
|
|
16
33
|
function runCheck(cmd, cwd = PROJECT_ROOT) {
|
|
17
34
|
try {
|
|
18
35
|
execFileSync('bash', ['-c', cmd], {
|
|
@@ -26,6 +43,80 @@ function runCheck(cmd, cwd = PROJECT_ROOT) {
|
|
|
26
43
|
}
|
|
27
44
|
}
|
|
28
45
|
|
|
46
|
+
// Reads the last line of scheduler-heartbeat.log, if fresh enough to trust.
|
|
47
|
+
// Returns null when the file is missing, empty, unparseable, or stale — all
|
|
48
|
+
// of which mean "can't observe live utilization right now", not "utilization
|
|
49
|
+
// is low".
|
|
50
|
+
function readFreshHeartbeat(heartbeatPath) {
|
|
51
|
+
let lines;
|
|
52
|
+
try {
|
|
53
|
+
lines = fs.readFileSync(heartbeatPath, 'utf8').split('\n').filter(Boolean);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (lines.length === 0) return null;
|
|
58
|
+
let entry;
|
|
59
|
+
try {
|
|
60
|
+
entry = JSON.parse(lines[lines.length - 1]);
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (typeof entry.ts !== 'number' || Date.now() - entry.ts > HEARTBEAT_STALE_MS) return null;
|
|
65
|
+
return entry;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Evaluates whether the scheduler tick looks stalled: pending work exists,
|
|
69
|
+
// there's free capacity to run it, and nothing about the queue's own state
|
|
70
|
+
// explains why it hasn't. Kept as a pure function of (queueState, heartbeat,
|
|
71
|
+
// now) so it's testable without touching the filesystem.
|
|
72
|
+
function evaluateTickLiveness(queueState, heartbeat, now, runningCount) {
|
|
73
|
+
const jobs = queueState.jobs || [];
|
|
74
|
+
const pending = jobs.filter((j) => j.status === 'pending');
|
|
75
|
+
const running = runningCount ?? jobs.filter((j) => j.status === 'running').length;
|
|
76
|
+
const config = queueState.config || {};
|
|
77
|
+
const concurrencyCap = config.concurrencyCap ?? Infinity;
|
|
78
|
+
|
|
79
|
+
if (pending.length === 0) return { stalled: false, reason: 'no-pending-jobs' };
|
|
80
|
+
if (queueState.paused) return { stalled: false, reason: 'paused' };
|
|
81
|
+
if (config.enabled === false) return { stalled: false, reason: 'disabled' };
|
|
82
|
+
if (running >= concurrencyCap) return { stalled: false, reason: 'at-capacity' };
|
|
83
|
+
|
|
84
|
+
const lastRunAt = queueState.lastRunAt ? Date.parse(queueState.lastRunAt) : null;
|
|
85
|
+
// No lastRunAt at all (fresh install, never ticked) — nothing to measure
|
|
86
|
+
// staleness against yet; don't manufacture a false positive.
|
|
87
|
+
if (lastRunAt == null || Number.isNaN(lastRunAt)) {
|
|
88
|
+
return { stalled: false, reason: 'no-lastRunAt', caveat: true };
|
|
89
|
+
}
|
|
90
|
+
const tickAgeMs = now - lastRunAt;
|
|
91
|
+
if (tickAgeMs <= TICK_STALL_THRESHOLD_MS) return { stalled: false, reason: 'recent-tick' };
|
|
92
|
+
|
|
93
|
+
// Candidate stall. The 'when-available' firePolicy legitimately holds
|
|
94
|
+
// pending jobs when billing utilization is at/above utilizationThreshold —
|
|
95
|
+
// rule that out before calling it a stall.
|
|
96
|
+
if (config.firePolicy === 'when-available' && typeof config.utilizationThreshold === 'number') {
|
|
97
|
+
if (!heartbeat) {
|
|
98
|
+
return {
|
|
99
|
+
stalled: false,
|
|
100
|
+
caveat: true,
|
|
101
|
+
reason: 'cannot-verify-utilization',
|
|
102
|
+
tickAgeMs,
|
|
103
|
+
oldestPendingSlug: pending[0]?.slug,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (typeof heartbeat.utilization === 'number' && heartbeat.utilization >= config.utilizationThreshold) {
|
|
107
|
+
return { stalled: false, reason: 'utilization-at-threshold', utilization: heartbeat.utilization };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
stalled: true,
|
|
113
|
+
reason: 'stalled',
|
|
114
|
+
tickAgeMs,
|
|
115
|
+
oldestPendingSlug: pending[0]?.slug,
|
|
116
|
+
pendingCount: pending.length,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
29
120
|
async function check() {
|
|
30
121
|
const start = Date.now();
|
|
31
122
|
const status = {
|
|
@@ -106,13 +197,33 @@ async function check() {
|
|
|
106
197
|
const failedCount = Object.values(queueState.jobs || {}).filter(
|
|
107
198
|
(j) => j.status === 'failed'
|
|
108
199
|
).length;
|
|
200
|
+
const heartbeatPath = path.join(
|
|
201
|
+
os.homedir(),
|
|
202
|
+
'.claude/session-manager/scheduler-heartbeat.log'
|
|
203
|
+
);
|
|
204
|
+
const heartbeat = readFreshHeartbeat(heartbeatPath);
|
|
205
|
+
const liveness = evaluateTickLiveness(queueState, heartbeat, Date.now(), runningCount);
|
|
109
206
|
status.components.scheduler_queue = {
|
|
110
|
-
ok:
|
|
207
|
+
ok: !liveness.stalled,
|
|
111
208
|
path: queuePath,
|
|
112
209
|
jobs: Object.keys(queueState.jobs || {}).length,
|
|
113
210
|
running: runningCount,
|
|
114
211
|
failed: failedCount,
|
|
212
|
+
tickLiveness: liveness.reason,
|
|
115
213
|
};
|
|
214
|
+
if (liveness.stalled) {
|
|
215
|
+
const ageMin = Math.round(liveness.tickAgeMs / 60_000);
|
|
216
|
+
status.components.scheduler_queue.stalledJob = liveness.oldestPendingSlug;
|
|
217
|
+
status.components.scheduler_queue.tickAgeMs = liveness.tickAgeMs;
|
|
218
|
+
status.issues.push(
|
|
219
|
+
`Scheduler tick appears stalled: "${liveness.oldestPendingSlug}" (and ${liveness.pendingCount - 1} other pending job(s)) has been waiting ~${ageMin}m with free capacity and no tick progress`
|
|
220
|
+
);
|
|
221
|
+
} else if (liveness.caveat) {
|
|
222
|
+
status.components.scheduler_queue.caveat =
|
|
223
|
+
liveness.reason === 'cannot-verify-utilization'
|
|
224
|
+
? `Tick hasn't advanced in a while but scheduler-heartbeat.log is missing/stale, so current billing utilization can't be checked — cannot rule out a legitimate when-available hold`
|
|
225
|
+
: 'No lastRunAt recorded yet — cannot assess tick liveness';
|
|
226
|
+
}
|
|
116
227
|
} catch (e) {
|
|
117
228
|
if (e.code !== 'ENOENT') {
|
|
118
229
|
status.issues.push(`Scheduler queue unreadable: ${e.message}`);
|
|
@@ -197,7 +308,7 @@ async function check() {
|
|
|
197
308
|
// Critical: nodejs, config dir, typescript, build artifact, test infrastructure.
|
|
198
309
|
// Non-fatal: scheduler/transcripts dirs may not exist on fresh install.
|
|
199
310
|
// Informational: app log age (shows if app is running, but not blocking).
|
|
200
|
-
const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure'];
|
|
311
|
+
const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure', 'scheduler_queue'];
|
|
201
312
|
status.ok = criticalComponents.every((c) => status.components[c]?.ok !== false);
|
|
202
313
|
|
|
203
314
|
status.elapsedMs = Date.now() - start;
|
|
@@ -213,4 +324,4 @@ if (require.main === module) {
|
|
|
213
324
|
})();
|
|
214
325
|
}
|
|
215
326
|
|
|
216
|
-
module.exports = { check };
|
|
327
|
+
module.exports = { check, evaluateTickLiveness, readFreshHeartbeat, TICK_STALL_THRESHOLD_MS, HEARTBEAT_STALE_MS };
|
package/src/main/hives.cjs
CHANGED
|
@@ -33,6 +33,7 @@ const path = require('node:path');
|
|
|
33
33
|
const os = require('node:os');
|
|
34
34
|
const { z } = require('zod');
|
|
35
35
|
const config = require('./config.cjs');
|
|
36
|
+
const { kebabCase: sharedKebabCase } = require('./lib/kebabCase.cjs');
|
|
36
37
|
|
|
37
38
|
// ──────────────────────────────────────────── caps
|
|
38
39
|
const SLUG_RE = /^[a-z0-9-_]{1,64}$/;
|
|
@@ -89,7 +90,7 @@ async function ensureRoot() {
|
|
|
89
90
|
|
|
90
91
|
// ──────────────────────────────────────────── helpers
|
|
91
92
|
function kebabCase(s) {
|
|
92
|
-
return s
|
|
93
|
+
return sharedKebabCase(s, { maxLen: 64, fallback: 'agent' });
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
// ──────────────────────────────────────────── legacy migration
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -228,6 +228,33 @@ const scheduleWritePrd = z.object({
|
|
|
228
228
|
),
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
+
// PRD create (admin API — scheduler_create_prd, PRD 549). This slug is
|
|
232
|
+
// deliberately STRICTER than SCHEDULE_SLUG_RE: it becomes a brand-new
|
|
233
|
+
// filename segment written to disk by the create route, not a lookup key
|
|
234
|
+
// for an existing file, so it excludes '.' and '_' too (matches
|
|
235
|
+
// pluginInstall.cjs's slug precedent, minus '/' — this slug must never
|
|
236
|
+
// introduce a subdirectory).
|
|
237
|
+
const PRD_CREATE_SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
238
|
+
// title/cwd become frontmatter VALUES (`title: ${title}`) in prdCreate.cjs's
|
|
239
|
+
// hand-rolled `key: value` serializer, which — unlike a real YAML
|
|
240
|
+
// library — has no escaping. A title/cwd containing '\n' could inject a
|
|
241
|
+
// bare `\n---\n` that terminates the frontmatter block early (per
|
|
242
|
+
// prdFrontmatter.cjs's `indexOf('\n---')` parse) and smuggle extra
|
|
243
|
+
// frontmatter-shaped lines into the body. Block newlines at the boundary
|
|
244
|
+
// instead of trying to escape them later.
|
|
245
|
+
const NO_NEWLINE_RE = /^[^\r\n]*$/;
|
|
246
|
+
const schedulerCreatePrd = z.object({
|
|
247
|
+
title: z.string().min(1).max(200).regex(NO_NEWLINE_RE, 'must not contain newlines'),
|
|
248
|
+
cwd: z.string().min(1).max(4096).regex(NO_NEWLINE_RE, 'must not contain newlines'),
|
|
249
|
+
estimateMinutes: z.number().int().min(1).max(100000),
|
|
250
|
+
goal: z.string().min(1).max(20000),
|
|
251
|
+
acceptanceCriteria: z.array(z.string().min(1).max(2000)).min(1).max(100),
|
|
252
|
+
implementationNotes: z.string().min(1).max(20000),
|
|
253
|
+
outOfScope: z.array(z.string().min(1).max(2000)).max(100).optional(),
|
|
254
|
+
slug: z.string().min(1).max(60).regex(PRD_CREATE_SLUG_RE).optional(),
|
|
255
|
+
parallelGroup: z.number().int().min(1).max(999999).optional(),
|
|
256
|
+
});
|
|
257
|
+
|
|
231
258
|
// Bulk archive: slug list, capped to limit unbounded retag/archive payloads.
|
|
232
259
|
const scheduleArchivePrd = z.object({
|
|
233
260
|
slugs: z.array(z.string().regex(SCHEDULE_SLUG_RE)).min(1).max(500),
|
|
@@ -290,11 +317,11 @@ const setConfigSchema = z.object({
|
|
|
290
317
|
}).strict();
|
|
291
318
|
|
|
292
319
|
// ──────────────────────────────────────────── Memory tool (Bundle C, cycle 3)
|
|
293
|
-
// Workspace-scoped markdown store at ~/.claude/
|
|
294
|
-
// Slug regex
|
|
295
|
-
// encodeWorkspace() output (alphanumeric + dash) plus 'default'.
|
|
320
|
+
// Workspace-scoped markdown store at ~/.claude/projects/<ws>/memory/.
|
|
321
|
+
// Slug regex is shared with memoryTool.cjs/memoryAggregate.cjs via lib/memorySlug.cjs;
|
|
322
|
+
// workspace regex matches encodeWorkspace() output (alphanumeric + dash) plus 'default'.
|
|
296
323
|
const MEMORY_WORKSPACE_RE = /^[a-zA-Z0-9-_]{1,256}$/;
|
|
297
|
-
const MEMORY_SLUG_RE =
|
|
324
|
+
const { MEMORY_SLUG_RE } = require('./lib/memorySlug.cjs');
|
|
298
325
|
// 1 MiB hard cap — matches MAX_FILE_BYTES in memoryTool.cjs.
|
|
299
326
|
const MEMORY_MAX_BYTES = 1024 * 1024;
|
|
300
327
|
|
|
@@ -613,6 +640,7 @@ module.exports = {
|
|
|
613
640
|
// direct test()/match() containment checks alongside the zod parses.
|
|
614
641
|
SCHEDULE_SLUG_RE,
|
|
615
642
|
SCHEDULE_RUN_ID_RE,
|
|
643
|
+
PRD_CREATE_SLUG_RE,
|
|
616
644
|
READ_COMMANDS,
|
|
617
645
|
SAS_GATED_READS,
|
|
618
646
|
MUTATE_COMMANDS,
|
|
@@ -651,6 +679,7 @@ module.exports = {
|
|
|
651
679
|
scheduleSlug,
|
|
652
680
|
scheduleReadLog,
|
|
653
681
|
scheduleWritePrd,
|
|
682
|
+
schedulerCreatePrd,
|
|
654
683
|
scheduleArchivePrd,
|
|
655
684
|
scheduleRetagPrd,
|
|
656
685
|
setConfigSchema,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kebabCase.cjs — shared lowercase/kebab-case slugify. Extracted from
|
|
3
|
+
* hives.cjs (private `kebabCase` helper) so prdCreate.cjs doesn't fork a
|
|
4
|
+
* second, subtly-different copy (API reuse: one concept, one implementation).
|
|
5
|
+
*/
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
function kebabCase(s, { maxLen = 64, fallback = '' } = {}) {
|
|
9
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, maxLen) || fallback;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = { kebabCase };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared workspace-memory filename regex. memoryTool.cjs (CRUD), memoryAggregate.cjs
|
|
5
|
+
* (clustering), and ipcSchemas.cjs (IPC validation) all gate on the same slug shape —
|
|
6
|
+
* single source of truth so the three don't drift.
|
|
7
|
+
*/
|
|
8
|
+
const MEMORY_SLUG_RE = /^[a-z0-9-_]+\.md$/;
|
|
9
|
+
|
|
10
|
+
module.exports = { MEMORY_SLUG_RE };
|