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/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-H7rwsoKV.js"></script>
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.0",
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
+ });
@@ -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
- const childEnv = cleanChildEnv({ PATH: pathWithUserBins() });
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
@@ -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) => writeLine(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);
@@ -1283,7 +1283,13 @@ export interface SessionManagerAPI {
1283
1283
  status: () => Promise<McpStatusResult>;
1284
1284
  };
1285
1285
  logs: {
1286
- write: (scope: string, level: 'debug' | 'info' | 'warn' | 'error', message: string, meta?: unknown) => void;
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: {
@@ -146,8 +146,11 @@ contextBridge.exposeInMainWorld('api', {
146
146
  status: () => ipcRenderer.invoke('mcp:status'),
147
147
  },
148
148
  logs: {
149
- write: (scope, level, message, meta) =>
150
- ipcRenderer.send('log:write', { scope, level, message, meta }),
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: {