claude-code-session-manager 0.41.0 → 0.41.1
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-Cr-O75Yg.js} +1 -1
- package/dist/assets/{index-H7rwsoKV.js → index-CV9TNjxX.js} +107 -107
- package/dist/index.html +1 -1
- package/package.json +1 -1
- 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/opsErrorLog.cjs +96 -0
- package/src/main/lib/opsOwnership.cjs +3 -0
- package/src/main/lib/prdCreate.cjs +30 -0
- package/src/main/logs.cjs +19 -1
- package/src/main/pty.cjs +15 -0
- package/src/preload/api.d.ts +7 -1
- package/src/preload/index.cjs +5 -2
package/dist/index.html
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
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-CV9TNjxX.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
13
|
<link rel="stylesheet" crossorigin href="./assets/index-CKY3mHgV.css">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-session-manager",
|
|
3
|
-
"version": "0.41.
|
|
3
|
+
"version": "0.41.1",
|
|
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",
|
|
@@ -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(),
|
|
@@ -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 };
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* - config.cjs writeTextAtomic / writeBinaryAtomic / writeJson / writeJsonSync
|
|
27
27
|
* - lib/epicMint.cjs (raw fs tmp+rename)
|
|
28
28
|
* - lib/queueStore.cjs (raw fs tmp+rename)
|
|
29
|
+
* - lib/opsErrorLog.cjs (raw fs append, JSONL error log)
|
|
29
30
|
* Renderer callers declare their writer id through the IPC payload.
|
|
30
31
|
*/
|
|
31
32
|
|
|
@@ -51,6 +52,8 @@ const OWNERS = Object.freeze({
|
|
|
51
52
|
'feedback': 'feedback',
|
|
52
53
|
// Browser tab scratch saves (DOM captures, screenshots, recorded flows).
|
|
53
54
|
'browser': 'browser',
|
|
55
|
+
// Structured per-tab error log lines (JSONL), tagged for tracing/analysis.
|
|
56
|
+
'logs': 'logs',
|
|
54
57
|
});
|
|
55
58
|
|
|
56
59
|
/**
|
|
@@ -23,6 +23,7 @@ const { kebabCase } = require('./kebabCase.cjs');
|
|
|
23
23
|
const config = require('../config.cjs');
|
|
24
24
|
const { expandHome } = require('./expandHome.cjs');
|
|
25
25
|
const { readBody, sendJson } = require('./localAdminHttp.cjs');
|
|
26
|
+
const { readActiveIndex } = require('./epicMint.cjs');
|
|
26
27
|
|
|
27
28
|
const STANDARDS_PATH = path.join(
|
|
28
29
|
__dirname, '..', '..', '..',
|
|
@@ -90,6 +91,29 @@ function buildPrdBody(input) {
|
|
|
90
91
|
return `${fmLines.join('\n')}${bodyLines.join('\n')}`;
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Best-effort fallback for a PRD queued via the scheduler_create_prd MCP tool
|
|
96
|
+
* from inside an Epic's own headless chat session, when the model's tool
|
|
97
|
+
* call omitted `sourcePromptId` — the exact gap that let PRDs 866/867 mint
|
|
98
|
+
* two unrelated sibling Epics instead of joining the Epic that was actually
|
|
99
|
+
* driving the conversation (2026-08-01). `claudeSessionId` is this process's
|
|
100
|
+
* `originClaudeSessionId` input (chatRunner.cjs's SM_CHAT_SESSION_ID env var,
|
|
101
|
+
* inherited down to the MCP server child); looked up against `cwd`'s
|
|
102
|
+
* active-index.json for the Epic whose own claudeSessionId matches (domain
|
|
103
|
+
* model: Epic:claude-session is 1:1). Returns null for a non-Epic tab (plain
|
|
104
|
+
* SessionTab chat) or when the id is stale/unknown — never throws, since this
|
|
105
|
+
* is strictly a convenience default and a miss must fall back to minting a
|
|
106
|
+
* normal Epic exactly like today.
|
|
107
|
+
*/
|
|
108
|
+
function resolveSourcePromptIdFromClaudeSession(cwd, claudeSessionId) {
|
|
109
|
+
if (!claudeSessionId) return null;
|
|
110
|
+
const { sessions } = readActiveIndex(cwd);
|
|
111
|
+
for (const session of Object.values(sessions)) {
|
|
112
|
+
if (session && session.claudeSessionId === claudeSessionId) return session.id;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
93
117
|
/**
|
|
94
118
|
* Core create-PRD orchestration: cwd validation, slug derivation, NN
|
|
95
119
|
* allocation, existing-file collision check, body build, write. Shared by
|
|
@@ -130,6 +154,11 @@ async function createPrd(input, remote) {
|
|
|
130
154
|
config.addAllowedRoot(realCwd);
|
|
131
155
|
input = { ...input, cwd };
|
|
132
156
|
|
|
157
|
+
if (!input.sourcePromptId && input.originClaudeSessionId) {
|
|
158
|
+
const resolved = resolveSourcePromptIdFromClaudeSession(cwd, input.originClaudeSessionId);
|
|
159
|
+
if (resolved) input = { ...input, sourcePromptId: resolved };
|
|
160
|
+
}
|
|
161
|
+
|
|
133
162
|
const slug = input.slug || deriveSlugFromTitle(input.title);
|
|
134
163
|
if (!slug || !PRD_CREATE_SLUG_RE.test(slug)) {
|
|
135
164
|
return { ok: false, status: 400, error: 'could not derive a valid kebab-case slug from title; supply "slug" explicitly' };
|
|
@@ -209,6 +238,7 @@ module.exports = {
|
|
|
209
238
|
readStandards,
|
|
210
239
|
deriveSlugFromTitle,
|
|
211
240
|
buildPrdBody,
|
|
241
|
+
resolveSourcePromptIdFromClaudeSession,
|
|
212
242
|
createPrd,
|
|
213
243
|
registerAdminRoute,
|
|
214
244
|
};
|
package/src/main/logs.cjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { app, ipcMain } = require('electron');
|
|
2
2
|
const fs = require('node:fs');
|
|
3
3
|
const path = require('node:path');
|
|
4
|
+
const opsErrorLog = require('./lib/opsErrorLog.cjs');
|
|
4
5
|
|
|
5
6
|
// Rolling daily files under <userData>/logs/. Anything mtime'd older than
|
|
6
7
|
// RETENTION_MS is pruned at boot; we never compact mid-session, since the
|
|
@@ -75,7 +76,24 @@ function writeLine(payload) {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
function registerLogHandlers() {
|
|
78
|
-
ipcMain.on('log:write', (_e, payload) =>
|
|
79
|
+
ipcMain.on('log:write', (_e, payload) => {
|
|
80
|
+
writeLine(payload);
|
|
81
|
+
// Renderer errors that name which project/tab they belong to also land
|
|
82
|
+
// in that project's own session-manager-operations/logs/ — the
|
|
83
|
+
// machine-global file above mixes every project together and isn't
|
|
84
|
+
// taggable, so it's not a substitute for the per-project trace.
|
|
85
|
+
if (payload && payload.level === 'error' && payload.cwd) {
|
|
86
|
+
opsErrorLog.appendError({
|
|
87
|
+
cwd: payload.cwd,
|
|
88
|
+
scope: payload.scope,
|
|
89
|
+
tabId: payload.tabId,
|
|
90
|
+
epicId: payload.epicId,
|
|
91
|
+
tags: Array.isArray(payload.tags) ? payload.tags : [],
|
|
92
|
+
message: payload.message,
|
|
93
|
+
meta: payload.meta,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
});
|
|
79
97
|
ipcMain.handle('log:dir', () => logDir());
|
|
80
98
|
}
|
|
81
99
|
|
package/src/main/pty.cjs
CHANGED
|
@@ -20,6 +20,7 @@ const { addAllowedRoot } = require('./config.cjs');
|
|
|
20
20
|
const { cleanChildEnv, pathWithUserBins } = require('./lib/cleanEnv.cjs');
|
|
21
21
|
const { checkInsideHome } = require('./lib/insideHome.cjs');
|
|
22
22
|
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
23
|
+
const opsErrorLog = require('./lib/opsErrorLog.cjs');
|
|
23
24
|
|
|
24
25
|
// Absolute path to the installed package root (src/main/ -> ../../), shown in
|
|
25
26
|
// the remediation message so the user can cd there and rebuild.
|
|
@@ -162,6 +163,13 @@ class PtyManager {
|
|
|
162
163
|
});
|
|
163
164
|
} catch (err) {
|
|
164
165
|
console.error('[pty] pty.spawn threw:', err);
|
|
166
|
+
opsErrorLog.appendError({
|
|
167
|
+
cwd,
|
|
168
|
+
scope: 'pty',
|
|
169
|
+
tabId,
|
|
170
|
+
tags: ['spawn-failed'],
|
|
171
|
+
message: `pty.spawn threw: ${err?.message ?? String(err)}`,
|
|
172
|
+
});
|
|
165
173
|
// Surface the cause in the tab + report a clean exit rather than throwing
|
|
166
174
|
// an opaque IPC error (covers a missing/invalid cwd or a native fault).
|
|
167
175
|
sendIfAlive(this.window, `pty:data:${tabId}`, nativeModuleHelp(`pty.spawn failed: ${err?.message ?? String(err)}`));
|
|
@@ -196,6 +204,13 @@ class PtyManager {
|
|
|
196
204
|
// real interactive shell (which prints a prompt immediately) is exempt.
|
|
197
205
|
if (!gotData && exitCode !== 0 && Date.now() - spawnedAt < 1200) {
|
|
198
206
|
sendIfAlive(this.window, `pty:data:${tabId}`, nativeModuleHelp(`The shell exited immediately (code ${exitCode}).`));
|
|
207
|
+
opsErrorLog.appendError({
|
|
208
|
+
cwd,
|
|
209
|
+
scope: 'pty',
|
|
210
|
+
tabId,
|
|
211
|
+
tags: ['immediate-exit'],
|
|
212
|
+
message: `shell exited immediately (code ${exitCode}, signal ${signal ?? 'none'}) after ${Date.now() - spawnedAt}ms with no output`,
|
|
213
|
+
});
|
|
199
214
|
}
|
|
200
215
|
sendIfAlive(this.window, `pty:exit:${tabId}`, { exitCode, signal });
|
|
201
216
|
this.sessions.delete(tabId);
|
package/src/preload/api.d.ts
CHANGED
|
@@ -1283,7 +1283,13 @@ export interface SessionManagerAPI {
|
|
|
1283
1283
|
status: () => Promise<McpStatusResult>;
|
|
1284
1284
|
};
|
|
1285
1285
|
logs: {
|
|
1286
|
-
write: (
|
|
1286
|
+
write: (
|
|
1287
|
+
scope: string,
|
|
1288
|
+
level: 'debug' | 'info' | 'warn' | 'error',
|
|
1289
|
+
message: string,
|
|
1290
|
+
meta?: unknown,
|
|
1291
|
+
ctx?: { cwd?: string; tabId?: string; epicId?: string; tags?: string[] },
|
|
1292
|
+
) => void;
|
|
1287
1293
|
dir: () => Promise<string>;
|
|
1288
1294
|
};
|
|
1289
1295
|
config: {
|
package/src/preload/index.cjs
CHANGED
|
@@ -146,8 +146,11 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
146
146
|
status: () => ipcRenderer.invoke('mcp:status'),
|
|
147
147
|
},
|
|
148
148
|
logs: {
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
// ctx is optional: { cwd, tabId, epicId, tags } — when present and
|
|
150
|
+
// level is 'error', main also appends a tagged line to that project's
|
|
151
|
+
// own session-manager-operations/logs/ (see opsErrorLog.cjs).
|
|
152
|
+
write: (scope, level, message, meta, ctx) =>
|
|
153
|
+
ipcRenderer.send('log:write', { scope, level, message, meta, ...ctx }),
|
|
151
154
|
dir: () => ipcRenderer.invoke('log:dir'),
|
|
152
155
|
},
|
|
153
156
|
config: {
|