claude-code-session-manager 0.105.0 → 0.107.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 (59) hide show
  1. package/dist/assets/{AgentLibrary-BDtr4oI-.js → AgentLibrary-Bjeq2-kp.js} +1 -1
  2. package/dist/assets/{Button-CuXBCQDp.js → Button-CRLhoI2K.js} +1 -1
  3. package/dist/assets/{DataModel-DmvRipT5.js → DataModel-iPJw5jcG.js} +1 -1
  4. package/dist/assets/{History-B-v9pwwr.js → History-DfPR3_6N.js} +1 -1
  5. package/dist/assets/{Hooks-S_0oHHlr.js → Hooks-Bw7H_DmC.js} +1 -1
  6. package/dist/assets/{Library-BcbGJxQ3.js → Library-rqaJJJ1b.js} +1 -1
  7. package/dist/assets/{ListDetail-DtqHqJHt.js → ListDetail-qI27OYfy.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-B56sPp_l.js → MarkdownEditor-BEFuHVNz.js} +1 -1
  9. package/dist/assets/{McpServers-D5VnmxAn.js → McpServers-Bz9O79rz.js} +1 -1
  10. package/dist/assets/{Memory-0yGZLyRP.js → Memory-DobxIoZ-.js} +1 -1
  11. package/dist/assets/{Panel-NoAADJwx.js → Panel-Cyt1wpUf.js} +1 -1
  12. package/dist/assets/{Permissions-D2Dh-ee8.js → Permissions-ChSNRUJJ.js} +1 -1
  13. package/dist/assets/{Plugins-kGoUgS9b.js → Plugins-CII221R2.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-CHwDFTX7.js → ProvenanceBadge-C_sk5-v3.js} +1 -1
  15. package/dist/assets/{SaveBar-BtKNMIYK.js → SaveBar-BuYrg5E9.js} +1 -1
  16. package/dist/assets/Scheduler-eixbMGEk.js +16 -0
  17. package/dist/assets/{ScopeSwitcher-BusohWZ5.js → ScopeSwitcher-BM0gLPhd.js} +1 -1
  18. package/dist/assets/Settings-qD-0W-Ut.js +4 -0
  19. package/dist/assets/{SkillReferenceGraph-BHOdIpsy.js → SkillReferenceGraph-D0Hgdfxq.js} +1 -1
  20. package/dist/assets/{Skills-DSTLZimI.js → Skills-C3T_2_2o.js} +1 -1
  21. package/dist/assets/{SystemPrompt-BG7wFWdb.js → SystemPrompt-B-xTy9Qn.js} +1 -1
  22. package/dist/assets/{TagLibrary-Bx27aE3_.js → TagLibrary-BH67tR5z.js} +1 -1
  23. package/dist/assets/{TiptapBody-j0O8J7YI.js → TiptapBody-CP_F_mwb.js} +1 -1
  24. package/dist/assets/{Toggle-DzVZ6rC7.js → Toggle-haRPHS5N.js} +1 -1
  25. package/dist/assets/{ViewTabs-BizjuynT.js → ViewTabs-BHV3MA5u.js} +1 -1
  26. package/dist/assets/{index-BLVzrc4k.css → index-tr1w3hHB.css} +1 -1
  27. package/dist/assets/index-v_3LvhMx.js +3077 -0
  28. package/dist/assets/{settingsSchema-7MRAxaJk.js → settingsSchema-CE46TA3V.js} +1 -1
  29. package/dist/assets/useAgentsDirWatch-BZTi2K2e.js +1 -0
  30. package/dist/assets/{useConfigDirWatch-ot0VYwQC.js → useConfigDirWatch-CjcswsWR.js} +1 -1
  31. package/dist/index.html +2 -2
  32. package/package.json +2 -1
  33. package/scripts/scheduler-mcp-server.cjs +58 -2
  34. package/src/main/build-info.json +4 -4
  35. package/src/main/chatRunner.cjs +4 -2
  36. package/src/main/crashDiagnostics.cjs +39 -3
  37. package/src/main/index.cjs +47 -3
  38. package/src/main/ipcSchemas.cjs +120 -0
  39. package/src/main/lib/claudeBin.cjs +22 -8
  40. package/src/main/lib/cleanEnv.cjs +6 -5
  41. package/src/main/lib/customerFeedbackAdminRoutes.cjs +94 -0
  42. package/src/main/lib/customerFeedbackClient.cjs +261 -0
  43. package/src/main/lib/customerFeedbackInbox.cjs +286 -0
  44. package/src/main/lib/customerFeedbackIpc.cjs +43 -0
  45. package/src/main/lib/e2eStateMachine.cjs +39 -0
  46. package/src/main/lib/insideHome.cjs +1 -1
  47. package/src/main/lib/mcpToolCatalog.cjs +31 -6
  48. package/src/main/lib/projectFolder.cjs +89 -0
  49. package/src/main/lib/usageCircuit.cjs +15 -0
  50. package/src/main/lib/winUserData.cjs +22 -0
  51. package/src/main/scheduler.cjs +68 -20
  52. package/src/main/usage.cjs +10 -2
  53. package/src/main/webRemote.cjs +1364 -0
  54. package/src/preload/api.d.ts +132 -1
  55. package/src/preload/index.cjs +51 -0
  56. package/dist/assets/Scheduler-B0_kMMDs.js +0 -16
  57. package/dist/assets/Settings-DuRJntaE.js +0 -3
  58. package/dist/assets/index-CLIaYljt.js +0 -3073
  59. package/dist/assets/useAgentsDirWatch-BUwfU2Kq.js +0 -1
@@ -32,10 +32,11 @@ function resolveClaudeBin(opts) {
32
32
  const env = (opts && opts.env) || process.env;
33
33
  const home = (opts && opts.homedir) || os.homedir();
34
34
  const accessSync = (opts && opts.accessSync) || fs.accessSync;
35
+ const P = platform === 'win32' ? path.win32 : path.posix;
35
36
  let candidates;
36
37
  let mode;
37
38
  if (platform === 'win32') {
38
- const w = path.win32;
39
+ const w = P;
39
40
  candidates = [];
40
41
  if (env.USERPROFILE) candidates.push(w.join(env.USERPROFILE, '.local', 'bin', 'claude.exe')); // native installer
41
42
  if (env.APPDATA) candidates.push(w.join(env.APPDATA, 'npm', 'claude.cmd'));
@@ -44,9 +45,9 @@ function resolveClaudeBin(opts) {
44
45
  } else {
45
46
  // Merged candidate list — was forked in scheduler vs pluginInstall before.
46
47
  candidates = [
47
- path.join(home, '.claude', 'local', 'claude'), // Claude Code bundled install
48
- path.join(home, '.local', 'bin', 'claude'), // user pip-style install
49
- path.join(home, '.npm-global', 'bin', 'claude'), // user npm-global
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
50
51
  '/usr/local/bin/claude',
51
52
  '/opt/homebrew/bin/claude',
52
53
  '/usr/bin/claude',
@@ -81,9 +82,22 @@ const ROLE_ALIASES = { job: 'sm-claude-job', chat: 'sm-claude-chat', aux: 'sm-cl
81
82
  * @param {'job'|'chat'|'aux'} role
82
83
  * @param {string} [detail] argv0 label detail (job slug / epic session id)
83
84
  * @param {string} [realBin] defaults to resolveClaudeBin()
84
- * @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
85
89
  */
86
- 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) {
87
101
  if (process.env.SM_CLAUDE_BIN) return { command: realBin };
88
102
  const alias = ROLE_ALIASES[role];
89
103
  if (!alias) return { command: realBin };
@@ -133,8 +147,8 @@ function probeClaudeVersion({ now = Date.now(), execFileImpl } = {}) {
133
147
  // it, and the binary must never be handed the caller's working tree —
134
148
  // a stub `claude` (tests point SM_CLAUDE_BIN at one) that writes
135
149
  // markers or commits into process.cwd() once did so in the real repo.
136
- const target = claudeSpawnTarget('aux', 'version', bin);
137
- 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) => {
138
152
  if (err) return done(null);
139
153
  const m = /(\d+\.\d+\.\d+[^\s]*)/.exec(String(stdout || ''));
140
154
  done(m ? m[1] : (String(stdout || '').trim() || null));
@@ -13,9 +13,10 @@ const SECRET_KEY_RE = /^(?:.*_)?(TOKEN|API_?KEY|SECRET|PASSWORD|AUTHORIZATION|CO
13
13
  */
14
14
  function userBinDirs(opts) {
15
15
  const platform = (opts && opts.platform) || process.platform;
16
+ const P = platform === 'win32' ? path.win32 : path.posix;
16
17
  if (platform === 'win32') {
17
18
  const env = (opts && opts.env) || process.env;
18
- const w = path.win32;
19
+ const w = P;
19
20
  const dirs = [];
20
21
  if (env.USERPROFILE) dirs.push(w.join(env.USERPROFILE, '.local', 'bin'));
21
22
  if (env.APPDATA) dirs.push(w.join(env.APPDATA, 'npm'));
@@ -27,9 +28,9 @@ function userBinDirs(opts) {
27
28
  }
28
29
  const home = (opts && opts.homedir) || os.homedir();
29
30
  return [
30
- path.join(home, '.claude', 'local'),
31
- path.join(home, '.local', 'bin'),
32
- 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'),
33
34
  '/opt/homebrew/bin',
34
35
  '/opt/homebrew/sbin',
35
36
  '/usr/local/bin',
@@ -48,7 +49,7 @@ function pathWithUserBins(opts) {
48
49
  const platform = (opts && opts.platform) || process.platform;
49
50
  const env = (opts && opts.env) || process.env;
50
51
  const win = platform === 'win32';
51
- const delim = win ? ';' : ':';
52
+ const delim = (win ? path.win32 : path.posix).delimiter;
52
53
  let base = env.PATH || '';
53
54
  if (win && !base) {
54
55
  const key = Object.keys(env).find((k) => k.toUpperCase() === 'PATH');
@@ -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
+ };