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.
- package/dist/assets/{TiptapBody-BtrSXTRp.js → TiptapBody-GMk5LS9Y.js} +1 -1
- package/dist/assets/{index-uVGdpAGF.js → index-CQqSHpIq.js} +250 -250
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/0-fetch-pool/SKILL.md +38 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/1-reject-fixed/SKILL.md +64 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/2-reject-claimed/SKILL.md +71 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/3-rank-impact/SKILL.md +39 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/SKILL.md +75 -0
- package/plugins/session-manager-dev/skills/issue-address/1-confirm-open/SKILL.md +39 -0
- package/plugins/session-manager-dev/skills/issue-address/2-claim/SKILL.md +57 -0
- package/plugins/session-manager-dev/skills/issue-address/3-reproduce/SKILL.md +40 -0
- package/plugins/session-manager-dev/skills/issue-address/4-fix/SKILL.md +33 -0
- package/plugins/session-manager-dev/skills/issue-address/5-verify/SKILL.md +40 -0
- package/plugins/session-manager-dev/skills/issue-address/SKILL.md +120 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/0-fetch/SKILL.md +41 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/1-classify/SKILL.md +45 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/2-check-fixed/SKILL.md +49 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/3-queue/SKILL.md +50 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/4-land-and-resolve/SKILL.md +60 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/SKILL.md +124 -0
- package/plugins/session-manager-dev/skills/pr-signal/SKILL.md +67 -0
- package/src/main/__tests__/chat-cancel-terminal.test.cjs +31 -0
- package/src/main/__tests__/chat-exit-close-race.test.cjs +140 -0
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +64 -0
- package/src/main/chatRunner.cjs +37 -12
- package/src/main/ipcSchemas.cjs +2 -3
- package/src/main/scheduler.cjs +27 -4
- package/src/main/sessionsStore.cjs +4 -5
- package/src/main/webRemote.cjs +3 -3
- package/src/preload/api.d.ts +4 -5
- 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
|
+
});
|
package/src/main/chatRunner.cjs
CHANGED
|
@@ -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 →
|
|
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)
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
650
|
-
if (
|
|
651
|
-
|
|
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.
|
|
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 }) => {
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -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).
|
|
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
|
-
|
|
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(),
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -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.
|
|
216
|
-
//
|
|
217
|
-
|
|
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,
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
62
|
-
chatSessionId: crypto.randomUUID(),
|
|
61
|
+
sessionId: crypto.randomUUID(),
|
|
63
62
|
}));
|
|
64
63
|
await save({ tabs: freshTabs, activeTabId, freshStart: true });
|
|
65
64
|
}
|
package/src/main/webRemote.cjs
CHANGED
|
@@ -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 ===
|
|
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.
|
|
735
|
+
tabId: t.sessionId,
|
|
736
736
|
cwd: t.cwd,
|
|
737
737
|
title: t.label || t.cwd,
|
|
738
|
-
state: _sessionWatchers.get(t.
|
|
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);
|
package/src/preload/api.d.ts
CHANGED
|
@@ -163,9 +163,7 @@ export interface SubscribeResult {
|
|
|
163
163
|
|
|
164
164
|
export interface PersistedTab {
|
|
165
165
|
id: string;
|
|
166
|
-
|
|
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).
|
|
1467
|
-
|
|
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;
|
package/src/preload/index.cjs
CHANGED
|
@@ -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.
|
|
397
|
-
|
|
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);
|