claude-code-session-manager 0.91.1 → 0.92.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.
Files changed (47) hide show
  1. package/dist/assets/{AgentLibrary-Chmfxih8.js → AgentLibrary-Ci6u03bq.js} +1 -1
  2. package/dist/assets/{DataModel-Cp38ePS_.js → DataModel-BIqcjsv2.js} +1 -1
  3. package/dist/assets/{History-SMWV7lD6.js → History-BOR_fJNr.js} +1 -1
  4. package/dist/assets/{Hooks-JUWiyu-r.js → Hooks-BtD436iL.js} +1 -1
  5. package/dist/assets/{HostBilko-DFTixTqy.js → HostBilko-CMe5cH3H.js} +1 -1
  6. package/dist/assets/{Library-zUu71gsR.js → Library-DeKdY-tD.js} +1 -1
  7. package/dist/assets/{ListDetail-BK_PH4lf.js → ListDetail-CtVjrHKB.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-ITJDD0Mr.js → MarkdownEditor-CbzC-Oqf.js} +1 -1
  9. package/dist/assets/{McpServers-CWwrfGkx.js → McpServers-DKN75Shn.js} +1 -1
  10. package/dist/assets/{Memory-DgHttor9.js → Memory-B68AJjg2.js} +1 -1
  11. package/dist/assets/{Panel-C2Cb7TS4.js → Panel-C2YTW-qP.js} +1 -1
  12. package/dist/assets/{Permissions-hd8OBsj_.js → Permissions-Cr0RRvNy.js} +1 -1
  13. package/dist/assets/{Plugins-BYa-ClGK.js → Plugins-DbZkJs5l.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-Dmhz8ZOB.js → ProvenanceBadge-kJ__BOQv.js} +1 -1
  15. package/dist/assets/{SaveBar-1NvfbO9r.js → SaveBar-87ZJfscX.js} +1 -1
  16. package/dist/assets/{Scheduler-SYgINPCB.js → Scheduler-DdEaxku8.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-MBmWNzFk.js → ScopeSwitcher-CKzpqjJ_.js} +1 -1
  18. package/dist/assets/{Settings-C1WOHoYb.js → Settings-CjywVsNB.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-Cuse__ul.js → SkillReferenceGraph-D_-wg4r9.js} +1 -1
  20. package/dist/assets/{Skills-CQmLYgNh.js → Skills-Cd_yPO_l.js} +1 -1
  21. package/dist/assets/{SystemPrompt-DkhAYRXL.js → SystemPrompt-2z6eGPSF.js} +1 -1
  22. package/dist/assets/{TagLibrary-DGxI_3m3.js → TagLibrary-DcaWQSp4.js} +1 -1
  23. package/dist/assets/{TiptapBody-BY-fFjwh.js → TiptapBody-DYXIp8aG.js} +1 -1
  24. package/dist/assets/{Toggle--nwLR-_8.js → Toggle-CDXR5l3m.js} +1 -1
  25. package/dist/assets/{index-CRPJslSA.css → index-D3P_jldk.css} +1 -1
  26. package/dist/assets/{index-4dEn1t8J.js → index-D5H_H5wC.js} +329 -329
  27. package/dist/assets/{settingsSchema-DiqEpSBA.js → settingsSchema-JRDfjgHb.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -1
  30. package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +52 -3
  31. package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +105 -0
  32. package/src/main/build-info.json +4 -4
  33. package/src/main/chatRunner.cjs +27 -4
  34. package/src/main/config.cjs +5 -0
  35. package/src/main/docEdit.cjs +3 -2
  36. package/src/main/index.cjs +2 -0
  37. package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +58 -2
  38. package/src/main/lib/__tests__/epicSpawnPlan.test.cjs +79 -0
  39. package/src/main/lib/__tests__/updateCheck.test.cjs +63 -0
  40. package/src/main/lib/epicSpawnCwd.cjs +22 -13
  41. package/src/main/lib/epicSpawnPlan.cjs +27 -12
  42. package/src/main/lib/epicTranscriptPath.cjs +3 -2
  43. package/src/main/lib/epicWorktreeMerge.cjs +4 -4
  44. package/src/main/lib/updateCheck.cjs +102 -0
  45. package/src/main/pty.cjs +32 -6
  46. package/src/preload/api.d.ts +2 -0
  47. package/src/preload/index.cjs +1 -0
@@ -8,11 +8,13 @@
8
8
  * conversation found"), the flag-swap retry then hit `--session-id` ("already in use"), and both
9
9
  * doors were locked.
10
10
  *
11
- * Chosen fix: (a) first — resolveEpicSpawnCwd({restore:true}) re-attaches the Epic's own branch
12
- * at the SAME recorded path (encoding unchanged), so the CLI sees the transcript again. When that
13
- * is impossible, (b): refuse BEFORE spawning with an actionable message naming the missing
14
- * worktree path, never claim a flag that cannot work. Reachability = "some existing transcript
15
- * lives under encodeCwd(spawn cwd)" and the spawn cwd is a real directory.
11
+ * Three-way decision: (1) re-attach — resolveEpicSpawnCwd({restore:true}) re-attaches the Epic's
12
+ * own branch at the SAME recorded path (encoding unchanged), so the CLI sees the transcript again;
13
+ * (2) project-cwd fallback — when that spawn cwd cannot see the transcript but the project cwd can
14
+ * (transcript filed under encodeCwd(project cwd)), spawn from the project cwd; nothing forks since
15
+ * it writes to the directory it already occupies; (3) refuse BEFORE spawning with a message stating
16
+ * the real cause, never claim a flag that cannot work. Reachable = "some existing transcript lives
17
+ * under encodeCwd(candidate cwd)" and the candidate is a real directory.
16
18
  *
17
19
  * Also refuses requests routed to a closed (`completed`) Epic, and keeps a per-session circuit
18
20
  * breaker: once a session is found unreachable, later requests are refused after ONE stat (is the
@@ -95,19 +97,32 @@ function planEpicSpawn({ cwd, claudeSessionId, fallbackResume = false, deps = {}
95
97
  useResume = !!resolved.existsAnywhere;
96
98
  } catch { /* keep the caller-supplied flag */ }
97
99
 
98
- const execCwd = (deps.resolveSpawnCwd || resolveEpicSpawnCwd)({ cwd, claudeSessionId, deps: { restore: true, readActiveIndex: deps.readActiveIndex, statSync: deps.statSync, restoreWorktree: deps.restoreWorktree } });
100
+ let execCwd = (deps.resolveSpawnCwd || resolveEpicSpawnCwd)({ cwd, claudeSessionId, deps: { restore: true, readActiveIndex: deps.readActiveIndex, statSync: deps.statSync, restoreWorktree: deps.restoreWorktree } });
99
101
 
100
102
  if (resolved && resolved.existsAnywhere) {
101
103
  const projectsDir = path.join(deps.homeDir || os.homedir(), '.claude', 'projects');
102
- const home = path.join(projectsDir, encodeCwd(execCwd));
103
- const reachable = isDir(execCwd, statSync) && resolved.existingPaths.some((p) => path.dirname(p) === home);
104
- if (!reachable) {
104
+ // O(candidates × existingPaths); both are tiny. Candidate order: chosen execCwd, then project cwd.
105
+ const findReachable = (dir) => {
106
+ if (!isDir(dir, statSync)) return null;
107
+ const home = path.join(projectsDir, encodeCwd(dir));
108
+ return resolved.existingPaths.find((p) => path.dirname(p) === home) || null;
109
+ };
110
+ let hit = findReachable(execCwd);
111
+ if (!hit && execCwd !== cwd) {
112
+ // The transcript may already live under the project encoding; spawning there writes back to
113
+ // the directory it occupies, so nothing forks.
114
+ hit = findReachable(cwd);
115
+ if (hit) execCwd = cwd;
116
+ }
117
+ if (!hit) {
105
118
  const missing = brokenDir || execCwd;
119
+ const wt = epic?.worktree;
120
+ const recovery = wt?.status === 'merged'
121
+ ? `That checkout was removed when the Epic was merged to main and its ${wt.branch || 'sm-epic/*'} branch was deleted, so it cannot be recreated. Start a new Epic.`
122
+ : `The checkout is gone and could not be re-attached (it may have been swept from its temp/state directory). Recreate it (git worktree add "${missing}" ${wt?.branch || '<sm-epic branch>'}) or start a new Epic.`;
106
123
  const message =
107
124
  `Session ${claudeSessionId} cannot be resumed: its transcript is at ${resolved.existingPaths[0]}, ` +
108
- `which the CLI only finds when run from ${missing}, and that worktree no longer exists and could not be ` +
109
- `re-attached (likely swept from /tmp). Recreate it (git worktree add "${missing}" ${epic?.worktree?.branch || '<sm-epic branch>'}) ` +
110
- `or start a new Epic.`;
125
+ `which the CLI only finds when run from ${missing}. ${recovery}`;
111
126
  unreachable.set(claudeSessionId, { dir: missing, message });
112
127
  return { ok: false, code: 'session_unreachable', message };
113
128
  }
@@ -5,8 +5,9 @@
5
5
  * transcript actually live?".
6
6
  *
7
7
  * The CLI writes `~/.claude/projects/<encodeCwd(spawn cwd)>/<id>.jsonl`, and an
8
- * Epic's spawn cwd is its isolated worktree dir (chatRunner.cjs via
9
- * resolveEpicSpawnCwd), NOT the project cwd. Turn 1 can still land under the
8
+ * Epic's spawn cwd is normally its isolated worktree dir (chatRunner.cjs and
9
+ * pty.cjs via epicSpawnPlan.cjs), falling back to the project cwd when the
10
+ * worktree is merged/gone. Turn 1 can still land under the
10
11
  * project encoding (the worktree mint is fire-and-forget), and the worktree dir
11
12
  * itself is routinely swept from /tmp while its transcript survives under
12
13
  * ~/.claude/projects. So candidates are built from the recorded `worktree.dir`
@@ -17,10 +17,10 @@
17
17
  * On success the worktree checkout is torn down (cleanupEpicWorktree) since
18
18
  * its content now safely lives on the main tree. On a real conflict, the
19
19
  * branch and worktree directory are left intact — never silently discarded
20
- * — so a human can still open a Terminal into `worktree.dir` (PRD 1033's
21
- * existing epicSpawnCwd.cjs wiring — it keys off `worktree.dir` alone, not
22
- * `worktree.status`, so a conflicted Epic's Terminal spawn keeps landing
23
- * there with no new code path needed) and resolve it manually before
20
+ * — so a human can still open a Terminal into `worktree.dir` (the spawn
21
+ * resolution in epicSpawnCwd.cjs is status-aware: `needs_merge_resolution`
22
+ * with an existing dir still lands there, while `merged`/`disabled` skip the
23
+ * re-attach and fall back to the project cwd) and resolve it manually before
24
24
  * re-running this same action.
25
25
  */
26
26
 
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * updateCheck.cjs — bounded, offline-safe, opt-out "is a newer build published?" probe.
5
+ *
6
+ * getUpdateStatus() → { current, latest, behind }. Never throws/rejects; any failure
7
+ * (offline, DNS, non-200, bad JSON, timeout) → { current, latest: null, behind: false }.
8
+ * The request is a bare GET (no headers/cookies/query/body) so it carries nothing that
9
+ * identifies the user or machine. Disabled by SM_UPDATE_CHECK=0 or E2E mode (SM_E2E=1).
10
+ */
11
+
12
+ const https = require('node:https');
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+
16
+ const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
17
+ const REQUEST_TIMEOUT_MS = 3000;
18
+ const CACHE_FILE = 'update-check.json';
19
+
20
+ /** Plain GET → parsed JSON. Rejects on non-200, bad JSON, or timeout. */
21
+ function httpGetJson(url, timeoutMs) {
22
+ return new Promise((resolve, reject) => {
23
+ const req = https.get(url, { timeout: timeoutMs }, (res) => {
24
+ if (res.statusCode !== 200) {
25
+ res.resume();
26
+ reject(new Error(`HTTP ${res.statusCode}`));
27
+ return;
28
+ }
29
+ let body = '';
30
+ res.setEncoding('utf8');
31
+ res.on('data', (c) => {
32
+ body += c;
33
+ if (body.length > 65536) req.destroy(new Error('response too large'));
34
+ });
35
+ res.on('end', () => {
36
+ try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
37
+ });
38
+ res.on('error', reject);
39
+ });
40
+ req.on('timeout', () => req.destroy(new Error('timeout')));
41
+ req.on('error', reject);
42
+ });
43
+ }
44
+
45
+ function parseTriple(v) {
46
+ const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v || ''));
47
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
48
+ }
49
+
50
+ /** True iff `current` is strictly older than `latest` (numeric major.minor.patch). */
51
+ function isBehind(current, latest) {
52
+ const a = parseTriple(current);
53
+ const b = parseTriple(latest);
54
+ if (!a || !b) return false;
55
+ for (let i = 0; i < 3; i++) {
56
+ if (a[i] !== b[i]) return a[i] < b[i];
57
+ }
58
+ return false;
59
+ }
60
+
61
+ function readCache(file, now) {
62
+ try {
63
+ const c = JSON.parse(fs.readFileSync(file, 'utf8'));
64
+ if (c && typeof c.latest === 'string' && typeof c.checkedAt === 'number'
65
+ && now - c.checkedAt >= 0 && now - c.checkedAt < CACHE_TTL_MS) return c.latest;
66
+ } catch { /* missing/corrupt cache = miss */ }
67
+ return null;
68
+ }
69
+
70
+ function defaultPackageName() {
71
+ return require(path.join(__dirname, '..', '..', '..', 'package.json')).name;
72
+ }
73
+
74
+ async function getUpdateStatus({ deps } = {}) {
75
+ const d = deps || {};
76
+ let current = '';
77
+ try {
78
+ const app = d.app || require('electron').app;
79
+ current = app.getVersion();
80
+ const env = d.env || process.env;
81
+ if (env.SM_UPDATE_CHECK === '0' || env.SM_E2E === '1') return { current, latest: null, behind: false };
82
+
83
+ const now = (d.now || Date.now)();
84
+ const file = path.join(app.getPath('userData'), CACHE_FILE);
85
+ let latest = readCache(file, now);
86
+ if (latest == null) {
87
+ const name = (d.packageName || defaultPackageName)();
88
+ const url = `https://registry.npmjs.org/-/package/${name}/dist-tags`;
89
+ const tags = await (d.fetchJson || httpGetJson)(url, REQUEST_TIMEOUT_MS);
90
+ latest = tags && typeof tags.latest === 'string' ? tags.latest : null;
91
+ if (latest == null) return { current, latest: null, behind: false };
92
+ try {
93
+ await (d.writeJson || require('../config.cjs').writeJson)(file, { checkedAt: now, latest });
94
+ } catch { /* cache is best-effort */ }
95
+ }
96
+ return { current, latest, behind: isBehind(current, latest) };
97
+ } catch {
98
+ return { current, latest: null, behind: false };
99
+ }
100
+ }
101
+
102
+ module.exports = { getUpdateStatus, isBehind };
package/src/main/pty.cjs CHANGED
@@ -22,6 +22,7 @@ const { cleanChildEnv, pathWithUserBins } = require('./lib/cleanEnv.cjs');
22
22
  const { checkInsideHome } = require('./lib/insideHome.cjs');
23
23
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
24
24
  const opsErrorLog = require('./lib/opsErrorLog.cjs');
25
+ const { planEpicSpawn } = require('./lib/epicSpawnPlan.cjs');
25
26
  const { resolveEpicSpawnCwd } = require('./lib/epicSpawnCwd.cjs');
26
27
  const telemetryCounters = require('./lib/telemetryCounters.cjs');
27
28
 
@@ -176,12 +177,37 @@ class PtyManager {
176
177
 
177
178
  const shell = process.env.SHELL || '/bin/bash';
178
179
  console.log('[pty] spawning shell', shell);
179
- // Tab ID = claudeSessionId (see CLAUDE.md), so for an Epic-attached tab
180
- // this resolves the Epic's own record in `cwd`'s active-index.json and
181
- // returns its isolated worktree dir when one exists — `cwd` itself is
182
- // NEVER repointed; only this actual PTY spawn option is. See
183
- // epicSpawnCwd.cjs's header comment (the ops-root hazard) for why.
184
- const spawnCwd = resolveEpicSpawnCwd({ cwd, claudeSessionId: tabId, deps: { restore: true } });
180
+ // Tab ID = claudeSessionId (see CLAUDE.md), and Chat/Terminal are two views over ONE session,
181
+ // so this asks the same decision function chatRunner.cjs does (epicSpawnPlan.cjs). `cwd`
182
+ // itself is NEVER repointed; only this actual PTY spawn option is (epicSpawnCwd.cjs's
183
+ // ops-root hazard). A refused plan is surfaced in-tab rather than spawning into a bad cwd.
184
+ // Split: Chat and Terminal share this one verdict function, but the Terminal additionally
185
+ // downgrades `epic_closed` to a plain shell — it opens `$SHELL -il` (no --resume, no
186
+ // transcript write), not a chat turn. That path uses the read-side resolver WITHOUT
187
+ // `deps.restore`, so opening a closed Epic's terminal never re-attaches a checkout.
188
+ const plan = planEpicSpawn({ cwd, claudeSessionId: tabId });
189
+ let spawnCwd;
190
+ if (plan.ok) {
191
+ spawnCwd = plan.execCwd;
192
+ } else if (plan.code === 'epic_closed') {
193
+ spawnCwd = resolveEpicSpawnCwd({ cwd, claudeSessionId: tabId });
194
+ sendIfAlive(
195
+ this.window,
196
+ `pty:data:${tabId}`,
197
+ `\r\nThis Epic is completed \u2014 opening a read-only shell in ${spawnCwd}. Start a new Epic for new work.\r\n`,
198
+ );
199
+ } else {
200
+ opsErrorLog.appendError({
201
+ cwd,
202
+ scope: 'pty',
203
+ tabId,
204
+ tags: ['spawn-refused', plan.code],
205
+ message: plan.message,
206
+ });
207
+ sendIfAlive(this.window, `pty:data:${tabId}`, `\r\n${plan.message}\r\n`);
208
+ setImmediate(() => sendIfAlive(this.window, `pty:exit:${tabId}`, { exitCode: 1, signal: undefined }));
209
+ return { pid: null, cwd, reattached: false, error: plan.message };
210
+ }
185
211
  if (spawnCwd !== cwd) {
186
212
  console.log(`[pty] tabId=${tabId} isolated in worktree ${spawnCwd}`);
187
213
  }
@@ -1541,6 +1541,8 @@ export interface ChatExternalSendEvent {
1541
1541
  export interface SessionManagerAPI {
1542
1542
  app: {
1543
1543
  version: () => Promise<string>;
1544
+ /** Published-`latest` probe (cached 6h, opt-out SM_UPDATE_CHECK=0). Never rejects; offline → latest null. */
1545
+ updateStatus: () => Promise<{ current: string; latest: string | null; behind: boolean }>;
1544
1546
  homeDir: () => Promise<string>;
1545
1547
  cwd: () => Promise<string>;
1546
1548
  engageRulesPath: () => Promise<string | null>;
@@ -3,6 +3,7 @@ const { contextBridge, ipcRenderer } = require('electron');
3
3
  contextBridge.exposeInMainWorld('api', {
4
4
  app: {
5
5
  version: () => ipcRenderer.invoke('app:version'),
6
+ updateStatus: () => ipcRenderer.invoke('app:update-status'),
6
7
  homeDir: () => ipcRenderer.invoke('app:home-dir'),
7
8
  cwd: () => ipcRenderer.invoke('app:cwd'),
8
9
  engageRulesPath: () => ipcRenderer.invoke('app:engage-rules-path'),