claude-code-session-manager 0.41.0 → 0.42.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/{TiptapBody-BnRle0iw.js → TiptapBody-7JlgGW3C.js} +1 -1
- package/dist/assets/{index-CKY3mHgV.css → index-CxGY-V5G.css} +1 -1
- package/dist/assets/{index-H7rwsoKV.js → index-D4ga1iZ5.js} +681 -681
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/lib/watchdogHelpers.cjs +7 -0
- package/src/main/__tests__/opsErrorLog.test.cjs +87 -0
- package/src/main/__tests__/prdCreate.test.cjs +70 -0
- package/src/main/chatRunner.cjs +22 -1
- package/src/main/ipcSchemas.cjs +8 -0
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +30 -11
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +94 -0
- package/src/main/lib/auditLog.cjs +60 -0
- package/src/main/lib/epicMint.cjs +6 -0
- package/src/main/lib/localAdminHttp.cjs +9 -0
- package/src/main/lib/opsErrorLog.cjs +96 -0
- package/src/main/lib/opsOwnership.cjs +3 -0
- package/src/main/lib/prdCreate.cjs +42 -0
- package/src/main/lib/schedulerBatch.cjs +27 -5
- package/src/main/logs.cjs +19 -1
- package/src/main/pty.cjs +23 -0
- package/src/main/scheduler.cjs +51 -0
- package/src/preload/api.d.ts +7 -1
- package/src/preload/index.cjs +5 -2
package/dist/index.html
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
10
|
-
<script type="module" crossorigin src="./assets/index-
|
|
10
|
+
<script type="module" crossorigin src="./assets/index-D4ga1iZ5.js"></script>
|
|
11
11
|
<link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
|
|
12
12
|
<link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
|
|
13
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
13
|
+
<link rel="stylesheet" crossorigin href="./assets/index-CxGY-V5G.css">
|
|
14
14
|
</head>
|
|
15
15
|
<body class="bg-bg text-fg font-sans antialiased">
|
|
16
16
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-session-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
4
4
|
"description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main/index.cjs",
|
|
@@ -313,9 +313,16 @@ async function emitFeedbackPRD(cwd, {
|
|
|
313
313
|
// Every PRD belongs to an Epic (CLAUDE.md domain model). The sweep joins
|
|
314
314
|
// one standing "Inbound feedback" Epic per project (reuseByGoal) so
|
|
315
315
|
// recurring ticks chain into a single Epic rather than minting one each.
|
|
316
|
+
// This is an unattended automated sweep, not a human action — status
|
|
317
|
+
// MUST be 'proposed' (same as lib/rcaFeedbackHook.cjs) so the Epic lands
|
|
318
|
+
// behind the Approve & start human gate instead of running immediately;
|
|
319
|
+
// an 'active' default here would let an unattended process mint and
|
|
320
|
+
// execute PRDs with zero human approval, exactly the hole the 'proposed'
|
|
321
|
+
// gate exists to close.
|
|
316
322
|
const epic = await ensureEpic(cwd, {
|
|
317
323
|
goalText: 'Inbound feedback processing',
|
|
318
324
|
tag: 'discussion',
|
|
325
|
+
status: 'proposed',
|
|
319
326
|
reuseByGoal: true,
|
|
320
327
|
});
|
|
321
328
|
epicId = epic.epicId;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opsErrorLog.test.cjs — appendError() is the sole writer of the `logs` ops
|
|
3
|
+
* namespace (single-writer law, opsOwnership.cjs). Verifies: writes a tagged
|
|
4
|
+
* JSONL line to <cwd>/session-manager-operations/logs/errors-<date>.jsonl,
|
|
5
|
+
* redacts sensitive meta keys, and no-ops (never throws) with no cwd.
|
|
6
|
+
*
|
|
7
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/opsErrorLog.test.cjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
import { test } from 'vitest';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
const opsErrorLog = require('../lib/opsErrorLog.cjs');
|
|
19
|
+
|
|
20
|
+
function mkTmpProject() {
|
|
21
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'sm-opslog-test-'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readLines(cwd) {
|
|
25
|
+
const dir = opsErrorLog.logsDir(cwd);
|
|
26
|
+
const file = opsErrorLog.todayFile(cwd);
|
|
27
|
+
if (!fs.existsSync(file)) return [];
|
|
28
|
+
return fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('appendError writes a tagged JSONL line', () => {
|
|
32
|
+
const cwd = mkTmpProject();
|
|
33
|
+
opsErrorLog.appendError({
|
|
34
|
+
cwd,
|
|
35
|
+
scope: 'pty',
|
|
36
|
+
tabId: 'tab-123',
|
|
37
|
+
tags: ['spawn-failed'],
|
|
38
|
+
message: 'pty.spawn threw: boom',
|
|
39
|
+
});
|
|
40
|
+
const lines = readLines(cwd);
|
|
41
|
+
assert.equal(lines.length, 1);
|
|
42
|
+
const [line] = lines;
|
|
43
|
+
assert.equal(line.level, 'error');
|
|
44
|
+
assert.equal(line.scope, 'pty');
|
|
45
|
+
assert.equal(line.tabId, 'tab-123');
|
|
46
|
+
assert.equal(line.message, 'pty.spawn threw: boom');
|
|
47
|
+
assert.ok(line.tags.includes('level:error'));
|
|
48
|
+
assert.ok(line.tags.includes('scope:pty'));
|
|
49
|
+
assert.ok(line.tags.includes('tab:tab-123'));
|
|
50
|
+
assert.ok(line.tags.includes('spawn-failed'));
|
|
51
|
+
assert.ok(typeof line.ts === 'string' && !Number.isNaN(Date.parse(line.ts)));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('appendError redacts sensitive meta keys', () => {
|
|
55
|
+
const cwd = mkTmpProject();
|
|
56
|
+
opsErrorLog.appendError({
|
|
57
|
+
cwd,
|
|
58
|
+
scope: 'chatRunner',
|
|
59
|
+
message: 'run failed',
|
|
60
|
+
meta: { token: 'sk-secret', sessionId: 'abc', password: 'hunter2' },
|
|
61
|
+
});
|
|
62
|
+
const [line] = readLines(cwd);
|
|
63
|
+
assert.equal(line.meta.token, '[redacted]');
|
|
64
|
+
assert.equal(line.meta.password, '[redacted]');
|
|
65
|
+
assert.equal(line.meta.sessionId, 'abc');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('appendError is a no-op with no cwd', () => {
|
|
69
|
+
// Must not throw, and there is nowhere to assert a file was NOT written
|
|
70
|
+
// since no cwd means no path — this just proves it never throws.
|
|
71
|
+
assert.doesNotThrow(() => opsErrorLog.appendError({ scope: 'x', message: 'y' }));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('appendError merges caller tags without duplicating auto-derived ones', () => {
|
|
75
|
+
const cwd = mkTmpProject();
|
|
76
|
+
opsErrorLog.appendError({
|
|
77
|
+
cwd,
|
|
78
|
+
scope: 'chatRunner',
|
|
79
|
+
tabId: 'tab-9',
|
|
80
|
+
tags: ['level:error', 'silent-probe'], // deliberately duplicates an auto tag
|
|
81
|
+
message: 'probe failed',
|
|
82
|
+
});
|
|
83
|
+
const [line] = readLines(cwd);
|
|
84
|
+
const count = line.tags.filter((t) => t === 'level:error').length;
|
|
85
|
+
assert.equal(count, 1);
|
|
86
|
+
assert.ok(line.tags.includes('silent-probe'));
|
|
87
|
+
});
|
|
@@ -23,6 +23,7 @@ const {
|
|
|
23
23
|
PRD_CREATE_SLUG_RE,
|
|
24
24
|
registerAdminRoute,
|
|
25
25
|
createPrd,
|
|
26
|
+
resolveSourcePromptIdFromClaudeSession,
|
|
26
27
|
} = require('../lib/prdCreate.cjs');
|
|
27
28
|
const { createAdminHttp } = require('../lib/localAdminHttp.cjs');
|
|
28
29
|
const config = require('../config.cjs');
|
|
@@ -505,3 +506,72 @@ test('POST /admin/scheduler/create-prd with sourcePromptId writes it into the cr
|
|
|
505
506
|
await admin.stop();
|
|
506
507
|
}
|
|
507
508
|
});
|
|
509
|
+
|
|
510
|
+
// ──────────────────────────────────────────── originClaudeSessionId fallback
|
|
511
|
+
// (2026-08-01 fix: a PRD queued via the scheduler_create_prd MCP tool from
|
|
512
|
+
// inside an Epic's own chat session, without an explicit sourcePromptId,
|
|
513
|
+
// must still join that Epic rather than mint an unrelated sibling one.)
|
|
514
|
+
|
|
515
|
+
async function writeActiveIndexFixture(cwd, sessions) {
|
|
516
|
+
const dir = path.join(cwd, 'session-manager-operations', 'prompt-sessions');
|
|
517
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
518
|
+
await fsp.writeFile(path.join(dir, 'active-index.json'), JSON.stringify({ sessions, events: {} }, null, 2));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
test('resolveSourcePromptIdFromClaudeSession finds the Epic whose claudeSessionId matches', async () => {
|
|
522
|
+
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-resolve-epic-'));
|
|
523
|
+
try {
|
|
524
|
+
await writeActiveIndexFixture(root, {
|
|
525
|
+
'psess-abc123': { id: 'psess-abc123', claudeSessionId: 'claude-sess-1', status: 'active' },
|
|
526
|
+
});
|
|
527
|
+
expect(resolveSourcePromptIdFromClaudeSession(root, 'claude-sess-1')).toBe('psess-abc123');
|
|
528
|
+
expect(resolveSourcePromptIdFromClaudeSession(root, 'claude-sess-unknown')).toBeNull();
|
|
529
|
+
expect(resolveSourcePromptIdFromClaudeSession(root, null)).toBeNull();
|
|
530
|
+
} finally {
|
|
531
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test('createPrd resolves sourcePromptId from originClaudeSessionId when the caller omitted sourcePromptId', async () => {
|
|
536
|
+
const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-origin-session-epic-'));
|
|
537
|
+
config.addAllowedRoot(root);
|
|
538
|
+
try {
|
|
539
|
+
await writeActiveIndexFixture(root, {
|
|
540
|
+
'psess-always-select-tab': { id: 'psess-always-select-tab', claudeSessionId: 'claude-sess-epic-1', status: 'active' },
|
|
541
|
+
});
|
|
542
|
+
const prdsDir = path.join(root, 'session-manager-operations', 'scheduler', 'epics', 'psess-always-select-tab', 'prds');
|
|
543
|
+
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
544
|
+
|
|
545
|
+
const result = await createPrd(
|
|
546
|
+
validCreateBody({ cwd: root, slug: 'joins-the-epic', originClaudeSessionId: 'claude-sess-epic-1' }),
|
|
547
|
+
remote,
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
expect(result.ok).toBe(true);
|
|
551
|
+
const written = await fsp.readFile(path.join(prdsDir, result.filename), 'utf8');
|
|
552
|
+
expect(written).toMatch(/sourcePromptId: psess-always-select-tab/);
|
|
553
|
+
} finally {
|
|
554
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test('createPrd leaves sourcePromptId unset when originClaudeSessionId matches no known Epic (plain SessionTab chat)', async () => {
|
|
559
|
+
const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-origin-session-no-match-'));
|
|
560
|
+
config.addAllowedRoot(root);
|
|
561
|
+
try {
|
|
562
|
+
await writeActiveIndexFixture(root, {});
|
|
563
|
+
const prdsDir = path.join(root, 'session-manager-operations', 'scheduler', 'prds');
|
|
564
|
+
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
565
|
+
|
|
566
|
+
const result = await createPrd(
|
|
567
|
+
validCreateBody({ cwd: root, slug: 'no-epic-match', originClaudeSessionId: 'claude-sess-not-an-epic' }),
|
|
568
|
+
remote,
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
expect(result.ok).toBe(true);
|
|
572
|
+
const written = await fsp.readFile(path.join(prdsDir, result.filename), 'utf8');
|
|
573
|
+
expect(!/sourcePromptId:/.test(written)).toBeTruthy();
|
|
574
|
+
} finally {
|
|
575
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
576
|
+
}
|
|
577
|
+
});
|
package/src/main/chatRunner.cjs
CHANGED
|
@@ -51,6 +51,7 @@ const { classifyToolUse } = require('./lib/toolUseClassify.cjs');
|
|
|
51
51
|
const { extractJson } = require('./lib/extractJson.cjs');
|
|
52
52
|
const { classifyPromptTicket } = require('./lib/classifyPromptTicket.cjs');
|
|
53
53
|
const sessionSlots = require('./lib/sessionSlots.cjs');
|
|
54
|
+
const opsErrorLog = require('./lib/opsErrorLog.cjs');
|
|
54
55
|
|
|
55
56
|
// ─── Stop-signal protocol ──────────────────────────────────────────────────
|
|
56
57
|
// Single source of truth for the sentinel and parser. The renderer (PRD 320)
|
|
@@ -470,6 +471,19 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
470
471
|
const emitTerminal = (channel, payload) => {
|
|
471
472
|
if (terminalSent) return;
|
|
472
473
|
terminalSent = true;
|
|
474
|
+
// Every terminal error for this tab, logged once, regardless of the
|
|
475
|
+
// `silent` gate below — a probe run's failure is still real signal for
|
|
476
|
+
// tracing even though it never reaches the visible chat UI.
|
|
477
|
+
if (channel === 'chat:run:error') {
|
|
478
|
+
opsErrorLog.appendError({
|
|
479
|
+
cwd,
|
|
480
|
+
scope: 'chatRunner',
|
|
481
|
+
tabId,
|
|
482
|
+
tags: silent ? ['silent-probe'] : [],
|
|
483
|
+
message: payload?.message ?? 'chat run failed',
|
|
484
|
+
meta: { sessionId, silent },
|
|
485
|
+
});
|
|
486
|
+
}
|
|
473
487
|
// Silent (probe) runs still need this bookkeeping so the lane frees
|
|
474
488
|
// correctly, but must never surface on the six turn-affecting channels.
|
|
475
489
|
if (silent) return;
|
|
@@ -495,7 +509,14 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
495
509
|
};
|
|
496
510
|
|
|
497
511
|
const claudeBin = resolveClaudeBin();
|
|
498
|
-
|
|
512
|
+
// Carried down to any MCP server this run spawns (e.g.
|
|
513
|
+
// scripts/scheduler-mcp-server.cjs, inherited via the claude CLI's own
|
|
514
|
+
// child env) so scheduler_create_prd can auto-resolve sourcePromptId
|
|
515
|
+
// when the model forgets to pass it — see prdCreate.cjs's
|
|
516
|
+
// resolveSourcePromptIdFromClaudeSession. sessionId IS the Epic's
|
|
517
|
+
// claudeSessionId for an Epic-backed tab (domain model: Epic:claude-session
|
|
518
|
+
// 1:1); for a non-Epic tab, resolution below simply finds no match.
|
|
519
|
+
const childEnv = cleanChildEnv({ PATH: pathWithUserBins(), SM_CHAT_SESSION_ID: sessionId });
|
|
499
520
|
|
|
500
521
|
// Prepend the stop-signal protocol instruction and the chat-mode truth
|
|
501
522
|
// instruction to every prompt
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -303,6 +303,14 @@ const schedulerCreatePrd = z.object({
|
|
|
303
303
|
// prompt back into the chat tab that queued this PRD, via
|
|
304
304
|
// enqueueExternalPrompt (PRD 753). Same newline-injection guard.
|
|
305
305
|
sourceTabId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
306
|
+
// The calling MCP process's own claude session id (scheduler-mcp-server.cjs
|
|
307
|
+
// forwards its inherited SM_CHAT_SESSION_ID env var here when the model
|
|
308
|
+
// omitted sourcePromptId) — never written to frontmatter itself; prdCreate.cjs's
|
|
309
|
+
// createPrd() resolves it against active-index.json to fill sourcePromptId
|
|
310
|
+
// when it matches an existing Epic's claudeSessionId, so a PRD queued from
|
|
311
|
+
// within an Epic's own chat session joins that Epic even if the tool call
|
|
312
|
+
// forgot to pass sourcePromptId explicitly.
|
|
313
|
+
originClaudeSessionId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
306
314
|
// User-selected Feature/Bug tag (PRD 774) carried from the originating
|
|
307
315
|
// PromptTicket — deterministic, never LLM-classified.
|
|
308
316
|
tag: z.enum(['feature', 'bug', 'discussion']).optional(),
|
|
@@ -11,12 +11,24 @@
|
|
|
11
11
|
|
|
12
12
|
'use strict';
|
|
13
13
|
|
|
14
|
-
import { test, expect } from 'vitest';
|
|
14
|
+
import { test, expect, afterAll } from 'vitest';
|
|
15
15
|
const http = require('node:http');
|
|
16
16
|
const fs = require('node:fs');
|
|
17
17
|
const path = require('node:path');
|
|
18
|
+
const os = require('node:os');
|
|
18
19
|
const { createAdminHttp, TOKEN_PATH, resolveTokenPath } = require('../localAdminHttp.cjs');
|
|
19
20
|
|
|
21
|
+
// Redirect every start() in this file away from the PRODUCTION admin-api.json.
|
|
22
|
+
// Without this, these tests overwrote the running app's port+token with a
|
|
23
|
+
// throwaway server's, orphaning scheduler-mcp-server.cjs until an app restart
|
|
24
|
+
// (confirmed live 2026-08-01). Tests that specifically exercise path
|
|
25
|
+
// resolution unset this override themselves.
|
|
26
|
+
// Must live under the home dir: config.writeJson enforces allowedRoots.
|
|
27
|
+
const TEST_TOKEN_PATH = path.join(path.dirname(TOKEN_PATH), `admin-api.test-${process.pid}.json`);
|
|
28
|
+
process.env.SM_ADMIN_TOKEN_PATH = TEST_TOKEN_PATH;
|
|
29
|
+
|
|
30
|
+
afterAll(() => { fs.rmSync(TEST_TOKEN_PATH, { force: true }); });
|
|
31
|
+
|
|
20
32
|
function request(port, { method = 'GET', path: reqPath, token, body }) {
|
|
21
33
|
return new Promise((resolve, reject) => {
|
|
22
34
|
const headers = {};
|
|
@@ -109,11 +121,11 @@ test('start() persists token file with port + token, mode 0600', async () => {
|
|
|
109
121
|
const admin = createAdminHttp();
|
|
110
122
|
const { port, token } = await admin.start();
|
|
111
123
|
try {
|
|
112
|
-
const raw = fs.readFileSync(
|
|
124
|
+
const raw = fs.readFileSync(TEST_TOKEN_PATH, 'utf8');
|
|
113
125
|
const parsed = JSON.parse(raw);
|
|
114
126
|
expect(parsed.port).toBe(port);
|
|
115
127
|
expect(parsed.token).toBe(token);
|
|
116
|
-
const mode = fs.statSync(
|
|
128
|
+
const mode = fs.statSync(TEST_TOKEN_PATH).mode & 0o777;
|
|
117
129
|
expect(mode).toBe(0o600);
|
|
118
130
|
} finally {
|
|
119
131
|
await admin.stop();
|
|
@@ -124,6 +136,8 @@ test('SM_DEV=1 writes to a dev-suffixed path, never the production admin-api.jso
|
|
|
124
136
|
const devPath = path.join(path.dirname(TOKEN_PATH), 'admin-api.dev.json');
|
|
125
137
|
const prevMtime = fs.existsSync(TOKEN_PATH) ? fs.statSync(TOKEN_PATH).mtimeMs : null;
|
|
126
138
|
const prevEnv = process.env.SM_DEV;
|
|
139
|
+
const prevOverride = process.env.SM_ADMIN_TOKEN_PATH;
|
|
140
|
+
delete process.env.SM_ADMIN_TOKEN_PATH;
|
|
127
141
|
process.env.SM_DEV = '1';
|
|
128
142
|
const admin = createAdminHttp();
|
|
129
143
|
try {
|
|
@@ -141,32 +155,36 @@ test('SM_DEV=1 writes to a dev-suffixed path, never the production admin-api.jso
|
|
|
141
155
|
} finally {
|
|
142
156
|
await admin.stop();
|
|
143
157
|
if (prevEnv === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevEnv;
|
|
158
|
+
if (prevOverride !== undefined) process.env.SM_ADMIN_TOKEN_PATH = prevOverride;
|
|
144
159
|
fs.rmSync(devPath, { force: true });
|
|
145
160
|
}
|
|
146
161
|
});
|
|
147
162
|
|
|
148
|
-
test('with neither SM_DEV nor SM_E2E set,
|
|
163
|
+
test('with neither SM_DEV nor SM_E2E set, resolves to the original admin-api.json path', () => {
|
|
164
|
+
// Path RESOLUTION only — deliberately does not start() a server here. This
|
|
165
|
+
// test used to boot one with no override set, which wrote a throwaway
|
|
166
|
+
// port+token straight into the live app's admin-api.json and cut
|
|
167
|
+
// scheduler-mcp-server.cjs off from the running app. Persistence itself is
|
|
168
|
+
// covered by the 0600 test above, against the redirected test path.
|
|
149
169
|
const prevDev = process.env.SM_DEV;
|
|
150
170
|
const prevE2e = process.env.SM_E2E;
|
|
171
|
+
const prevOverride = process.env.SM_ADMIN_TOKEN_PATH;
|
|
151
172
|
delete process.env.SM_DEV;
|
|
152
173
|
delete process.env.SM_E2E;
|
|
153
|
-
|
|
174
|
+
delete process.env.SM_ADMIN_TOKEN_PATH;
|
|
154
175
|
try {
|
|
155
176
|
expect(resolveTokenPath()).toBe(TOKEN_PATH);
|
|
156
|
-
admin = createAdminHttp();
|
|
157
|
-
const { port, token } = await admin.start();
|
|
158
|
-
const parsed = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
|
|
159
|
-
expect(parsed.port).toBe(port);
|
|
160
|
-
expect(parsed.token).toBe(token);
|
|
161
177
|
} finally {
|
|
162
|
-
if (admin) await admin.stop();
|
|
163
178
|
if (prevDev === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevDev;
|
|
164
179
|
if (prevE2e === undefined) delete process.env.SM_E2E; else process.env.SM_E2E = prevE2e;
|
|
180
|
+
if (prevOverride !== undefined) process.env.SM_ADMIN_TOKEN_PATH = prevOverride;
|
|
165
181
|
}
|
|
166
182
|
});
|
|
167
183
|
|
|
168
184
|
test('dev and production token paths never collide', () => {
|
|
169
185
|
const prevEnv = process.env.SM_DEV;
|
|
186
|
+
const prevOverride = process.env.SM_ADMIN_TOKEN_PATH;
|
|
187
|
+
delete process.env.SM_ADMIN_TOKEN_PATH;
|
|
170
188
|
try {
|
|
171
189
|
process.env.SM_DEV = '1';
|
|
172
190
|
const devPath = resolveTokenPath();
|
|
@@ -175,6 +193,7 @@ test('dev and production token paths never collide', () => {
|
|
|
175
193
|
expect(devPath).not.toBe(prodPath);
|
|
176
194
|
} finally {
|
|
177
195
|
if (prevEnv === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevEnv;
|
|
196
|
+
if (prevOverride !== undefined) process.env.SM_ADMIN_TOKEN_PATH = prevOverride;
|
|
178
197
|
}
|
|
179
198
|
});
|
|
180
199
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Run: timeout 120 npx vitest run src/main/lib/__tests__/schedulerBatchDepends.test.cjs
|
|
4
|
+
//
|
|
5
|
+
// Regression coverage for dependsOn eligibility in pickForProject. The
|
|
6
|
+
// bare-slug cases below are the ones that shipped broken: a PRD's filename
|
|
7
|
+
// carries the auto-allocated `NN-` prefix, but `dependsOn:` is authored with
|
|
8
|
+
// the bare name (the author can't know the number the allocator will issue),
|
|
9
|
+
// so every such dep silently resolved to "no row -> already done" and the
|
|
10
|
+
// gate never held anything.
|
|
11
|
+
|
|
12
|
+
// vitest globals (test) — same convention as the other .cjs tests.
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
const { pickForProject, DEFAULT_PROJECT_CWD } = require('../schedulerBatch.cjs');
|
|
15
|
+
|
|
16
|
+
const CWD = DEFAULT_PROJECT_CWD;
|
|
17
|
+
|
|
18
|
+
function job(slug, status, extra = {}) {
|
|
19
|
+
return {
|
|
20
|
+
slug,
|
|
21
|
+
status,
|
|
22
|
+
cwd: CWD,
|
|
23
|
+
parallelGroup: Number(String(slug).match(/^(\d+)-/)?.[1] ?? 99),
|
|
24
|
+
dependsOn: [],
|
|
25
|
+
...extra,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function pick(jobs, running = new Set(), slots = 3) {
|
|
30
|
+
return pickForProject(jobs, running, slots);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('holds a dependent whose bare-named dep is still running (NN- prefix stripped)', () => {
|
|
34
|
+
const jobs = [
|
|
35
|
+
job('873-leftnav-two-face-framework', 'running'),
|
|
36
|
+
job('874-nav-face-project-home', 'pending', { dependsOn: ['leftnav-two-face-framework'] }),
|
|
37
|
+
];
|
|
38
|
+
const { batch } = pick(jobs, new Set(['873-leftnav-two-face-framework']));
|
|
39
|
+
assert.deepEqual(batch.map((j) => j.slug), []);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('holds a dependent whose bare-named dep is still pending', () => {
|
|
43
|
+
const jobs = [
|
|
44
|
+
job('873-leftnav-two-face-framework', 'pending'),
|
|
45
|
+
job('895-home-global-behavior-settings', 'pending', {
|
|
46
|
+
dependsOn: ['leftnav-two-face-framework', 'nav-face-session-manager-config'],
|
|
47
|
+
}),
|
|
48
|
+
job('890-nav-face-session-manager-config', 'pending', {
|
|
49
|
+
dependsOn: ['leftnav-two-face-framework'],
|
|
50
|
+
}),
|
|
51
|
+
];
|
|
52
|
+
const { batch } = pick(jobs);
|
|
53
|
+
// Only the framework PRD (no deps) is eligible; both dependents are held.
|
|
54
|
+
assert.deepEqual(batch.map((j) => j.slug), ['873-leftnav-two-face-framework']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('releases a dependent once its bare-named dep is completed', () => {
|
|
58
|
+
const jobs = [
|
|
59
|
+
job('873-leftnav-two-face-framework', 'completed'),
|
|
60
|
+
job('874-nav-face-project-home', 'pending', { dependsOn: ['leftnav-two-face-framework'] }),
|
|
61
|
+
];
|
|
62
|
+
const { batch } = pick(jobs);
|
|
63
|
+
assert.deepEqual(batch.map((j) => j.slug), ['874-nav-face-project-home']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('still resolves an exact-slug dep (dep written WITH the NN- prefix)', () => {
|
|
67
|
+
const jobs = [
|
|
68
|
+
job('873-leftnav-two-face-framework', 'pending'),
|
|
69
|
+
job('874-nav-face-project-home', 'pending', {
|
|
70
|
+
dependsOn: ['873-leftnav-two-face-framework'],
|
|
71
|
+
}),
|
|
72
|
+
];
|
|
73
|
+
const { batch } = pick(jobs);
|
|
74
|
+
assert.deepEqual(batch.map((j) => j.slug), ['873-leftnav-two-face-framework']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a dep with no row at all is treated as already done (archived/retired)', () => {
|
|
78
|
+
const jobs = [
|
|
79
|
+
job('874-nav-face-project-home', 'pending', { dependsOn: ['some-long-archived-prd'] }),
|
|
80
|
+
];
|
|
81
|
+
const { batch } = pick(jobs);
|
|
82
|
+
assert.deepEqual(batch.map((j) => j.slug), ['874-nav-face-project-home']);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a FAILED bare-named dep holds the dependent and reports an explicit reason', () => {
|
|
86
|
+
const jobs = [
|
|
87
|
+
job('873-leftnav-two-face-framework', 'failed'),
|
|
88
|
+
job('874-nav-face-project-home', 'pending', { dependsOn: ['leftnav-two-face-framework'] }),
|
|
89
|
+
];
|
|
90
|
+
const { batch, reason } = pick(jobs);
|
|
91
|
+
assert.deepEqual(batch, []);
|
|
92
|
+
assert.match(reason, /depends-gate/);
|
|
93
|
+
assert.match(reason, /874-nav-face-project-home <- leftnav-two-face-framework/);
|
|
94
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auditLog.cjs — append-only trace of every Epic/PRD creation, so a rogue or
|
|
3
|
+
* unexpected Epic/PRD can always be traced back to its origin.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately machine-level (~/.claude/session-manager/), not under any
|
|
6
|
+
* project's session-manager-operations/ — this is a security trace, not app
|
|
7
|
+
* state a project's single-writer law needs to arbitrate, and it must
|
|
8
|
+
* survive even if the project's ops root doesn't exist yet.
|
|
9
|
+
*
|
|
10
|
+
* Best-effort only: a logging failure must never block the mint/create it is
|
|
11
|
+
* observing (same posture as rcaFeedbackHook.cjs / dodDrainHook.cjs).
|
|
12
|
+
*
|
|
13
|
+
* Plain Node module (no Electron deps) so scripts/mint-epic.cjs and
|
|
14
|
+
* scripts/propose-epic.cjs (which run outside Electron) can require it too.
|
|
15
|
+
*/
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const AUDIT_LOG_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'audit-log.jsonl');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Best-effort identity of the process making this call — there is no
|
|
26
|
+
* cryptographic caller identity available across the admin HTTP API (bearer
|
|
27
|
+
* token proves "runs as this OS user", not "which agent/session"), so this
|
|
28
|
+
* captures every breadcrumb that IS available: this process's pid/ppid, and
|
|
29
|
+
* (when set) the claude session id chatRunner.cjs stamps on headless
|
|
30
|
+
* `claude -p` children and passes down to MCP server children.
|
|
31
|
+
*/
|
|
32
|
+
function callerContext() {
|
|
33
|
+
return {
|
|
34
|
+
pid: process.pid,
|
|
35
|
+
ppid: process.ppid,
|
|
36
|
+
claudeSessionId: process.env.SM_CHAT_SESSION_ID || null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* appendAuditEvent(kind, fields) — append one JSONL record. `kind` is a short
|
|
42
|
+
* label ('epic_mint' | 'prd_create' | ...); `fields` is event-specific detail
|
|
43
|
+
* (cwd, epicId, status, title, slug, etc). Never throws.
|
|
44
|
+
*/
|
|
45
|
+
function appendAuditEvent(kind, fields = {}) {
|
|
46
|
+
try {
|
|
47
|
+
const record = {
|
|
48
|
+
at: new Date().toISOString(),
|
|
49
|
+
kind,
|
|
50
|
+
...fields,
|
|
51
|
+
caller: callerContext(),
|
|
52
|
+
};
|
|
53
|
+
fs.mkdirSync(path.dirname(AUDIT_LOG_PATH), { recursive: true });
|
|
54
|
+
fs.appendFileSync(AUDIT_LOG_PATH, `${JSON.stringify(record)}\n`);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
console.error('[auditLog] failed to append audit event', e?.message ?? String(e));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { appendAuditEvent, AUDIT_LOG_PATH };
|
|
@@ -22,6 +22,7 @@ const path = require('node:path');
|
|
|
22
22
|
const crypto = require('node:crypto');
|
|
23
23
|
const { resolveEpicPrdWriteDir } = require('./prdLocations.cjs');
|
|
24
24
|
const { assertOpsWrite } = require('./opsOwnership.cjs');
|
|
25
|
+
const { appendAuditEvent } = require('./auditLog.cjs');
|
|
25
26
|
|
|
26
27
|
function activeIndexPath(cwd) {
|
|
27
28
|
return path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
|
|
@@ -164,6 +165,11 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
|
|
|
164
165
|
index.events[epicId] = [firstEvent];
|
|
165
166
|
writeActiveIndex(cwd, index);
|
|
166
167
|
|
|
168
|
+
// Every mint is logged, whether the Epic starts 'proposed' (human gate
|
|
169
|
+
// ahead) or 'active' (started immediately) — this is the trace-back point
|
|
170
|
+
// for "who/what created this Epic" (see auditLog.cjs).
|
|
171
|
+
appendAuditEvent('epic_mint', { cwd, epicId, status, tag: tag ?? null, goalText: session.goalText });
|
|
172
|
+
|
|
167
173
|
const prdDir = resolveEpicPrdWriteDir(cwd, epicId);
|
|
168
174
|
fs.mkdirSync(prdDir, { recursive: true });
|
|
169
175
|
return { epicId, prdDir, created: true };
|
|
@@ -49,6 +49,15 @@ const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'admin-
|
|
|
49
49
|
* harmless tie-break, since real launches only ever set one or the other.
|
|
50
50
|
*/
|
|
51
51
|
function resolveTokenPath() {
|
|
52
|
+
// Explicit override, highest priority. Exists because the unit tests call
|
|
53
|
+
// createAdminHttp().start() directly with neither SM_DEV nor SM_E2E set,
|
|
54
|
+
// which resolved to the PRODUCTION admin-api.json and overwrote the
|
|
55
|
+
// running app's port+token with a throwaway test server's — silently
|
|
56
|
+
// orphaning scheduler-mcp-server.cjs's access to the live app until the
|
|
57
|
+
// next app restart (confirmed live 2026-08-01: `npm run test:unit` made
|
|
58
|
+
// every scheduler_* MCP tool report "app is not running"). Tests point
|
|
59
|
+
// this at a tmp file; production never sets it.
|
|
60
|
+
if (process.env.SM_ADMIN_TOKEN_PATH) return process.env.SM_ADMIN_TOKEN_PATH;
|
|
52
61
|
if (process.env.SM_DEV === '1') {
|
|
53
62
|
return path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.dev.json');
|
|
54
63
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* opsErrorLog.cjs — sole writer of the `logs` ops namespace
|
|
5
|
+
* (single-writer law, see opsOwnership.cjs). Appends one structured JSONL
|
|
6
|
+
* line per error to `<cwd>/session-manager-operations/logs/errors-<date>.jsonl`
|
|
7
|
+
* so errors from every tab/surface land in one common, per-project place
|
|
8
|
+
* instead of scattered across console.error calls and the machine-global
|
|
9
|
+
* <userData>/logs/ (which mixes every project together and isn't tagged for
|
|
10
|
+
* tracing). Every call site funnels through `appendError` — that is what
|
|
11
|
+
* makes this the sole writer in practice, not just in the OWNERS map.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { assertOpsWrite } = require('./opsOwnership.cjs');
|
|
17
|
+
|
|
18
|
+
// Same redaction policy as logs.cjs's sanitizeMeta — kept independent (not
|
|
19
|
+
// shared/required) so this module has no dependency on the Electron `app`
|
|
20
|
+
// module and can be unit-tested without a running app.
|
|
21
|
+
const REDACT_KEY = /^(transcript|interim|final|text|content|partial|userText|message|token|secret|password|authorization|cookie|api[_-]?key|access[_-]?token|refresh[_-]?token)$/i;
|
|
22
|
+
|
|
23
|
+
function sanitizeMeta(meta) {
|
|
24
|
+
if (!meta || typeof meta !== 'object') return meta;
|
|
25
|
+
if (Array.isArray(meta)) return meta;
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
28
|
+
out[k] = REDACT_KEY.test(k) ? '[redacted]' : v;
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function logsDir(cwd) {
|
|
34
|
+
return path.join(cwd, 'session-manager-operations', 'logs');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function todayFile(cwd) {
|
|
38
|
+
const d = new Date();
|
|
39
|
+
const yyyy = d.getFullYear();
|
|
40
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
41
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
42
|
+
return path.join(logsDir(cwd), `errors-${yyyy}-${mm}-${dd}.jsonl`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Append one structured error line, tagged for tracing/analysis.
|
|
47
|
+
*
|
|
48
|
+
* @param {{
|
|
49
|
+
* cwd: string, // required — which project's ops root owns this line
|
|
50
|
+
* scope: string, // subsystem name, e.g. 'pty', 'chatRunner', 'voice'
|
|
51
|
+
* level?: string, // default 'error'; 'warn' also accepted
|
|
52
|
+
* tabId?: string, // claudeSessionId of the tab this error belongs to, if any
|
|
53
|
+
* epicId?: string, // Epic id, if this error happened inside an Epic-backed run
|
|
54
|
+
* tags?: string[], // extra caller-supplied tags, merged with the auto-derived ones
|
|
55
|
+
* message: string,
|
|
56
|
+
* meta?: unknown,
|
|
57
|
+
* }} entry
|
|
58
|
+
*/
|
|
59
|
+
function appendError({ cwd, scope, level = 'error', tabId, epicId, tags = [], message, meta }) {
|
|
60
|
+
if (!cwd || typeof cwd !== 'string') return; // no project to attribute this line to — skip
|
|
61
|
+
const file = todayFile(cwd);
|
|
62
|
+
try {
|
|
63
|
+
assertOpsWrite(file, 'logs');
|
|
64
|
+
} catch {
|
|
65
|
+
return; // fail-closed, same as every other ops write path — never throw from a log call
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const autoTags = [
|
|
69
|
+
`level:${level}`,
|
|
70
|
+
`scope:${scope || 'unknown'}`,
|
|
71
|
+
...(tabId ? [`tab:${tabId}`] : []),
|
|
72
|
+
...(epicId ? [`epic:${epicId}`] : []),
|
|
73
|
+
];
|
|
74
|
+
const allTags = Array.from(new Set([...autoTags, ...tags]));
|
|
75
|
+
|
|
76
|
+
const line = {
|
|
77
|
+
ts: new Date().toISOString(),
|
|
78
|
+
level,
|
|
79
|
+
scope: scope || 'unknown',
|
|
80
|
+
tabId: tabId || null,
|
|
81
|
+
epicId: epicId || null,
|
|
82
|
+
tags: allTags,
|
|
83
|
+
message: String(message ?? ''),
|
|
84
|
+
...(meta !== undefined ? { meta: sanitizeMeta(meta) } : {}),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
fs.mkdirSync(logsDir(cwd), { recursive: true });
|
|
89
|
+
// 0o600: same rationale as logs.cjs — error meta can carry local paths /
|
|
90
|
+
// device labels, keep it off other local users' reach.
|
|
91
|
+
const fd = fs.openSync(file, 'a', 0o600);
|
|
92
|
+
try { fs.writeSync(fd, JSON.stringify(line) + '\n'); } finally { fs.closeSync(fd); }
|
|
93
|
+
} catch { /* best-effort — a logging failure must never break the caller */ }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { appendError, logsDir, todayFile };
|