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.
- package/dist/assets/{AgentLibrary-BDtr4oI-.js → AgentLibrary-Bjeq2-kp.js} +1 -1
- package/dist/assets/{Button-CuXBCQDp.js → Button-CRLhoI2K.js} +1 -1
- package/dist/assets/{DataModel-DmvRipT5.js → DataModel-iPJw5jcG.js} +1 -1
- package/dist/assets/{History-B-v9pwwr.js → History-DfPR3_6N.js} +1 -1
- package/dist/assets/{Hooks-S_0oHHlr.js → Hooks-Bw7H_DmC.js} +1 -1
- package/dist/assets/{Library-BcbGJxQ3.js → Library-rqaJJJ1b.js} +1 -1
- package/dist/assets/{ListDetail-DtqHqJHt.js → ListDetail-qI27OYfy.js} +1 -1
- package/dist/assets/{MarkdownEditor-B56sPp_l.js → MarkdownEditor-BEFuHVNz.js} +1 -1
- package/dist/assets/{McpServers-D5VnmxAn.js → McpServers-Bz9O79rz.js} +1 -1
- package/dist/assets/{Memory-0yGZLyRP.js → Memory-DobxIoZ-.js} +1 -1
- package/dist/assets/{Panel-NoAADJwx.js → Panel-Cyt1wpUf.js} +1 -1
- package/dist/assets/{Permissions-D2Dh-ee8.js → Permissions-ChSNRUJJ.js} +1 -1
- package/dist/assets/{Plugins-kGoUgS9b.js → Plugins-CII221R2.js} +2 -2
- package/dist/assets/{ProvenanceBadge-CHwDFTX7.js → ProvenanceBadge-C_sk5-v3.js} +1 -1
- package/dist/assets/{SaveBar-BtKNMIYK.js → SaveBar-BuYrg5E9.js} +1 -1
- package/dist/assets/Scheduler-eixbMGEk.js +16 -0
- package/dist/assets/{ScopeSwitcher-BusohWZ5.js → ScopeSwitcher-BM0gLPhd.js} +1 -1
- package/dist/assets/Settings-qD-0W-Ut.js +4 -0
- package/dist/assets/{SkillReferenceGraph-BHOdIpsy.js → SkillReferenceGraph-D0Hgdfxq.js} +1 -1
- package/dist/assets/{Skills-DSTLZimI.js → Skills-C3T_2_2o.js} +1 -1
- package/dist/assets/{SystemPrompt-BG7wFWdb.js → SystemPrompt-B-xTy9Qn.js} +1 -1
- package/dist/assets/{TagLibrary-Bx27aE3_.js → TagLibrary-BH67tR5z.js} +1 -1
- package/dist/assets/{TiptapBody-j0O8J7YI.js → TiptapBody-CP_F_mwb.js} +1 -1
- package/dist/assets/{Toggle-DzVZ6rC7.js → Toggle-haRPHS5N.js} +1 -1
- package/dist/assets/{ViewTabs-BizjuynT.js → ViewTabs-BHV3MA5u.js} +1 -1
- package/dist/assets/{index-BLVzrc4k.css → index-tr1w3hHB.css} +1 -1
- package/dist/assets/index-v_3LvhMx.js +3077 -0
- package/dist/assets/{settingsSchema-7MRAxaJk.js → settingsSchema-CE46TA3V.js} +1 -1
- package/dist/assets/useAgentsDirWatch-BZTi2K2e.js +1 -0
- package/dist/assets/{useConfigDirWatch-ot0VYwQC.js → useConfigDirWatch-CjcswsWR.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/scripts/scheduler-mcp-server.cjs +58 -2
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +4 -2
- package/src/main/crashDiagnostics.cjs +39 -3
- package/src/main/index.cjs +47 -3
- package/src/main/ipcSchemas.cjs +120 -0
- package/src/main/lib/claudeBin.cjs +22 -8
- package/src/main/lib/cleanEnv.cjs +6 -5
- package/src/main/lib/customerFeedbackAdminRoutes.cjs +94 -0
- package/src/main/lib/customerFeedbackClient.cjs +261 -0
- package/src/main/lib/customerFeedbackInbox.cjs +286 -0
- package/src/main/lib/customerFeedbackIpc.cjs +43 -0
- package/src/main/lib/e2eStateMachine.cjs +39 -0
- package/src/main/lib/insideHome.cjs +1 -1
- package/src/main/lib/mcpToolCatalog.cjs +31 -6
- package/src/main/lib/projectFolder.cjs +89 -0
- package/src/main/lib/usageCircuit.cjs +15 -0
- package/src/main/lib/winUserData.cjs +22 -0
- package/src/main/scheduler.cjs +68 -20
- package/src/main/usage.cjs +10 -2
- package/src/main/webRemote.cjs +1364 -0
- package/src/preload/api.d.ts +132 -1
- package/src/preload/index.cjs +51 -0
- package/dist/assets/Scheduler-B0_kMMDs.js +0 -16
- package/dist/assets/Settings-DuRJntaE.js +0 -3
- package/dist/assets/index-CLIaYljt.js +0 -3073
- 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 =
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
* @
|
|
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 =
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
};
|