claude-code-session-manager 0.104.2 → 0.106.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 (87) hide show
  1. package/README.md +15 -3
  2. package/bin/cli.cjs +0 -5
  3. package/dist/assets/{AgentLibrary-QPw9gC_o.js → AgentLibrary-CKOZzc2W.js} +1 -1
  4. package/dist/assets/{Button-8RAbgh1d.js → Button-yuqFpQsK.js} +1 -1
  5. package/dist/assets/{DataModel-BEkuOZuS.js → DataModel-Bwdc_gaJ.js} +1 -1
  6. package/dist/assets/{History-BLrNSazM.js → History-D1Z0lydo.js} +1 -1
  7. package/dist/assets/{Hooks-C8P6wQq1.js → Hooks-CyD6l4Is.js} +1 -1
  8. package/dist/assets/{Library-CWFvBZw-.js → Library-fnod5cPe.js} +1 -1
  9. package/dist/assets/{ListDetail-B1jVMgzN.js → ListDetail-BKO68SJ4.js} +1 -1
  10. package/dist/assets/{MarkdownEditor-JrnUbWjW.js → MarkdownEditor-DrYMnMBy.js} +1 -1
  11. package/dist/assets/{McpServers-BGqTxDBm.js → McpServers-CgfItWhF.js} +1 -1
  12. package/dist/assets/{Memory-h8bhRNyI.js → Memory-4M3gO3_r.js} +1 -1
  13. package/dist/assets/{Panel-srf0FT0m.js → Panel-CKbXo-LT.js} +1 -1
  14. package/dist/assets/{Permissions-BA4oWydI.js → Permissions-Bc9EpjXL.js} +1 -1
  15. package/dist/assets/{Plugins-DTuTQqUL.js → Plugins-B_-PlEIx.js} +2 -2
  16. package/dist/assets/{ProvenanceBadge-BW18EquH.js → ProvenanceBadge-Cxev7XSA.js} +1 -1
  17. package/dist/assets/{SaveBar-Cc6UukFa.js → SaveBar-DiqscG6q.js} +1 -1
  18. package/dist/assets/{Scheduler-BctPlNL1.js → Scheduler-CCcJzuu8.js} +1 -1
  19. package/dist/assets/{ScopeSwitcher-DV1OnSuc.js → ScopeSwitcher-BsM4-jHQ.js} +1 -1
  20. package/dist/assets/{Settings-Bpyt1RGa.js → Settings-C3-VL-T8.js} +1 -1
  21. package/dist/assets/{SkillReferenceGraph-B28AYo12.js → SkillReferenceGraph-B9YlOuPo.js} +1 -1
  22. package/dist/assets/{Skills-zXLI5OB_.js → Skills-Dtf-z_zN.js} +1 -1
  23. package/dist/assets/{SystemPrompt-DKoN0UAV.js → SystemPrompt-HJDn0E8C.js} +1 -1
  24. package/dist/assets/{TagLibrary-BkDOa8IM.js → TagLibrary-DLGOfauU.js} +1 -1
  25. package/dist/assets/{TiptapBody-CIY1f6mI.js → TiptapBody-Cdmv1CuM.js} +1 -1
  26. package/dist/assets/{Toggle-C139wuAK.js → Toggle-CQFhOo1F.js} +1 -1
  27. package/dist/assets/{ViewTabs-CUEfsMOe.js → ViewTabs-CxjDCHjH.js} +1 -1
  28. package/dist/assets/index-Blo38vfX.js +3077 -0
  29. package/dist/assets/{index-D89EUizB.css → index-DkJv4l6L.css} +1 -1
  30. package/dist/assets/{settingsSchema-CAFckLz6.js → settingsSchema-B0uoL_1R.js} +1 -1
  31. package/dist/assets/useAgentsDirWatch-e3TJUFPP.js +1 -0
  32. package/dist/assets/{useConfigDirWatch-QKxfZx9X.js → useConfigDirWatch-Dxv7JbCU.js} +1 -1
  33. package/dist/index.html +2 -2
  34. package/package.json +10 -12
  35. package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +12 -0
  36. package/plugins/session-manager-dev/skills/develop/SKILL.md +4 -2
  37. package/scripts/scheduler-mcp-server.cjs +51 -17
  38. package/src/main/build-info.json +4 -4
  39. package/src/main/chatRunner.cjs +8 -6
  40. package/src/main/crashDiagnostics.cjs +39 -3
  41. package/src/main/historyAggregator.cjs +7 -1
  42. package/src/main/index.cjs +73 -4
  43. package/src/main/ipcSchemas.cjs +27 -0
  44. package/src/main/lib/agentPersonaSchema.cjs +8 -1
  45. package/src/main/lib/appRuntime.cjs +57 -0
  46. package/src/main/lib/childWithLog.cjs +10 -10
  47. package/src/main/lib/claudeBin.cjs +58 -20
  48. package/src/main/lib/cleanEnv.cjs +33 -9
  49. package/src/main/lib/customerFeedbackAdminRoutes.cjs +94 -0
  50. package/src/main/lib/customerFeedbackClient.cjs +261 -0
  51. package/src/main/lib/customerFeedbackInbox.cjs +286 -0
  52. package/src/main/lib/customerFeedbackIpc.cjs +43 -0
  53. package/src/main/lib/defaultShell.cjs +36 -0
  54. package/src/main/lib/definitionOfDone.cjs +6 -2
  55. package/src/main/lib/delegationReadiness.cjs +13 -5
  56. package/src/main/lib/gitWorktree.cjs +6 -3
  57. package/src/main/lib/insideHome.cjs +15 -4
  58. package/src/main/lib/killTree.cjs +80 -0
  59. package/src/main/lib/loadGate.cjs +16 -0
  60. package/src/main/lib/loginShellPath.cjs +82 -0
  61. package/src/main/lib/machineProfile.cjs +4 -2
  62. package/src/main/lib/mcpStdioServer.cjs +127 -0
  63. package/src/main/lib/mcpToolCatalog.cjs +26 -1
  64. package/src/main/lib/openExternalApp.cjs +62 -11
  65. package/src/main/lib/otlpTraceExporter.cjs +147 -0
  66. package/src/main/lib/prereqs.cjs +145 -0
  67. package/src/main/lib/procIdentity.cjs +37 -2
  68. package/src/main/lib/projectFolder.cjs +89 -0
  69. package/src/main/lib/rcaReport.cjs +1 -0
  70. package/src/main/lib/reaperHelpers.cjs +5 -2
  71. package/src/main/lib/shippedPersonaSeeds.cjs +10 -0
  72. package/src/main/lib/timeoutShimScript.cjs +14 -2
  73. package/src/main/lib/updateCheck.cjs +41 -11
  74. package/src/main/lib/watchdogHelpers.cjs +23 -3
  75. package/src/main/lib/winSpawn.cjs +67 -0
  76. package/src/main/lib/winUserData.cjs +22 -0
  77. package/src/main/otel.cjs +18 -62
  78. package/src/main/pty.cjs +32 -11
  79. package/src/main/runVerify.cjs +42 -2
  80. package/src/main/scheduler.cjs +116 -29
  81. package/src/main/seedSchedulerMcp.cjs +11 -2
  82. package/src/preload/api.d.ts +82 -0
  83. package/src/preload/index.cjs +14 -0
  84. package/src/seed/agents/validator.md +2 -2
  85. package/dist/assets/index-DrvxIbmR.js +0 -3073
  86. package/dist/assets/useAgentsDirWatch-XhQEVlaK.js +0 -1
  87. package/scripts/postinstall.cjs +0 -52
@@ -97,12 +97,47 @@ function identityFromPs(pid) {
97
97
  return { pid, startTicks, cmdline, complete: startTicks !== null && cmdline !== null };
98
98
  }
99
99
 
100
- /** identity(pid) → {pid, startTicks, cmdline, complete}. Never throws. */
101
- function identity(pid) {
100
+ /** PowerShell serialises DateTime as "\/Date(ms)\/" (5.1) or ISO-8601 (7+). */
101
+ function parseWinStartTime(v) {
102
+ if (typeof v === 'string') {
103
+ const m = /^\/Date\((-?\d+)\)\/$/.exec(v.trim());
104
+ if (m) return Number(m[1]);
105
+ const ms = Date.parse(v);
106
+ return Number.isFinite(ms) ? ms : null;
107
+ }
108
+ if (v && typeof v === 'object' && typeof v.value === 'string') return parseWinStartTime(v.value);
109
+ return null;
110
+ }
111
+
112
+ function identityFromPowerShell(pid, execFile) {
113
+ const unknown = { pid, startTicks: null, cmdline: null, complete: false };
114
+ let out;
115
+ try {
116
+ out = execFile(
117
+ 'powershell.exe',
118
+ ['-NoProfile', '-Command', `Get-Process -Id ${pid} | Select-Object StartTime,ProcessName | ConvertTo-Json`],
119
+ { encoding: 'utf8', windowsHide: true, timeout: 5000 },
120
+ );
121
+ const parsed = JSON.parse(String(out));
122
+ const startTicks = parseWinStartTime(parsed && parsed.StartTime);
123
+ const name = parsed && typeof parsed.ProcessName === 'string' ? parsed.ProcessName.trim() : '';
124
+ const cmdline = name || null;
125
+ return { pid, startTicks, cmdline, complete: startTicks !== null && cmdline !== null };
126
+ } catch {
127
+ return unknown; // missing process, timeout, or unparseable output
128
+ }
129
+ }
130
+
131
+ /**
132
+ * identity(pid, opts?) → {pid, startTicks, cmdline, complete}. Never throws.
133
+ * opts.platform / opts.execFile (sync, execFileSync-shaped) are injectable for tests.
134
+ */
135
+ function identity(pid, { platform = process.platform, execFile = execFileSync } = {}) {
102
136
  if (!Number.isInteger(pid) || pid <= 0) {
103
137
  return { pid, startTicks: null, cmdline: null, complete: false };
104
138
  }
105
139
  try {
140
+ if (platform === 'win32') return identityFromPowerShell(pid, execFile);
106
141
  return hasProc() ? identityFromProc(pid) : identityFromPs(pid);
107
142
  } catch {
108
143
  return { pid, startTicks: null, cmdline: null, complete: false };
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * projectFolder.cjs — create a new, empty project folder from a parent
5
+ * directory + a user-typed name. Electron's `createDirectory` picker property
6
+ * is macOS-only, so the app creates the folder itself.
7
+ *
8
+ * Never throws for validation / exists / io failures; returns a result object.
9
+ */
10
+
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+
14
+ const MAX_NAME_LENGTH = 100;
15
+ // NUL + C0 controls + DEL.
16
+ // eslint-disable-next-line no-control-regex
17
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/;
18
+
19
+ /**
20
+ * @param {unknown} name
21
+ * @returns {{ ok: true, name: string } | { ok: false, error: string }}
22
+ */
23
+ function validateProjectName(name) {
24
+ if (typeof name !== 'string') return { ok: false, error: 'Project name must be text.' };
25
+ const trimmed = name.trim();
26
+ if (!trimmed) return { ok: false, error: 'Project name cannot be empty.' };
27
+ if (trimmed.length > MAX_NAME_LENGTH) {
28
+ return { ok: false, error: `Project name must be ${MAX_NAME_LENGTH} characters or fewer.` };
29
+ }
30
+ if (trimmed === '.' || trimmed === '..') {
31
+ return { ok: false, error: 'Project name cannot be "." or "..".' };
32
+ }
33
+ if (trimmed.startsWith('-')) return { ok: false, error: 'Project name cannot start with "-".' };
34
+ if (trimmed.includes('/') || trimmed.includes('\\')) {
35
+ return { ok: false, error: 'Project name cannot contain "/" or "\\".' };
36
+ }
37
+ if (CONTROL_RE.test(trimmed)) {
38
+ return { ok: false, error: 'Project name cannot contain control characters.' };
39
+ }
40
+ return { ok: true, name: trimmed };
41
+ }
42
+
43
+ /**
44
+ * @param {{ parentDir: string, name: string }} input
45
+ * @returns {Promise<
46
+ * | { ok: true, path: string }
47
+ * | { ok: false, code: 'invalid-name' | 'invalid-parent' | 'io', error: string }
48
+ * | { ok: false, code: 'exists', path: string, error: string }
49
+ * >}
50
+ */
51
+ async function createProjectFolder({ parentDir, name } = /** @type {any} */ ({})) {
52
+ const v = validateProjectName(name);
53
+ if (!v.ok) return { ok: false, code: 'invalid-name', error: v.error };
54
+
55
+ if (typeof parentDir !== 'string' || !path.isAbsolute(parentDir)) {
56
+ return { ok: false, code: 'invalid-parent', error: 'Parent folder must be an absolute path.' };
57
+ }
58
+ try {
59
+ const st = await fs.promises.stat(parentDir);
60
+ if (!st.isDirectory()) {
61
+ return { ok: false, code: 'invalid-parent', error: `${parentDir} is not a folder.` };
62
+ }
63
+ } catch {
64
+ return { ok: false, code: 'invalid-parent', error: `Parent folder ${parentDir} does not exist.` };
65
+ }
66
+
67
+ const target = path.join(parentDir, v.name);
68
+ if (path.dirname(target) !== path.resolve(parentDir)) {
69
+ return { ok: false, code: 'invalid-name', error: 'Project name must be a single folder name.' };
70
+ }
71
+
72
+ try {
73
+ await fs.promises.mkdir(target); // non-recursive on purpose
74
+ return { ok: true, path: target };
75
+ } catch (err) {
76
+ const e = /** @type {NodeJS.ErrnoException} */ (err);
77
+ if (e && e.code === 'EEXIST') {
78
+ return {
79
+ ok: false,
80
+ code: 'exists',
81
+ path: target,
82
+ error: `A folder named "${v.name}" already exists in ${parentDir}`,
83
+ };
84
+ }
85
+ return { ok: false, code: 'io', error: (e && e.message) || String(err) };
86
+ }
87
+ }
88
+
89
+ module.exports = { createProjectFolder, validateProjectName };
@@ -50,6 +50,7 @@ const VERDICT_LABELS = {
50
50
  no_verdict_sentinel: 'no commit or verdict sentinel',
51
51
  abandoned_background_task: 'abandoned auto-backgrounded task',
52
52
  pass_no_commit: 'PASS sentinel but no commit landed',
53
+ sentinel_fail: 'executor reported SCHEDULER_VERDICT: FAIL',
53
54
  pass_no_commit_already_shipped: 'PASS with no commit — deliverables already shipped',
54
55
  pass_no_commit_prior_run_verified: 'PASS with no commit — prior run of this slug already landed the work',
55
56
  silent_no_op: 'no commit, clean tree — no evidence of work',
@@ -30,9 +30,11 @@ const { pidAlive } = require('./pidAlive.cjs');
30
30
  * Conservative by design: a false negative (live process treated as dead) is
31
31
  * far worse than a late reap.
32
32
  */
33
- function claudePidAlive(pid) {
33
+ function claudePidAlive(pid, { platform = process.platform } = {}) {
34
34
  if (!pid || typeof pid !== 'number' || pid <= 1) return false;
35
35
  if (!pidAlive(pid)) return false;
36
+ // Windows has no /proc — cannot verify identity, so never call it dead.
37
+ if (platform === 'win32') return true;
36
38
  try {
37
39
  const cmd = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
38
40
  return /\bclaude\b/.test(cmd);
@@ -73,7 +75,8 @@ function claudePidAlive(pid) {
73
75
  * — i.e. exactly today's behaviour (fail toward terminalizing), never a hang
74
76
  * or a thrown error propagating to the caller.
75
77
  */
76
- function findLiveProcessForJob(job, { worktreeDir, runCwd } = {}) {
78
+ function findLiveProcessForJob(job, { worktreeDir, runCwd, platform = process.platform } = {}) {
79
+ if (platform === 'win32') return null; // no /proc — skip every read
77
80
  const slug = job?.slug;
78
81
  let entries;
79
82
  try {
@@ -207,6 +207,16 @@ const SHIPPED_PERSONA_SEEDS = deepFreeze({
207
207
  tools: "Read, Grep, Glob, Bash",
208
208
  },
209
209
  },
210
+ {
211
+ commit: "<fill in>",
212
+ bodySha256: "ca159cd47ac4b618af842c6ccd59107fac4a34b0efcfa5dfe7d06875736156ed",
213
+ fm: {
214
+ description: "Validates a finished PLAN (the PRDs its validate PRD lists) once, after its last PRD lands — re-runs each PRD's gate, checks every acceptance criterion against the real tree, reviews the plan's combined diff, and reports one VERIFIED/REFUTED verdict per PRD via sentinel lines. Runs headless as a scheduled PRD (agentType: validator); never edits product code and never queues work.",
215
+ model: "sonnet",
216
+ title: "Engineering — Plan Validator",
217
+ tools: "Read, Grep, Glob, Bash",
218
+ },
219
+ },
210
220
  ],
211
221
  "demo-video-builder": [
212
222
  {
@@ -222,16 +222,28 @@ function main() {
222
222
  // spawned, without detaching further, dies too); fall back to the direct
223
223
  // pid only when the group signal itself fails (e.g. --foreground, where
224
224
  // the child shares OUR group rather than leading its own).
225
+ // Inlined (not require('./killTree.cjs')): this script is copied to ~ and
226
+ // runs standalone, so it cannot resolve app-relative modules. win32 has no
227
+ // process groups, so taskkill /T /F stands in for the group signal.
225
228
  function killGroup(sig) {
229
+ if (process.platform === 'win32') {
230
+ try {
231
+ require('node:child_process').spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
232
+ } catch {
233
+ try { process.kill(child.pid, sig); } catch { /* already gone */ }
234
+ }
235
+ return;
236
+ }
226
237
  try {
227
- process.kill(-child.pid, sig);
238
+ const groupPid = -child.pid; // negative pid = whole POSIX process group
239
+ process.kill(groupPid, sig);
228
240
  } catch {
229
241
  try { process.kill(child.pid, sig); } catch { /* already gone */ }
230
242
  }
231
243
  }
232
244
 
233
245
  try {
234
- child = spawn(command, commandArgs, { stdio: 'inherit', detached: !opts.foreground });
246
+ child = spawn(command, commandArgs, { stdio: 'inherit', detached: !opts.foreground, windowsHide: true });
235
247
  } catch (err) {
236
248
  finish(err && err.code === 'EACCES' ? 126 : 127);
237
249
  return;
@@ -3,9 +3,10 @@
3
3
  /**
4
4
  * updateCheck.cjs — bounded, offline-safe, opt-out "is a newer build published?" probe.
5
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
6
+ * getUpdateStatus() → { current, latest, behind, channel, downloadUrl? } (installer builds compare
7
+ * against the latest GitHub Release; npx/dev builds against the npm registry). Never throws/rejects; any failure
8
+ * (offline, DNS, non-200, bad JSON, timeout) → { current, latest: null, behind: false, channel }.
9
+ * The request is a bare GET (only a static User-Agent; no cookies/query/body) so it carries nothing that
9
10
  * identifies the user or machine. Disabled by SM_UPDATE_CHECK=0 or E2E mode (SM_E2E=1).
10
11
  */
11
12
 
@@ -16,11 +17,14 @@ const path = require('node:path');
16
17
  const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
17
18
  const REQUEST_TIMEOUT_MS = 3000;
18
19
  const CACHE_FILE = 'update-check.json';
20
+ const GITHUB_CACHE_FILE = 'update-check-github.json';
21
+ const GITHUB_RELEASE_URL = 'https://api.github.com/repos/StanislavBG/claude-code-session-manager/releases/latest';
19
22
 
20
23
  /** Plain GET → parsed JSON. Rejects on non-200, bad JSON, or timeout. */
21
24
  function httpGetJson(url, timeoutMs) {
22
25
  return new Promise((resolve, reject) => {
23
- const req = https.get(url, { timeout: timeoutMs }, (res) => {
26
+ // GitHub's API rejects requests without a User-Agent; it names the app only, not the user.
27
+ const req = https.get(url, { timeout: timeoutMs, headers: { 'User-Agent': 'claude-code-session-manager' } }, (res) => {
24
28
  if (res.statusCode !== 200) {
25
29
  res.resume();
26
30
  reject(new Error(`HTTP ${res.statusCode}`));
@@ -58,15 +62,20 @@ function isBehind(current, latest) {
58
62
  return false;
59
63
  }
60
64
 
61
- function readCache(file, now) {
65
+ function readCacheEntry(file, now) {
62
66
  try {
63
67
  const c = JSON.parse(fs.readFileSync(file, 'utf8'));
64
68
  if (c && typeof c.latest === 'string' && typeof c.checkedAt === 'number'
65
- && now - c.checkedAt >= 0 && now - c.checkedAt < CACHE_TTL_MS) return c.latest;
69
+ && now - c.checkedAt >= 0 && now - c.checkedAt < CACHE_TTL_MS) return c;
66
70
  } catch { /* missing/corrupt cache = miss */ }
67
71
  return null;
68
72
  }
69
73
 
74
+ function readCache(file, now) {
75
+ const c = readCacheEntry(file, now);
76
+ return c ? c.latest : null;
77
+ }
78
+
70
79
  function defaultPackageName() {
71
80
  return require(path.join(__dirname, '..', '..', '..', 'package.json')).name;
72
81
  }
@@ -74,13 +83,34 @@ function defaultPackageName() {
74
83
  async function getUpdateStatus({ deps } = {}) {
75
84
  const d = deps || {};
76
85
  let current = '';
86
+ let channel = 'npm';
77
87
  try {
78
88
  const app = d.app || require('electron').app;
79
89
  current = app.getVersion();
90
+ const packaged = (d.isPackaged || require('./appRuntime.cjs').isPackagedApp)();
91
+ channel = packaged ? 'installer' : 'npm';
80
92
  const env = d.env || process.env;
81
- if (env.SM_UPDATE_CHECK === '0' || env.SM_E2E === '1') return { current, latest: null, behind: false };
93
+ if (env.SM_UPDATE_CHECK === '0' || env.SM_E2E === '1') return { current, latest: null, behind: false, channel };
82
94
 
83
95
  const now = (d.now || Date.now)();
96
+ const write = d.writeJson || require('../config.cjs').writeJson;
97
+ if (packaged) {
98
+ const file = path.join(app.getPath('userData'), GITHUB_CACHE_FILE);
99
+ let entry = readCacheEntry(file, now);
100
+ if (entry == null) {
101
+ const rel = await (d.fetchJson || httpGetJson)(GITHUB_RELEASE_URL, REQUEST_TIMEOUT_MS);
102
+ const tag = rel && typeof rel.tag_name === 'string' ? rel.tag_name.replace(/^v/, '') : '';
103
+ if (!tag) return { current, latest: null, behind: false, channel };
104
+ const downloadUrl = typeof rel.html_url === 'string' ? rel.html_url : null;
105
+ entry = { checkedAt: now, latest: tag, downloadUrl };
106
+ try { await write(file, entry); } catch { /* cache is best-effort */ }
107
+ }
108
+ return {
109
+ current, latest: entry.latest, behind: isBehind(current, entry.latest), channel,
110
+ downloadUrl: typeof entry.downloadUrl === 'string' ? entry.downloadUrl : null,
111
+ };
112
+ }
113
+
84
114
  const file = path.join(app.getPath('userData'), CACHE_FILE);
85
115
  let latest = readCache(file, now);
86
116
  if (latest == null) {
@@ -88,14 +118,14 @@ async function getUpdateStatus({ deps } = {}) {
88
118
  const url = `https://registry.npmjs.org/-/package/${name}/dist-tags`;
89
119
  const tags = await (d.fetchJson || httpGetJson)(url, REQUEST_TIMEOUT_MS);
90
120
  latest = tags && typeof tags.latest === 'string' ? tags.latest : null;
91
- if (latest == null) return { current, latest: null, behind: false };
121
+ if (latest == null) return { current, latest: null, behind: false, channel };
92
122
  try {
93
- await (d.writeJson || require('../config.cjs').writeJson)(file, { checkedAt: now, latest });
123
+ await write(file, { checkedAt: now, latest });
94
124
  } catch { /* cache is best-effort */ }
95
125
  }
96
- return { current, latest, behind: isBehind(current, latest) };
126
+ return { current, latest, behind: isBehind(current, latest), channel };
97
127
  } catch {
98
- return { current, latest: null, behind: false };
128
+ return { current, latest: null, behind: false, channel };
99
129
  }
100
130
  }
101
131
 
@@ -9,6 +9,7 @@ const schedulerPaths = require('./schedulerPaths.cjs');
9
9
  const { isRestartingMarkerActive } = require('./upgradeDrain.cjs');
10
10
  const { pidAlive: pidAliveCore } = require('./pidAlive.cjs');
11
11
  const atomicFs = require('./atomicFs.cjs');
12
+ const { isPackagedApp } = require('./appRuntime.cjs');
12
13
 
13
14
 
14
15
 
@@ -230,14 +231,32 @@ function logRelaunchLine(logPath, message) {
230
231
  * (short-lived) process doesn't block on the launched app and the app
231
232
  * survives the watchdog exiting.
232
233
  */
233
- function defaultSpawnRelaunch({ logPath = schedulerPaths.watchdogRelaunchLogPath() } = {}) {
234
- const { spawn } = require('node:child_process');
234
+ /**
235
+ * relaunchSpec(opts?) — what the watchdog spawns to bring the app back.
236
+ * Packaged installer builds have no npx: relaunch the app binary itself.
237
+ * @param {{ packaged?: boolean, execPath?: string }} [opts]
238
+ * @returns {{ command: string, args: string[], packaged: boolean }}
239
+ */
240
+ function relaunchSpec(opts) {
241
+ if (isPackagedApp(opts)) {
242
+ return { command: (opts && opts.execPath) || process.execPath, args: [], packaged: true };
243
+ }
244
+ return { command: 'npx', args: ['claude-code-session-manager@latest'], packaged: false };
245
+ }
246
+
247
+ function defaultSpawnRelaunch({ logPath = schedulerPaths.watchdogRelaunchLogPath(), packaged, execPath, spawnFn } = {}) {
248
+ const spawn = spawnFn || require('node:child_process').spawn;
249
+ const spec = relaunchSpec({ packaged, execPath });
235
250
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
236
251
  const fd = fs.openSync(logPath, 'a');
237
252
  try {
238
- const child = spawn('npx', ['claude-code-session-manager@latest'], {
253
+ const env = { ...process.env };
254
+ // The watchdog may itself run as node via the app binary; the relaunched app must not.
255
+ if (spec.packaged) delete env.ELECTRON_RUN_AS_NODE;
256
+ const child = spawn(spec.command, spec.args, {
239
257
  detached: true,
240
258
  stdio: ['ignore', fd, fd],
259
+ env,
241
260
  });
242
261
  child.on('error', (e) => {
243
262
  logRelaunchLine(logPath, `spawn error: ${e?.message}`);
@@ -460,6 +479,7 @@ module.exports = {
460
479
  readRelaunchState,
461
480
  writeRelaunchState,
462
481
  defaultSpawnRelaunch,
482
+ relaunchSpec,
463
483
  maybeRelaunchApp,
464
484
  DEFAULT_MAX_AGE_MS,
465
485
  DEFAULT_DISPATCH_DEAD_MS,
@@ -0,0 +1,67 @@
1
+ /**
2
+ * winSpawn.cjs — make spawn() work for Windows .cmd/.bat shims (npx, npm) without
3
+ * shell:true. Node refuses to spawn those directly (EINVAL), so they are wrapped in
4
+ * an explicit `cmd.exe /d /s /c "<line>"` argv with every cmd metacharacter escaped.
5
+ * Non-win32 input passes through untouched.
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+
13
+ const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD';
14
+ const META = /([()\][%!^"`<>&|;, *?])/g;
15
+
16
+ function envGet(env, name) {
17
+ if (env[name] !== undefined) return env[name];
18
+ const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase());
19
+ return key ? env[key] : undefined;
20
+ }
21
+
22
+ function findOnPath(command, env, exists) {
23
+ const w = path.win32;
24
+ const exts = (envGet(env, 'PATHEXT') || DEFAULT_PATHEXT)
25
+ .split(';').filter(Boolean).map((e) => e.toLowerCase());
26
+ const hasDir = /[\\/]/.test(command);
27
+ const dirs = hasDir ? [''] : (envGet(env, 'PATH') || '').split(';').filter(Boolean);
28
+ const hasExt = exts.includes(w.extname(command).toLowerCase());
29
+ for (const dir of dirs) {
30
+ const base = dir ? w.join(dir, command) : command;
31
+ const candidates = hasExt ? [base] : [];
32
+ for (const e of exts) candidates.push(base + e);
33
+ // PATHEXT order is by extension; keep the casing the filesystem probe accepts.
34
+ for (const cand of candidates) {
35
+ if (exists(cand)) return cand;
36
+ const upper = cand.slice(0, cand.length - w.extname(cand).length) + w.extname(cand).toUpperCase();
37
+ if (upper !== cand && exists(upper)) return upper;
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ // Quote for CreateProcess argv parsing, then ^-escape every cmd metacharacter.
44
+ function quoteArg(arg) {
45
+ let s = String(arg);
46
+ s = s.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1');
47
+ s = `"${s}"`;
48
+ return s.replace(META, '^$1');
49
+ }
50
+
51
+ function resolveSpawn(command, args = [], { platform = process.platform, env = process.env, exists = fs.existsSync } = {}) {
52
+ if (platform !== 'win32') return { command, args };
53
+ const resolved = findOnPath(command, env, exists);
54
+ if (!resolved) return { command, args };
55
+ const ext = path.win32.extname(resolved).toLowerCase();
56
+ if (ext === '.cmd' || ext === '.bat') {
57
+ const line = [resolved, ...args].map(quoteArg).join(' ');
58
+ return {
59
+ command: envGet(env, 'ComSpec') || 'cmd.exe',
60
+ args: ['/d', '/s', '/c', `"${line}"`],
61
+ windowsVerbatimArguments: true,
62
+ };
63
+ }
64
+ return { command: resolved, args };
65
+ }
66
+
67
+ module.exports = { resolveSpawn };
@@ -0,0 +1,22 @@
1
+ // Chromium resolves 'userData' via Windows known folders; with a stripped profile
2
+ // (no AppData) app.getPath('userData') throws "Failed to get 'userData' path" and the
3
+ // process dies 0x80000003. Fall back to an explicit %APPDATA%-based path.
4
+ const path = require('node:path');
5
+
6
+ function ensureWinUserDataPath({ app, env, platform, fs, os, log = () => {} }) {
7
+ if (platform !== 'win32') return false;
8
+ try {
9
+ app.getPath('userData');
10
+ return false;
11
+ } catch (err) {
12
+ const appData = (env && env.APPDATA) || path.win32.join(os.homedir(), 'AppData', 'Roaming');
13
+ const userData = path.win32.join(appData, 'Session Manager');
14
+ fs.mkdirSync(userData, { recursive: true });
15
+ app.setPath('appData', appData);
16
+ app.setPath('userData', userData);
17
+ log(`[win-userdata] getPath('userData') failed (${err && err.message}); using ${userData}`);
18
+ return true;
19
+ }
20
+ }
21
+
22
+ module.exports = { ensureWinUserDataPath };
package/src/main/otel.cjs CHANGED
@@ -8,58 +8,36 @@
8
8
  * plan text, and agent prompts are excluded unless `includeContent` is on
9
9
  * (matches upstream OTEL_LOG_USER_PROMPTS opt-in).
10
10
  *
11
- * The OTEL SDK is required lazily — if any of the @opentelemetry/* packages
12
- * fail to load, the module is left in an inert state and `recordTranscriptEvent`
13
- * becomes a no-op. The app must keep working without telemetry.
14
- *
15
- * Threading: the BatchSpanProcessor batches/sends spans on its own timer; we
16
- * never block the transcript flush.
11
+ * Spans are POSTed as OTLP/HTTP JSON by lib/otlpTraceExporter.cjs (zero
12
+ * dependencies). recordSpan() only queues; the exporter batches/sends on its
13
+ * own unref()'d timer, so we never block the transcript flush. The app must
14
+ * keep working without telemetry: a failed export is surfaced, never thrown.
17
15
  */
18
16
 
19
17
  const otelSettings = require('./otelSettings.cjs');
18
+ const { createOtlpTraceExporter } = require('./lib/otlpTraceExporter.cjs');
20
19
 
21
- let provider = null;
22
- let tracer = null;
20
+ let exporter = null;
23
21
  let enabled = false;
24
22
  let includeContent = false;
25
23
  let lastError = null;
26
24
  let initialized = false;
27
25
 
28
26
  /**
29
- * Lazy-require all @opentelemetry/* packages. Returns null if any are missing.
30
- * We catch the per-require so a partial install doesn't half-init.
31
- */
32
- function loadDeps() {
33
- try {
34
- const api = require('@opentelemetry/api');
35
- const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
36
- const { BatchSpanProcessor } = require('@opentelemetry/sdk-trace-base');
37
- const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
38
- const { resourceFromAttributes } = require('@opentelemetry/resources');
39
- const { ATTR_SERVICE_NAME } = require('@opentelemetry/semantic-conventions');
40
- return { api, NodeTracerProvider, BatchSpanProcessor, OTLPTraceExporter, resourceFromAttributes, ATTR_SERVICE_NAME };
41
- } catch (err) {
42
- return { error: err };
43
- }
44
- }
45
-
46
- /**
47
- * Tear down any existing provider. Best-effort — flushes pending spans on a
27
+ * Tear down any existing exporter. Best-effort — flushes pending spans on a
48
28
  * 2s ceiling so a wedged exporter doesn't block app shutdown / config reload.
49
29
  */
50
30
  async function shutdown() {
51
- if (!provider) {
31
+ if (!exporter) {
52
32
  enabled = false;
53
- tracer = null;
54
33
  return;
55
34
  }
56
- const p = provider;
57
- provider = null;
58
- tracer = null;
35
+ const e = exporter;
36
+ exporter = null;
59
37
  enabled = false;
60
38
  try {
61
39
  await Promise.race([
62
- p.shutdown(),
40
+ e.shutdown(),
63
41
  new Promise((resolve) => setTimeout(resolve, 2000)),
64
42
  ]);
65
43
  } catch (err) {
@@ -68,7 +46,7 @@ async function shutdown() {
68
46
  }
69
47
 
70
48
  /**
71
- * Configure the tracer provider. Tears down any prior provider first so a
49
+ * Configure the exporter. Tears down any prior exporter first so a
72
50
  * settings change reliably re-points at the new endpoint.
73
51
  *
74
52
  * Returns { ok, error?: string } — the renderer surfaces `error` in the UI.
@@ -77,31 +55,13 @@ async function init({ endpoint, headers, serviceName, includeContent: ic } = {})
77
55
  await shutdown();
78
56
  initialized = true;
79
57
 
80
- const deps = loadDeps();
81
- if (deps.error) {
82
- lastError = `OTEL packages unavailable: ${deps.error.message}`;
83
- return { ok: false, error: lastError };
84
- }
85
-
86
- const { NodeTracerProvider, BatchSpanProcessor, OTLPTraceExporter, resourceFromAttributes, ATTR_SERVICE_NAME } = deps;
87
-
88
58
  try {
89
- const exporter = new OTLPTraceExporter({
59
+ exporter = createOtlpTraceExporter({
90
60
  url: String(endpoint || 'http://localhost:4318/v1/traces'),
91
61
  headers: typeof headers === 'object' && headers ? headers : {},
62
+ serviceName: String(serviceName || 'session-manager'),
63
+ scopeName: 'session-manager-transcripts',
92
64
  });
93
- const tp = new NodeTracerProvider({
94
- resource: resourceFromAttributes({
95
- [ATTR_SERVICE_NAME]: String(serviceName || 'session-manager'),
96
- }),
97
- spanProcessors: [new BatchSpanProcessor(exporter)],
98
- });
99
- // Note: we deliberately do NOT call tp.register() — that installs a global
100
- // tracer provider, which would intercept any other OTEL user in the
101
- // process. We only want to emit our own spans, so we use the provider
102
- // directly via getTracer().
103
- provider = tp;
104
- tracer = tp.getTracer('session-manager-transcripts');
105
65
  includeContent = !!ic;
106
66
  enabled = true;
107
67
  lastError = null;
@@ -205,11 +165,11 @@ function pruneAttrs(obj) {
205
165
 
206
166
  /**
207
167
  * Emit a 0-duration span for one classified transcript event.
208
- * Synchronous + non-blocking: the BatchSpanProcessor handles network I/O
168
+ * Synchronous + non-blocking: the exporter handles network I/O
209
169
  * on its own schedule.
210
170
  */
211
171
  function recordTranscriptEvent({ tabId, tabCwd, kind, data, ts }) {
212
- if (!enabled || !tracer) return;
172
+ if (!enabled || !exporter) return;
213
173
  try {
214
174
  const startTime = typeof ts === 'number' ? ts : Date.now();
215
175
  const attrs = pruneAttrs({
@@ -218,11 +178,7 @@ function recordTranscriptEvent({ tabId, tabCwd, kind, data, ts }) {
218
178
  kind,
219
179
  ...attrsFor(kind, data),
220
180
  });
221
- const span = tracer.startSpan(`transcript.${kind}`, {
222
- startTime,
223
- attributes: attrs,
224
- });
225
- span.end(startTime);
181
+ exporter.recordSpan({ name: `transcript.${kind}`, timeMs: startTime, attributes: attrs });
226
182
  } catch (err) {
227
183
  // A failure here must never break transcript ingestion. Log once-ish.
228
184
  if (!recordTranscriptEvent._warned) {