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
@@ -17,28 +17,53 @@ const os = require('node:os');
17
17
 
18
18
  let cached = null;
19
19
 
20
- function resolveClaudeBin() {
20
+ /**
21
+ * @param {{ platform?: string, env?: Record<string, string|undefined>, homedir?: string,
22
+ * accessSync?: (p: string, mode?: number) => void }} [opts] injectable for tests;
23
+ * passing any opts bypasses the process-lifetime cache.
24
+ */
25
+ function resolveClaudeBin(opts) {
21
26
  // Explicit override wins and is never cached — lets operators pin a binary
22
27
  // and lets tests point the runner at a controllable stub process.
23
28
  if (process.env.SM_CLAUDE_BIN) return process.env.SM_CLAUDE_BIN;
24
- if (cached) return cached;
25
- // Merged candidate list — was forked in scheduler vs pluginInstall before.
26
- const home = os.homedir();
27
- const candidates = [
28
- path.join(home, '.claude', 'local', 'claude'), // Claude Code bundled install
29
- path.join(home, '.local', 'bin', 'claude'), // user pip-style install
30
- path.join(home, '.npm-global', 'bin', 'claude'), // user npm-global
31
- '/usr/local/bin/claude',
32
- '/opt/homebrew/bin/claude',
33
- '/usr/bin/claude',
34
- ];
29
+ const injected = !!opts;
30
+ if (!injected && cached) return cached;
31
+ const platform = (opts && opts.platform) || process.platform;
32
+ const env = (opts && opts.env) || process.env;
33
+ const home = (opts && opts.homedir) || os.homedir();
34
+ const accessSync = (opts && opts.accessSync) || fs.accessSync;
35
+ const P = platform === 'win32' ? path.win32 : path.posix;
36
+ let candidates;
37
+ let mode;
38
+ if (platform === 'win32') {
39
+ const w = P;
40
+ candidates = [];
41
+ if (env.USERPROFILE) candidates.push(w.join(env.USERPROFILE, '.local', 'bin', 'claude.exe')); // native installer
42
+ if (env.APPDATA) candidates.push(w.join(env.APPDATA, 'npm', 'claude.cmd'));
43
+ if (env.LOCALAPPDATA) candidates.push(w.join(env.LOCALAPPDATA, 'Programs', 'claude', 'claude.exe'));
44
+ mode = fs.constants.F_OK; // X_OK is meaningless on Windows
45
+ } else {
46
+ // Merged candidate list — was forked in scheduler vs pluginInstall before.
47
+ candidates = [
48
+ P.join(home, '.claude', 'local', 'claude'), // Claude Code bundled install
49
+ P.join(home, '.local', 'bin', 'claude'), // user pip-style install
50
+ P.join(home, '.npm-global', 'bin', 'claude'), // user npm-global
51
+ '/usr/local/bin/claude',
52
+ '/opt/homebrew/bin/claude',
53
+ '/usr/bin/claude',
54
+ ];
55
+ mode = fs.constants.X_OK;
56
+ }
35
57
  for (const c of candidates) {
36
- try { fs.accessSync(c, fs.constants.X_OK); cached = c; return c; } catch { /* */ }
58
+ try { accessSync(c, mode); if (!injected) cached = c; return c; } catch { /* */ }
37
59
  }
38
- cached = 'claude';
39
- return cached;
60
+ if (!injected) cached = 'claude';
61
+ return 'claude';
40
62
  }
41
63
 
64
+ /** Test hook — forget the cached resolution. */
65
+ function __resetForTests() { cached = null; }
66
+
42
67
  // ---------- process naming ----------
43
68
  //
44
69
  // Every `claude` spawn goes through claudeSpawnTarget so GNOME System Monitor
@@ -57,9 +82,22 @@ const ROLE_ALIASES = { job: 'sm-claude-job', chat: 'sm-claude-chat', aux: 'sm-cl
57
82
  * @param {'job'|'chat'|'aux'} role
58
83
  * @param {string} [detail] argv0 label detail (job slug / epic session id)
59
84
  * @param {string} [realBin] defaults to resolveClaudeBin()
60
- * @returns {{ command: string, argv0?: string }} spread `argv0` into spawn options
85
+ * @param {string[]} [args] the claude argv; only needed so a Windows .cmd/.bat shim can be wrapped
86
+ * @param {{ platform?: string, env?: Record<string, string|undefined>, exists?: (p: string) => boolean }} [winOpts] injectable for tests
87
+ * @returns {{ command: string, argv0?: string, args?: string[], windowsVerbatimArguments?: boolean }}
88
+ * spread `argv0` into spawn options; when `args` is present spawn with it instead of the caller's argv
61
89
  */
62
- function claudeSpawnTarget(role, detail, realBin = resolveClaudeBin()) {
90
+ function claudeSpawnTarget(role, detail, realBin = resolveClaudeBin(), args = [], winOpts = {}) {
91
+ const base = baseSpawnTarget(role, detail, realBin);
92
+ const platform = winOpts.platform || process.platform;
93
+ if (platform !== 'win32' || !/\.(cmd|bat)$/i.test(base.command)) return base;
94
+ // Node refuses to spawn a .cmd/.bat without a shell (EINVAL) — wrap in cmd.exe argv.
95
+ const { resolveSpawn } = require('./winSpawn.cjs');
96
+ const wrapped = resolveSpawn(base.command, args, { platform, ...(winOpts.env ? { env: winOpts.env } : {}), ...(winOpts.exists ? { exists: winOpts.exists } : {}) });
97
+ return { ...base, ...wrapped };
98
+ }
99
+
100
+ function baseSpawnTarget(role, detail, realBin) {
63
101
  if (process.env.SM_CLAUDE_BIN) return { command: realBin };
64
102
  const alias = ROLE_ALIASES[role];
65
103
  if (!alias) return { command: realBin };
@@ -109,8 +147,8 @@ function probeClaudeVersion({ now = Date.now(), execFileImpl } = {}) {
109
147
  // it, and the binary must never be handed the caller's working tree —
110
148
  // a stub `claude` (tests point SM_CLAUDE_BIN at one) that writes
111
149
  // markers or commits into process.cwd() once did so in the real repo.
112
- const target = claudeSpawnTarget('aux', 'version', bin);
113
- execFile(target.command, ['--version'], { timeout: VERSION_PROBE_TIMEOUT_MS, windowsHide: true, cwd: os.tmpdir(), ...(target.argv0 ? { argv0: target.argv0 } : {}) }, (err, stdout) => {
150
+ const target = claudeSpawnTarget('aux', 'version', bin, ['--version']);
151
+ execFile(target.command, target.args || ['--version'], { timeout: VERSION_PROBE_TIMEOUT_MS, windowsHide: true, cwd: os.tmpdir(), ...(target.argv0 ? { argv0: target.argv0 } : {}), ...(target.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}) }, (err, stdout) => {
114
152
  if (err) return done(null);
115
153
  const m = /(\d+\.\d+\.\d+[^\s]*)/.exec(String(stdout || ''));
116
154
  done(m ? m[1] : (String(stdout || '').trim() || null));
@@ -127,4 +165,4 @@ function resetClaudeVersionCache() {
127
165
  versionCache = { at: 0, value: null, inflight: null };
128
166
  }
129
167
 
130
- module.exports = { resolveClaudeBin, claudeSpawnTarget, probeClaudeVersion, resetClaudeVersionCache };
168
+ module.exports = { resolveClaudeBin, __resetForTests, claudeSpawnTarget, probeClaudeVersion, resetClaudeVersionCache };
@@ -11,12 +11,26 @@ const SECRET_KEY_RE = /^(?:.*_)?(TOKEN|API_?KEY|SECRET|PASSWORD|AUTHORIZATION|CO
11
11
  * so pty.cjs and pluginInstall.cjs can't drift (one of them was missing
12
12
  * /opt/homebrew and broke plugin install on Apple Silicon).
13
13
  */
14
- function userBinDirs() {
15
- const home = os.homedir();
14
+ function userBinDirs(opts) {
15
+ const platform = (opts && opts.platform) || process.platform;
16
+ const P = platform === 'win32' ? path.win32 : path.posix;
17
+ if (platform === 'win32') {
18
+ const env = (opts && opts.env) || process.env;
19
+ const w = P;
20
+ const dirs = [];
21
+ if (env.USERPROFILE) dirs.push(w.join(env.USERPROFILE, '.local', 'bin'));
22
+ if (env.APPDATA) dirs.push(w.join(env.APPDATA, 'npm'));
23
+ if (env.ProgramFiles) {
24
+ dirs.push(w.join(env.ProgramFiles, 'Git', 'cmd'));
25
+ dirs.push(w.join(env.ProgramFiles, 'nodejs'));
26
+ }
27
+ return dirs;
28
+ }
29
+ const home = (opts && opts.homedir) || os.homedir();
16
30
  return [
17
- path.join(home, '.claude', 'local'),
18
- path.join(home, '.local', 'bin'),
19
- path.join(home, '.npm-global', 'bin'),
31
+ P.join(home, '.claude', 'local'),
32
+ P.join(home, '.local', 'bin'),
33
+ P.join(home, '.npm-global', 'bin'),
20
34
  '/opt/homebrew/bin',
21
35
  '/opt/homebrew/sbin',
22
36
  '/usr/local/bin',
@@ -29,10 +43,20 @@ function userBinDirs() {
29
43
  * value to put in a spawned child's PATH so `claude`, node, git, etc. resolve
30
44
  * on macOS even under a stripped Finder/Dock PATH. Appended (not prepended) so
31
45
  * a user's version manager (nvm/asdf/volta/mise) earlier in PATH still wins and
32
- * isn't shadowed by /opt/homebrew. */
33
- function pathWithUserBins() {
34
- const base = process.env.PATH || '';
35
- return base ? `${base}:${userBinDirs().join(':')}` : userBinDirs().join(':');
46
+ * isn't shadowed by /opt/homebrew. Windows: ';' delimiter, and PATH is read
47
+ * case-insensitively (the variable is usually `Path`). */
48
+ function pathWithUserBins(opts) {
49
+ const platform = (opts && opts.platform) || process.platform;
50
+ const env = (opts && opts.env) || process.env;
51
+ const win = platform === 'win32';
52
+ const delim = (win ? path.win32 : path.posix).delimiter;
53
+ let base = env.PATH || '';
54
+ if (win && !base) {
55
+ const key = Object.keys(env).find((k) => k.toUpperCase() === 'PATH');
56
+ base = (key && env[key]) || '';
57
+ }
58
+ const dirs = userBinDirs({ ...(opts || {}), platform, env }).join(delim);
59
+ return base && dirs ? `${base}${delim}${dirs}` : base || dirs;
36
60
  }
37
61
 
38
62
  /**
@@ -0,0 +1,94 @@
1
+ // @ts-check
2
+ /**
3
+ * customerFeedbackAdminRoutes.cjs — loopback admin HTTP routes so an agent
4
+ * session (via the scheduler MCP) can inspect and resolve customer feedback
5
+ * through customerFeedbackInbox.cjs:
6
+ * GET /admin/customer-feedback/inbox?pull=1&includeHidden=1 -> { ok, items, pulled? }
7
+ * POST /admin/customer-feedback/status {id, status, note?} -> { ok, item }
8
+ *
9
+ * Deliberately NO route that mints an Epic (SINGLE-CREATOR LAW, epicMint.cjs).
10
+ * Responses never carry the owner token — the inbox lib redacts it from errors.
11
+ */
12
+ 'use strict';
13
+
14
+ const { readBody, sendJson } = require('./localAdminHttp.cjs');
15
+
16
+ /** @param {any} dep */
17
+ function getInbox(dep) {
18
+ return dep || require('./customerFeedbackInbox.cjs');
19
+ }
20
+
21
+ /** @param {any} inboxDep */
22
+ function inboxHandler(inboxDep) {
23
+ return async (/** @type {any} */ _req, /** @type {any} */ res, /** @type {URLSearchParams} */ query) => {
24
+ const inbox = getInbox(inboxDep);
25
+ const wantPull = query.get('pull') === '1';
26
+ const includeHidden = query.get('includeHidden') === '1';
27
+ /** @type {any} */
28
+ let pulled;
29
+ if (wantPull) {
30
+ pulled = await inbox.pull();
31
+ if (!pulled.ok) {
32
+ sendJson(res, 200, { ok: false, error: pulled.error });
33
+ return;
34
+ }
35
+ }
36
+ const result = await inbox.list({ includeHidden });
37
+ if (!result.ok) {
38
+ sendJson(res, 200, { ok: false, error: result.error });
39
+ return;
40
+ }
41
+ sendJson(res, 200, { ok: true, items: result.items, ...(wantPull ? { pulled: { fetched: pulled.fetched, pages: pulled.pages } } : {}) });
42
+ };
43
+ }
44
+
45
+ /** @param {any} inboxDep */
46
+ function statusHandler(inboxDep) {
47
+ return async (/** @type {any} */ req, /** @type {any} */ res) => {
48
+ const inbox = getInbox(inboxDep);
49
+ let raw;
50
+ try {
51
+ raw = await readBody(req);
52
+ } catch (/** @type {any} */ e) {
53
+ sendJson(res, 400, { ok: false, error: `request body rejected: ${e?.message ?? 'unreadable'}` });
54
+ return;
55
+ }
56
+ /** @type {any} */
57
+ let body;
58
+ try {
59
+ body = raw ? JSON.parse(raw) : {};
60
+ } catch {
61
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
62
+ return;
63
+ }
64
+ if (!body || typeof body !== 'object' || typeof body.id !== 'string' || !body.id) {
65
+ sendJson(res, 400, { ok: false, error: 'id is required' });
66
+ return;
67
+ }
68
+ if (typeof body.status !== 'string' || !inbox.STATUSES.includes(body.status)) {
69
+ sendJson(res, 400, { ok: false, error: `status must be one of: ${inbox.STATUSES.join(', ')}` });
70
+ return;
71
+ }
72
+ if (body.note !== undefined && body.note !== null && typeof body.note !== 'string') {
73
+ sendJson(res, 400, { ok: false, error: 'note must be a string' });
74
+ return;
75
+ }
76
+ const result = await inbox.setStatus(body.id, body.status, body.note ?? undefined);
77
+ if (!result.ok) {
78
+ sendJson(res, 200, { ok: false, error: result.error });
79
+ return;
80
+ }
81
+ sendJson(res, 200, { ok: true, item: result.item });
82
+ };
83
+ }
84
+
85
+ /**
86
+ * @param {any} adminHttp
87
+ * @param {{ inbox?: any }} [opts] test seam
88
+ */
89
+ function registerAdminRoute(adminHttp, opts = {}) {
90
+ adminHttp.registerRoute('GET', '/admin/customer-feedback/inbox', inboxHandler(opts.inbox));
91
+ adminHttp.registerRoute('POST', '/admin/customer-feedback/status', statusHandler(opts.inbox));
92
+ }
93
+
94
+ module.exports = { registerAdminRoute };
@@ -0,0 +1,261 @@
1
+ // @ts-check
2
+ /**
3
+ * customerFeedbackClient — customer side of in-app feedback.
4
+ *
5
+ * Validates a submission, POSTs it to the bilko.run per-project feedback API, records it in a
6
+ * machine-global store (~/.claude/session-manager/customer-feedback.json) so the user can see what
7
+ * they sent, and refreshes each item's resolution status via a receipt-based lookup endpoint that
8
+ * may not exist on the host yet (404 degrades to `unsupported`).
9
+ *
10
+ * Sends no identity: client = {app, version, platform} only. Deliberately NOT gated on
11
+ * SM_TELEMETRY — feedback is an explicit user action.
12
+ */
13
+ 'use strict';
14
+
15
+ const path = require('node:path');
16
+ const fsp = require('node:fs/promises');
17
+ const atomicFs = require('./atomicFs.cjs');
18
+ const schedulerPaths = require('./schedulerPaths.cjs');
19
+
20
+ const FEEDBACK_SLUG = 'session-manager';
21
+ const TAGS = /** @type {const} */ (['bug', 'feature', 'discussion']);
22
+ const MAX_TITLE = 120;
23
+ const MAX_BODY = 4000;
24
+ const MAX_ITEMS = 500;
25
+ const MAX_LOOKUP = 100;
26
+ const TIMEOUT_MS = 15000;
27
+ const SCHEMA_VERSION = 1;
28
+ const RATE_LIMIT_ERROR = 'Too many submissions — try again in a minute.';
29
+
30
+ /**
31
+ * @typedef {{ id: string, receipt: string|null, tag: string, title: string, body: string,
32
+ * submittedAt: number, status: string, statusNote: string|null,
33
+ * statusAt: number|string|null, seenStatusAt: number|string|null }} FeedbackItem
34
+ * @typedef {{ fetchImpl?: typeof fetch, storeFile?: string, now?: () => number, appVersion?: string }} Deps
35
+ */
36
+
37
+ function resolveFeedbackBase() {
38
+ return (process.env.SM_FEEDBACK_ENDPOINT || 'https://bilko.run').replace(/\/+$/, '');
39
+ }
40
+
41
+ /** @param {string} tag */
42
+ function toWireType(tag) {
43
+ return tag === 'discussion' ? 'feedback' : tag;
44
+ }
45
+
46
+ /** @param {string} type */
47
+ function fromWireType(type) {
48
+ return type === 'feedback' ? 'discussion' : type;
49
+ }
50
+
51
+ /**
52
+ * @param {{ title?: unknown, body?: unknown, tag?: unknown }} input
53
+ * @returns {{ ok: true, value: { title: string, body: string, tag: string } } | { ok: false, error: string }}
54
+ */
55
+ function validateSubmission(input) {
56
+ const src = input || {};
57
+ const title = typeof src.title === 'string' ? src.title.trim() : '';
58
+ const body = typeof src.body === 'string' ? src.body.trim() : '';
59
+ const tag = src.tag;
60
+ if (!title) return { ok: false, error: 'Please enter a title.' };
61
+ if (title.length > MAX_TITLE) return { ok: false, error: `Title must be ${MAX_TITLE} characters or fewer.` };
62
+ if (!body) return { ok: false, error: 'Please enter a description.' };
63
+ if (body.length > MAX_BODY) return { ok: false, error: `Description must be ${MAX_BODY} characters or fewer.` };
64
+ if (typeof tag !== 'string' || !TAGS.includes(/** @type {any} */ (tag))) {
65
+ return { ok: false, error: `Tag must be one of: ${TAGS.join(', ')}.` };
66
+ }
67
+ return { ok: true, value: { title, body, tag } };
68
+ }
69
+
70
+ function defaultStoreFile() {
71
+ return path.join(schedulerPaths.schedulerHome(), 'customer-feedback.json');
72
+ }
73
+
74
+ function defaultVersion() {
75
+ try {
76
+ const electron = require('electron');
77
+ if (electron && electron.app && typeof electron.app.getVersion === 'function') return electron.app.getVersion();
78
+ } catch { /* not running under Electron */ }
79
+ try {
80
+ return String(require('../../../package.json').version || 'unknown');
81
+ } catch {
82
+ return 'unknown';
83
+ }
84
+ }
85
+
86
+ /** @param {Deps} [deps] */
87
+ function ctx(deps) {
88
+ const d = deps || {};
89
+ return {
90
+ fetchImpl: d.fetchImpl || globalThis.fetch,
91
+ storeFile: d.storeFile || defaultStoreFile(),
92
+ now: d.now || Date.now,
93
+ appVersion: d.appVersion || defaultVersion(),
94
+ };
95
+ }
96
+
97
+ /** @param {string} file @returns {Promise<FeedbackItem[]>} */
98
+ async function readItems(file) {
99
+ try {
100
+ const data = JSON.parse(await fsp.readFile(file, 'utf8'));
101
+ if (data && Array.isArray(data.items)) return data.items.filter((/** @type {unknown} */ i) => i && typeof i === 'object');
102
+ } catch { /* missing or corrupt reads as empty */ }
103
+ return [];
104
+ }
105
+
106
+ let writeQueue = Promise.resolve();
107
+ /**
108
+ * Serialized read-modify-write: each mutation reads the freshest on-disk items.
109
+ * @param {string} file
110
+ * @param {(items: FeedbackItem[]) => FeedbackItem[]} mutate
111
+ */
112
+ function mutateStore(file, mutate) {
113
+ const run = async () => {
114
+ const next = mutate(await readItems(file));
115
+ const capped = next.slice(0, MAX_ITEMS);
116
+ await atomicFs.writeJsonAtomic(file, { schemaVersion: SCHEMA_VERSION, items: capped }, { mode: 0o600 });
117
+ return capped;
118
+ };
119
+ const tail = writeQueue.then(run, run);
120
+ writeQueue = tail.then(() => {}, () => {});
121
+ return tail;
122
+ }
123
+
124
+ /** Items newest-first (store order). @param {Deps} [deps] */
125
+ async function list(deps) {
126
+ return readItems(ctx(deps).storeFile);
127
+ }
128
+
129
+ /** @param {Response} res @param {string} fallback */
130
+ async function errorText(res, fallback) {
131
+ try {
132
+ /** @type {any} */
133
+ const j = await res.json();
134
+ if (j && typeof j.error === 'string' && j.error) return j.error;
135
+ } catch { /* no body */ }
136
+ return fallback;
137
+ }
138
+
139
+ /**
140
+ * @param {{ title?: unknown, body?: unknown, tag?: unknown }} input
141
+ * @param {Deps} [deps]
142
+ * @returns {Promise<{ ok: true, item: FeedbackItem } | { ok: false, error: string }>}
143
+ */
144
+ async function submit(input, deps) {
145
+ const v = validateSubmission(input);
146
+ if (!v.ok) return v;
147
+ const c = ctx(deps);
148
+ const { title, body, tag } = v.value;
149
+ try {
150
+ const res = await c.fetchImpl(`${resolveFeedbackBase()}/api/projects/${FEEDBACK_SLUG}/feedback`, {
151
+ method: 'POST',
152
+ headers: { 'content-type': 'application/json' },
153
+ body: JSON.stringify({
154
+ target: { kind: 'page', id: 'desktop-app', label: 'Session Manager desktop' },
155
+ type: toWireType(tag),
156
+ title,
157
+ description: body,
158
+ client: { app: 'session-manager', version: c.appVersion, platform: process.platform },
159
+ }),
160
+ signal: AbortSignal.timeout(TIMEOUT_MS),
161
+ });
162
+ if (res.status === 429) return { ok: false, error: RATE_LIMIT_ERROR };
163
+ if (res.status !== 201) return { ok: false, error: await errorText(res, `Feedback service returned ${res.status}.`) };
164
+ /** @type {any} */
165
+ const j = await res.json().catch(() => null);
166
+ if (!j || typeof j.id !== 'string' || !j.id) return { ok: false, error: 'Feedback service returned an unexpected response.' };
167
+ /** @type {FeedbackItem} */
168
+ const item = {
169
+ id: j.id,
170
+ receipt: typeof j.receipt === 'string' && j.receipt ? j.receipt : null,
171
+ tag,
172
+ title,
173
+ body,
174
+ submittedAt: c.now(),
175
+ status: 'open',
176
+ statusNote: null,
177
+ statusAt: null,
178
+ seenStatusAt: null,
179
+ };
180
+ await mutateStore(c.storeFile, (items) => [item, ...items]);
181
+ return { ok: true, item };
182
+ } catch (e) {
183
+ return { ok: false, error: `Could not reach the feedback service: ${e instanceof Error ? e.message : String(e)}` };
184
+ }
185
+ }
186
+
187
+ /**
188
+ * @param {Deps} [deps]
189
+ * @returns {Promise<{ ok: true, updated: number, unsupported?: true } | { ok: false, error: string }>}
190
+ */
191
+ async function refreshStatuses(deps) {
192
+ const c = ctx(deps);
193
+ const lookup = (await readItems(c.storeFile))
194
+ .filter((i) => typeof i.receipt === 'string' && i.receipt)
195
+ .slice(0, MAX_LOOKUP)
196
+ .map((i) => ({ id: i.id, receipt: i.receipt }));
197
+ if (lookup.length === 0) return { ok: true, updated: 0 };
198
+ try {
199
+ const res = await c.fetchImpl(`${resolveFeedbackBase()}/api/projects/${FEEDBACK_SLUG}/feedback/status-lookup`, {
200
+ method: 'POST',
201
+ headers: { 'content-type': 'application/json' },
202
+ body: JSON.stringify({ items: lookup }),
203
+ signal: AbortSignal.timeout(TIMEOUT_MS),
204
+ });
205
+ if (res.status === 404) return { ok: true, updated: 0, unsupported: true };
206
+ if (!res.ok) return { ok: false, error: await errorText(res, `Feedback service returned ${res.status}.`) };
207
+ /** @type {any} */
208
+ const j = await res.json().catch(() => null);
209
+ const entries = j && Array.isArray(j.items) ? j.items : [];
210
+ /** @type {Map<string, any>} */
211
+ const byId = new Map();
212
+ for (const e of entries) if (e && typeof e.id === 'string') byId.set(e.id, e);
213
+ let updated = 0;
214
+ await mutateStore(c.storeFile, (items) => {
215
+ updated = 0;
216
+ return items.map((it) => {
217
+ const e = byId.get(it.id);
218
+ if (!e || typeof e.status !== 'string') return it;
219
+ updated++;
220
+ return {
221
+ ...it,
222
+ status: e.status,
223
+ statusNote: typeof e.note === 'string' ? e.note : null,
224
+ statusAt: e.statusAt ?? null,
225
+ };
226
+ });
227
+ });
228
+ return { ok: true, updated };
229
+ } catch (e) {
230
+ return { ok: false, error: `Could not reach the feedback service: ${e instanceof Error ? e.message : String(e)}` };
231
+ }
232
+ }
233
+
234
+ /** @param {string[]} [ids] omit to mark everything seen @param {Deps} [deps] */
235
+ async function markSeen(ids, deps) {
236
+ const c = ctx(deps);
237
+ const only = Array.isArray(ids) ? new Set(ids) : null;
238
+ return mutateStore(c.storeFile, (items) =>
239
+ items.map((it) => (!only || only.has(it.id) ? { ...it, seenStatusAt: it.statusAt ?? null } : it)));
240
+ }
241
+
242
+ /** @param {FeedbackItem[]} items */
243
+ function unseenCount(items) {
244
+ return (items || []).filter((i) => i.statusAt != null && i.statusAt !== i.seenStatusAt).length;
245
+ }
246
+
247
+ module.exports = {
248
+ FEEDBACK_SLUG,
249
+ TAGS,
250
+ MAX_TITLE,
251
+ MAX_BODY,
252
+ resolveFeedbackBase,
253
+ toWireType,
254
+ fromWireType,
255
+ validateSubmission,
256
+ submit,
257
+ list,
258
+ refreshStatuses,
259
+ markSeen,
260
+ unseenCount,
261
+ };