claude-code-session-manager 0.37.2 → 0.38.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.
Files changed (33) hide show
  1. package/dist/assets/{TiptapBody-BtrSXTRp.js → TiptapBody-GMk5LS9Y.js} +1 -1
  2. package/dist/assets/{index-uVGdpAGF.js → index-CQqSHpIq.js} +250 -250
  3. package/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
  6. package/plugins/session-manager-dev/skills/issue-address/0-select/0-fetch-pool/SKILL.md +38 -0
  7. package/plugins/session-manager-dev/skills/issue-address/0-select/1-reject-fixed/SKILL.md +64 -0
  8. package/plugins/session-manager-dev/skills/issue-address/0-select/2-reject-claimed/SKILL.md +71 -0
  9. package/plugins/session-manager-dev/skills/issue-address/0-select/3-rank-impact/SKILL.md +39 -0
  10. package/plugins/session-manager-dev/skills/issue-address/0-select/SKILL.md +75 -0
  11. package/plugins/session-manager-dev/skills/issue-address/1-confirm-open/SKILL.md +39 -0
  12. package/plugins/session-manager-dev/skills/issue-address/2-claim/SKILL.md +57 -0
  13. package/plugins/session-manager-dev/skills/issue-address/3-reproduce/SKILL.md +40 -0
  14. package/plugins/session-manager-dev/skills/issue-address/4-fix/SKILL.md +33 -0
  15. package/plugins/session-manager-dev/skills/issue-address/5-verify/SKILL.md +40 -0
  16. package/plugins/session-manager-dev/skills/issue-address/SKILL.md +120 -0
  17. package/plugins/session-manager-dev/skills/pr-review-sweep/0-fetch/SKILL.md +41 -0
  18. package/plugins/session-manager-dev/skills/pr-review-sweep/1-classify/SKILL.md +45 -0
  19. package/plugins/session-manager-dev/skills/pr-review-sweep/2-check-fixed/SKILL.md +49 -0
  20. package/plugins/session-manager-dev/skills/pr-review-sweep/3-queue/SKILL.md +50 -0
  21. package/plugins/session-manager-dev/skills/pr-review-sweep/4-land-and-resolve/SKILL.md +60 -0
  22. package/plugins/session-manager-dev/skills/pr-review-sweep/SKILL.md +124 -0
  23. package/plugins/session-manager-dev/skills/pr-signal/SKILL.md +67 -0
  24. package/src/main/__tests__/chat-cancel-terminal.test.cjs +31 -0
  25. package/src/main/__tests__/chat-exit-close-race.test.cjs +140 -0
  26. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +64 -0
  27. package/src/main/chatRunner.cjs +37 -12
  28. package/src/main/ipcSchemas.cjs +2 -3
  29. package/src/main/scheduler.cjs +27 -4
  30. package/src/main/sessionsStore.cjs +4 -5
  31. package/src/main/webRemote.cjs +3 -3
  32. package/src/preload/api.d.ts +4 -5
  33. package/src/preload/index.cjs +3 -2
@@ -0,0 +1,140 @@
1
+ /**
2
+ * chat-exit-close-race.test.cjs — regression for the exit/close race that
3
+ * silently dropped a pure-text-only final turn (PRD 716).
4
+ *
5
+ * Bug: the terminal-event fallback was wired to `child.on('exit', ...)`
6
+ * instead of `child.on('close', ...)`. Node only guarantees all buffered
7
+ * stdout 'data' events have been delivered by 'close', not 'exit' — 'exit'
8
+ * can fire before the final stdout chunk (last assistant text + terminating
9
+ * `result` line) reaches the parent's 'data' handler. When that happened,
10
+ * the exit fallback ran first, set the one-shot terminalSent latch, and
11
+ * broadcast a misleading chat:run:error. The real result line then parsed
12
+ * moments later, but emitTerminal silently no-op'd and the genuine
13
+ * chat:run:complete was dropped.
14
+ *
15
+ * Fix: use `child.on('close', ...)` for the fallback so it only fires once
16
+ * Node guarantees stdout is fully drained — a real result event that arrives
17
+ * (even "late", right up against process exit) always wins.
18
+ *
19
+ * This test mocks node:child_process.spawn so it can deterministically
20
+ * reproduce the exact race ordering (exit fires, THEN the final stdout chunk
21
+ * arrives, THEN close fires) rather than relying on real OS/pipe timing,
22
+ * which is not reliably reproducible from a real child process.
23
+ *
24
+ * Run: timeout 120 node --test src/main/__tests__/chat-exit-close-race.test.cjs
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ delete process.env.SM_CHAT_CONCURRENCY;
30
+
31
+ const { test } = require('node:test');
32
+ const assert = require('node:assert/strict');
33
+ const EventEmitter = require('node:events');
34
+ const cp = require('node:child_process');
35
+
36
+ // Replace spawn BEFORE chatRunner.cjs is first required, so the module's
37
+ // top-level `const { spawn } = require('node:child_process')` destructuring
38
+ // captures this mock instead of the real implementation.
39
+ const originalSpawn = cp.spawn;
40
+ let nextChild = null;
41
+ cp.spawn = () => {
42
+ const child = new EventEmitter();
43
+ child.stdout = new EventEmitter();
44
+ child.stderr = new EventEmitter();
45
+ child.pid = 424242;
46
+ child.kill = () => {};
47
+ nextChild = child;
48
+ return child;
49
+ };
50
+
51
+ const cr = require('../chatRunner.cjs');
52
+
53
+ const tick = () => new Promise((r) => setImmediate(r));
54
+
55
+ test('a final-text-only turn whose stdout chunk lands after exit still surfaces chat:run:complete', async () => {
56
+ nextChild = null;
57
+ const events = [];
58
+ cr.attachWindow({
59
+ isDestroyed: () => false,
60
+ webContents: {
61
+ isDestroyed: () => false,
62
+ send: (channel, payload) => events.push({ channel, payload }),
63
+ },
64
+ });
65
+
66
+ cr.run({ tabId: 'T2', sessionId: 'S2', prompt: 'show me a sample post', cwd: process.cwd(), resume: false });
67
+
68
+ for (let i = 0; i < 20 && !nextChild; i++) await tick();
69
+ const child = nextChild;
70
+ assert.ok(child, 'spawn should have been invoked');
71
+
72
+ const text = 'Here is your sample post, all in one go.';
73
+ const lines =
74
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text }] } }) +
75
+ '\n' +
76
+ JSON.stringify({ type: 'result', subtype: 'success', result: text }) +
77
+ '\n';
78
+
79
+ // The exact race: 'exit' fires first (process already gone from the OS's
80
+ // perspective), the final stdout chunk is delivered to the 'data' handler
81
+ // only after that, and 'close' — which Node guarantees fires after all
82
+ // stdout data has been delivered — fires last.
83
+ child.emit('exit', 0, null);
84
+ child.stdout.emit('data', Buffer.from(lines));
85
+ child.emit('close', 0, null);
86
+
87
+ await tick();
88
+ await tick();
89
+
90
+ const terminal = events.filter((e) => isTerminal(e.channel));
91
+ assert.equal(terminal.length, 1, 'exactly one terminal event is emitted');
92
+ assert.equal(
93
+ terminal[0].channel,
94
+ 'chat:run:complete',
95
+ 'the real result event must win over the generic exit-fallback error',
96
+ );
97
+ assert.equal(terminal[0].payload.finalMessage, text);
98
+ });
99
+
100
+ test('a crashed process with no result event ever emitted still fires the fallback chat:run:error', async () => {
101
+ nextChild = null;
102
+ const events = [];
103
+ cr.attachWindow({
104
+ isDestroyed: () => false,
105
+ webContents: {
106
+ isDestroyed: () => false,
107
+ send: (channel, payload) => events.push({ channel, payload }),
108
+ },
109
+ });
110
+
111
+ cr.run({ tabId: 'T3', sessionId: 'S3', prompt: 'anything', cwd: process.cwd(), resume: false });
112
+
113
+ for (let i = 0; i < 20 && !nextChild; i++) await tick();
114
+ const child = nextChild;
115
+ assert.ok(child, 'spawn should have been invoked');
116
+
117
+ // Crashes with no stdout at all — 'close' is the only event that fires.
118
+ child.emit('close', 1, null);
119
+
120
+ await tick();
121
+ await tick();
122
+
123
+ const terminal = events.filter((e) => isTerminal(e.channel));
124
+ assert.equal(terminal.length, 1, 'exactly one terminal event is emitted');
125
+ assert.equal(terminal[0].channel, 'chat:run:error', 'a crash with no result surfaces as an error turn');
126
+ assert.match(terminal[0].payload.message, /process exited without a result event/);
127
+ });
128
+
129
+ function isTerminal(channel) {
130
+ return (
131
+ channel === 'chat:run:complete' ||
132
+ channel === 'chat:run:needs-input' ||
133
+ channel === 'chat:run:error'
134
+ );
135
+ }
136
+
137
+ test.after(() => {
138
+ cp.spawn = originalSpawn;
139
+ delete process.env.SM_CHAT_CONCURRENCY;
140
+ });
@@ -61,3 +61,67 @@ test('committedInWindow returns false when the window covers no commit', async (
61
61
  fs.rmSync(dir, { recursive: true, force: true });
62
62
  }
63
63
  });
64
+
65
+ // Reproduces the sigma PRD 713 incident: a commit made+pushed from an
66
+ // isolated worktree checkout that shares the run's remote but not job.cwd's
67
+ // local refs — e.g. `git worktree add /tmp/foo` followed by `git worktree
68
+ // remove` clears the worktree's local branch, leaving the commit reachable
69
+ // in job.cwd only via a remote-tracking ref that hasn't been fetched yet.
70
+ // Modeled here as a second, independent clone of the shared remote (rather
71
+ // than a literal `git worktree add`, which shares job.cwd's .git store and
72
+ // so opportunistically updates job.cwd's remote-tracking ref on push,
73
+ // masking the staleness this test needs to exercise) — the observable
74
+ // symptom is identical: job.cwd's remote-tracking ref is stale until fetched.
75
+ test('committedInWindow sees a commit pushed from an isolated checkout, only reachable via a stale remote-tracking ref', async () => {
76
+ const bareDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-committed-in-window-bare-'));
77
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-committed-in-window-cwd-'));
78
+ const worktreeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-committed-in-window-wt-'));
79
+ try {
80
+ git(bareDir, ['init', '-q', '--bare']);
81
+
82
+ const seedDir = makeRepo();
83
+ try {
84
+ git(seedDir, ['push', bareDir, 'HEAD:refs/heads/main']);
85
+ // Without an explicit default branch, the bare repo's symbolic HEAD can
86
+ // point at a nonexistent ref (e.g. 'master'), which makes the clone
87
+ // below check out no local branch at all — set it explicitly.
88
+ git(bareDir, ['symbolic-ref', 'HEAD', 'refs/heads/main']);
89
+ } finally {
90
+ fs.rmSync(seedDir, { recursive: true, force: true });
91
+ }
92
+
93
+ git(cwd, ['clone', '-q', bareDir, '.']);
94
+ git(cwd, ['config', 'user.email', 'test@example.com']);
95
+ git(cwd, ['config', 'user.name', 'Test']);
96
+
97
+ const startedAt = new Date().toISOString();
98
+
99
+ // Simulate the PRD's own isolated-checkout workflow: a separate clone of
100
+ // the same shared remote, a commit there, a push back to that remote —
101
+ // job.cwd never touches this checkout and never fetches after the push.
102
+ git(worktreeDir, ['clone', '-q', bareDir, '.']);
103
+ git(worktreeDir, ['config', 'user.email', 'test@example.com']);
104
+ git(worktreeDir, ['config', 'user.name', 'Test']);
105
+ git(worktreeDir, ['checkout', '-q', '-b', 'temp-merge-branch']);
106
+ fs.writeFileSync(path.join(worktreeDir, 'feature.txt'), 'feature\n');
107
+ git(worktreeDir, ['add', '.']);
108
+ git(worktreeDir, ['commit', '-q', '-m', 'feature commit from isolated worktree']);
109
+ git(worktreeDir, ['push', 'origin', 'temp-merge-branch']);
110
+
111
+ const finishedAt = new Date(Date.now() + 1000).toISOString();
112
+
113
+ // Precondition: without a fetch, job.cwd genuinely cannot see the commit —
114
+ // this is what made committedInWindow's old bare `git log --all` blind to it.
115
+ expect(() => git(cwd, ['log', '--all', '--format=%H', '--grep=feature commit from isolated worktree']))
116
+ .not.toThrow();
117
+ const beforeFetch = git(cwd, ['log', '--all', '--format=%H', '--grep=feature commit from isolated worktree']);
118
+ expect(beforeFetch).toBe('');
119
+
120
+ const result = await committedInWindow(cwd, startedAt, finishedAt);
121
+ expect(result).toBe(true);
122
+ } finally {
123
+ fs.rmSync(bareDir, { recursive: true, force: true });
124
+ fs.rmSync(cwd, { recursive: true, force: true });
125
+ fs.rmSync(worktreeDir, { recursive: true, force: true });
126
+ }
127
+ });
@@ -18,7 +18,7 @@
18
18
  * `silent` (PRD 470) runs still go through the CONCURRENCY_CAP FIFO lane and suppress the
19
19
  * six turn-affecting broadcasts + recordExchange (see probeContextUsage below). Non-silent
20
20
  * (manual) runs (PRD 493) execute immediately, uncapped, never entering the FIFO lane.
21
- * cancel(tabId): void
21
+ * cancel(tabId): Promise<void> — resolves once the cancelled run fully settles
22
22
  * parseStopSignal(finalText): { questions: string[] } | null — exported for reuse
23
23
  * parseContextUsageMarkdown(text): { usedTokens, totalTokens, usedPct, categories } | null
24
24
  * — pure parser for `/context`
@@ -284,7 +284,11 @@ function getConcurrencyCap() {
284
284
  );
285
285
  }
286
286
 
287
- // tabId → cancel() for every ACTIVE run; FIFO list of WAITING runs; live count.
287
+ // tabId → { cancelFn, donePromise } for every ACTIVE run; FIFO list of WAITING
288
+ // runs; live count. `donePromise` resolves once the run's own executeRun()
289
+ // promise settles (i.e. after settle() runs for that tabId) — attached right
290
+ // after the executor is invoked (see run()/pump()) so cancel() callers can
291
+ // await full teardown instead of firing SIGTERM and returning immediately.
288
292
  const inFlight = new Map();
289
293
  const waiting = []; // [{ tabId, sessionId, prompt, cwd, resume, silent, onSilentResult }]
290
294
  let activeCount = 0;
@@ -341,7 +345,10 @@ function run(opts) {
341
345
  // CLAUDE.md no longer strictly holds for foreground sessions (accepted
342
346
  // tradeoff). executeRun still registers in inFlight synchronously, so the
343
347
  // per-tab guard above and cancel() keep working; no activeCount, no pump.
344
- executor(opts).catch(() => { /* executeRun never rejects; defensive */ });
348
+ const donePromise = executor(opts);
349
+ const entry = inFlight.get(opts.tabId);
350
+ if (entry) entry.donePromise = donePromise;
351
+ donePromise.catch(() => { /* executeRun never rejects; defensive */ });
345
352
  }
346
353
 
347
354
  // Fill open lanes FIFO up to CONCURRENCY_CAP, then announce queue positions for
@@ -351,7 +358,12 @@ function pump() {
351
358
  const job = waiting.shift();
352
359
  activeCount += 1;
353
360
  Promise.resolve()
354
- .then(() => executor(job))
361
+ .then(() => {
362
+ const donePromise = executor(job);
363
+ const entry = inFlight.get(job.tabId);
364
+ if (entry) entry.donePromise = donePromise;
365
+ return donePromise;
366
+ })
355
367
  .catch(() => { /* executeRun never rejects; defensive */ })
356
368
  .finally(() => { activeCount -= 1; pump(); });
357
369
  }
@@ -483,7 +495,9 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
483
495
  setTimeout(() => doKill('SIGKILL'), 5000).unref?.();
484
496
  };
485
497
 
486
- inFlight.set(tabId, cancelFn);
498
+ // donePromise is attached by the caller (run()/pump()) right after this
499
+ // executor invocation returns — see comment above the inFlight Map decl.
500
+ inFlight.set(tabId, { cancelFn, donePromise: null });
487
501
 
488
502
  // Hard wall-clock ceiling — SIGTERM + SIGKILL on expiry
489
503
  const killTimer = setTimeout(() => {
@@ -607,7 +621,13 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
607
621
  settle();
608
622
  });
609
623
 
610
- child.on('exit', (code, signal) => {
624
+ // 'close' (not 'exit') so this fallback only runs after Node guarantees
625
+ // every buffered stdout 'data' chunk has been delivered — 'exit' can fire
626
+ // before the final chunk (last assistant text + terminating `result`
627
+ // line) reaches the 'data' handler above, which raced this fallback into
628
+ // firing first and dropping the real chat:run:complete behind the
629
+ // terminalSent latch.
630
+ child.on('close', (code, signal) => {
611
631
  clearTimeout(killTimer);
612
632
  // Flush any partial line that didn't end with \n
613
633
  if (lineBuffer.trim()) processLine(lineBuffer.trim());
@@ -644,12 +664,16 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
644
664
  * Cancel a run for the given tabId. An ACTIVE run is SIGTERM→SIGKILL'd (its
645
665
  * child exit funnels through settle → pump, freeing the lane). A still-WAITING
646
666
  * run is dropped from the queue and announced as cancelled. No-op otherwise.
667
+ *
668
+ * @returns {Promise<void>} resolves once the cancelled run has fully settled
669
+ * (i.e. its terminal IPC event has fired) — or immediately for a waiting/
670
+ * no-op cancel, since there is nothing further to await in those cases.
647
671
  */
648
672
  function cancel(tabId) {
649
- const fn = inFlight.get(tabId);
650
- if (fn) {
651
- fn(); // settle() (on child exit) deletes from inFlight + pumps the next run
652
- return;
673
+ const entry = inFlight.get(tabId);
674
+ if (entry) {
675
+ entry.cancelFn(); // settle() (on child exit) deletes from inFlight + pumps the next run
676
+ return entry.donePromise || Promise.resolve();
653
677
  }
654
678
  const idx = waiting.findIndex((w) => w.tabId === tabId);
655
679
  if (idx !== -1) {
@@ -661,6 +685,7 @@ function cancel(tabId) {
661
685
  });
662
686
  pump(); // refresh remaining queue positions
663
687
  }
688
+ return Promise.resolve();
664
689
  }
665
690
 
666
691
  // ─── IPC handler registration ─────────────────────────────────────────────
@@ -673,10 +698,10 @@ function registerChatHandlers() {
673
698
  return { ok: true };
674
699
  }));
675
700
 
676
- ipcMain.on('chat:cancel', (_e, payload) => {
701
+ ipcMain.handle('chat:cancel', async (_e, payload) => {
677
702
  let tabId;
678
703
  try { tabId = schemas.chatCancel.parse(payload).tabId; } catch { return; }
679
- cancel(tabId);
704
+ await cancel(tabId);
680
705
  });
681
706
 
682
707
  ipcMain.handle('chat:probe-context', validated(schemas.chatProbeContext, async ({ tabId, sessionId, cwd }) => {
@@ -26,7 +26,7 @@ const ptyTabId = z.object({ tabId: z.string().min(1).max(128) });
26
26
  const sessionSubscribe = z.object({
27
27
  // tabId becomes a transcript FILENAME (`<tabId>.jsonl`) — restrict to a
28
28
  // session-id charset (no '/', no '.', so it can't traverse out of the project
29
- // transcript dir). claudeSessionId is a UUID, which satisfies this.
29
+ // transcript dir). sessionId is a UUID, which satisfies this.
30
30
  tabId: z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/),
31
31
  cwd: z.string().min(1).max(4096),
32
32
  });
@@ -196,8 +196,7 @@ const configWatch = z.array(z.string().min(1).max(4096));
196
196
  const sessionsPayload = z.object({
197
197
  tabs: z.array(z.object({
198
198
  id: z.string().min(1).max(128),
199
- claudeSessionId: z.string().min(1).max(128),
200
- chatSessionId: z.string().min(1).max(128).optional(),
199
+ sessionId: z.string().min(1).max(128),
201
200
  cwd: z.string().min(1).max(4096),
202
201
  label: z.string().max(256),
203
202
  presetId: z.string().max(128).nullable(),
@@ -208,15 +208,38 @@ function gitHead(cwd) {
208
208
  });
209
209
  }
210
210
 
211
+ // Best-effort `git fetch --all --prune` in cwd, bounded and never throwing.
212
+ // A PRD that does its work in a separate `git worktree add` checkout (per
213
+ // standards.md's own recommended pattern for shared repos) commits and pushes
214
+ // from THAT worktree, then removes it — the commit never touches job.cwd's
215
+ // own refs, so job.cwd's remote-tracking branches can be stale relative to
216
+ // what was actually pushed. Refreshing them here is what lets the git log
217
+ // --all scan below see a worktree-pushed commit. Resolves once fetch settles
218
+ // (or times out / errors) — callers don't need the result, just the refresh.
219
+ function fetchAllRefs(cwd) {
220
+ return new Promise((resolve) => {
221
+ if (!cwd) { resolve(); return; }
222
+ execFile(
223
+ 'git',
224
+ ['-C', cwd, 'fetch', '--all', '--prune'],
225
+ { timeout: 20_000, windowsHide: true },
226
+ () => resolve(),
227
+ );
228
+ });
229
+ }
230
+
211
231
  // Returns true if ≥1 commit landed on any ref (branch, remote-tracking branch,
212
232
  // or tag) in cwd between startedAt and finishedAt (with 60s slack) — not just
213
233
  // the currently checked-out branch. Used both by the self-heal pass and by the
214
234
  // live commit-guard's fallback (see computeCommittedDuringRun) to derive
215
- // committedDuringRun from the recorded run window. Never throws;
216
- // git-unavailable false (no override, job stays as-is).
217
- function committedInWindow(cwd, startedAt, finishedAt) {
235
+ // committedDuringRun from the recorded run window. Fetches remotes first (see
236
+ // fetchAllRefs) so a commit pushed from a separate worktree checkout that was
237
+ // since removed is still visible via job.cwd's remote-tracking refs. Never
238
+ // throws; git-unavailable → false (no override, job stays as-is).
239
+ async function committedInWindow(cwd, startedAt, finishedAt) {
240
+ if (!cwd || !startedAt) return false;
241
+ await fetchAllRefs(cwd);
218
242
  return new Promise((resolve) => {
219
- if (!cwd || !startedAt) { resolve(false); return; }
220
243
  const until = finishedAt
221
244
  ? new Date(Date.parse(finishedAt) + 60_000).toISOString()
222
245
  : new Date().toISOString();
@@ -5,9 +5,9 @@
5
5
  * Storage: ~/.config/session-manager/tabs.json
6
6
  * Shape: { tabs: PersistedTab[], activeTabId: string | null, savedAt: number }
7
7
  *
8
- * Only serializable, durable fields are persisted: id, claudeSessionId,
9
- * chatSessionId, cwd, label, presetId. Runtime-only fields (pid, status,
10
- * startupCommand, exitCode) are recomputed on boot.
8
+ * Only serializable, durable fields are persisted: id, sessionId, cwd,
9
+ * label, presetId. Runtime-only fields (pid, status, startupCommand,
10
+ * exitCode) are recomputed on boot.
11
11
  */
12
12
 
13
13
  const fs = require('node:fs');
@@ -58,8 +58,7 @@ async function markFreshRestart() {
58
58
  const crypto = require('node:crypto');
59
59
  const freshTabs = tabs.map((t) => ({
60
60
  ...t,
61
- claudeSessionId: crypto.randomUUID(),
62
- chatSessionId: crypto.randomUUID(),
61
+ sessionId: crypto.randomUUID(),
63
62
  }));
64
63
  await save({ tabs: freshTabs, activeTabId, freshStart: true });
65
64
  }
@@ -729,13 +729,13 @@ async function pushSessionList() {
729
729
  if (_e2e.state !== 'authenticated') return;
730
730
  const sessionsStore = require('./sessionsStore.cjs');
731
731
  const data = await sessionsStore.load();
732
- // Normalize persisted tabs → SessionMeta. tabId === claudeSessionId so it
732
+ // Normalize persisted tabs → SessionMeta. tabId === sessionId so it
733
733
  // matches the transcript JSONL name used by cmd:session:subscribe.
734
734
  const sessions = (data?.tabs ?? []).map((t) => ({
735
- tabId: t.claudeSessionId,
735
+ tabId: t.sessionId,
736
736
  cwd: t.cwd,
737
737
  title: t.label || t.cwd,
738
- state: _sessionWatchers.get(t.claudeSessionId)?.state ?? null,
738
+ state: _sessionWatchers.get(t.sessionId)?.state ?? null,
739
739
  }));
740
740
  const payload = { sessions, activeTabId: data?.activeTabId ?? null };
741
741
  const json = JSON.stringify(payload);
@@ -163,9 +163,7 @@ export interface SubscribeResult {
163
163
 
164
164
  export interface PersistedTab {
165
165
  id: string;
166
- claudeSessionId: string;
167
- /** Chat mode's own session id, decoupled from claudeSessionId. Optional for backwards-compat with tabs.json written before this field existed. */
168
- chatSessionId?: string;
166
+ sessionId: string;
169
167
  cwd: string;
170
168
  label: string;
171
169
  presetId: string | null;
@@ -1463,8 +1461,9 @@ export interface SessionManagerAPI {
1463
1461
  chat: {
1464
1462
  /** Spawn a headless `claude -p` run for a tab. Results arrive via the on* listeners. */
1465
1463
  run: (payload: ChatRunPayload) => Promise<{ ok: boolean }>;
1466
- /** Cancel the in-flight run for a tab (SIGTERM→SIGKILL). No-op when idle. */
1467
- cancel: (tabId: string) => void;
1464
+ /** Cancel the in-flight run for a tab (SIGTERM→SIGKILL). Resolves once the
1465
+ * run has fully settled (terminal event fired). No-op when idle. */
1466
+ cancel: (tabId: string) => Promise<void>;
1468
1467
  onQueued: (handler: (e: ChatRunQueuedEvent) => void) => () => void;
1469
1468
  onRunStarted: (handler: (e: ChatRunStartedEvent) => void) => () => void;
1470
1469
  onOutput: (handler: (e: ChatRunOutputEvent) => void) => () => void;
@@ -393,8 +393,9 @@ contextBridge.exposeInMainWorld('api', {
393
393
  chat: {
394
394
  /** Spawn a headless claude -p job. Results arrive via the on* listeners. */
395
395
  run: (payload) => ipcRenderer.invoke('chat:run', payload),
396
- /** Cancel an in-flight run for the given tabId. No-op if none running. */
397
- cancel: (tabId) => ipcRenderer.send('chat:cancel', { tabId }),
396
+ /** Cancel an in-flight run for the given tabId. Resolves once the run has
397
+ * fully settled (terminal event fired). No-op if none running. */
398
+ cancel: (tabId) => ipcRenderer.invoke('chat:cancel', { tabId }),
398
399
  onQueued: (handler) => {
399
400
  const listener = (_e, payload) => handler(payload);
400
401
  ipcRenderer.on('chat:run:queued', listener);