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
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* customerFeedbackInbox — OWNER side of customer feedback.
|
|
4
|
+
*
|
|
5
|
+
* With an owner token configured (env SM_FEEDBACK_OWNER_TOKEN or
|
|
6
|
+
* ~/.claude/session-manager/customer-feedback-owner.json) this machine incrementally pulls every
|
|
7
|
+
* submission for the session-manager slug from bilko.run into a local mirror, sets item status on
|
|
8
|
+
* the host, and records which local Epic an item became. No token = customer machine (ownerMode
|
|
9
|
+
* false). The token is only ever sent as a bearer header and never appears in errors or the mirror.
|
|
10
|
+
*/
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const fsp = require('node:fs/promises');
|
|
15
|
+
const atomicFs = require('./atomicFs.cjs');
|
|
16
|
+
const schedulerPaths = require('./schedulerPaths.cjs');
|
|
17
|
+
const { resolveFeedbackBase, fromWireType, FEEDBACK_SLUG } = require('./customerFeedbackClient.cjs');
|
|
18
|
+
|
|
19
|
+
const STATUSES = /** @type {const} */ (['open', 'in_progress', 'resolved', 'wontfix']);
|
|
20
|
+
const MAX_NOTE = 500;
|
|
21
|
+
const MAX_ITEMS = 2000;
|
|
22
|
+
const PAGE_LIMIT = 500;
|
|
23
|
+
const MAX_PAGES = 10;
|
|
24
|
+
const TIMEOUT_MS = 15000;
|
|
25
|
+
const SCHEMA_VERSION = 1;
|
|
26
|
+
const NO_TOKEN = 'Feedback owner token not configured';
|
|
27
|
+
const NO_STATUS_SUPPORT = 'Host does not support feedback status yet (or unknown id)';
|
|
28
|
+
const HIDDEN = new Set(['archived', 'deleted']);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {{ id: string, receivedAt: string|number|null, tag: string, title: string, body: string,
|
|
32
|
+
* clientVersion: string|null, clientPlatform: string|null, moderation: string|null, status: string,
|
|
33
|
+
* statusNote: string|null, statusAt: string|number|null, epicId: string|null }} InboxItem
|
|
34
|
+
* @typedef {{ items: InboxItem[], nextSince: string|null, nextModeratedSince: string|null }} Store
|
|
35
|
+
* @typedef {{ fetchImpl?: typeof fetch, storeFile?: string, ownerFile?: string,
|
|
36
|
+
* env?: Record<string, string|undefined>, now?: () => number }} Deps
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** @param {Deps} [deps] */
|
|
40
|
+
function ctx(deps) {
|
|
41
|
+
const d = deps || {};
|
|
42
|
+
return {
|
|
43
|
+
fetchImpl: d.fetchImpl || globalThis.fetch,
|
|
44
|
+
storeFile: d.storeFile || path.join(schedulerPaths.schedulerHome(), 'customer-feedback-inbox.json'),
|
|
45
|
+
ownerFile: d.ownerFile || path.join(schedulerPaths.schedulerHome(), 'customer-feedback-owner.json'),
|
|
46
|
+
env: d.env || process.env,
|
|
47
|
+
now: d.now || Date.now,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {Deps} [deps] @returns {{ token: string, projectCwd: string|null }|null} */
|
|
52
|
+
function loadOwnerConfig(deps) {
|
|
53
|
+
const c = ctx(deps);
|
|
54
|
+
const envToken = (c.env.SM_FEEDBACK_OWNER_TOKEN || '').trim();
|
|
55
|
+
if (envToken) return { token: envToken, projectCwd: c.env.SM_FEEDBACK_OWNER_PROJECT_CWD || null };
|
|
56
|
+
try {
|
|
57
|
+
const j = JSON.parse(require('node:fs').readFileSync(c.ownerFile, 'utf8'));
|
|
58
|
+
const token = j && typeof j.token === 'string' ? j.token.trim() : '';
|
|
59
|
+
if (token) return { token, projectCwd: typeof j.projectCwd === 'string' && j.projectCwd ? j.projectCwd : null };
|
|
60
|
+
} catch { /* missing or corrupt = not an owner */ }
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Never includes the token. @param {Deps} [deps] */
|
|
65
|
+
function getOwnerInfo(deps) {
|
|
66
|
+
const cfg = loadOwnerConfig(deps);
|
|
67
|
+
return { ownerMode: !!cfg, projectCwd: cfg ? cfg.projectCwd : null };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {string} file @returns {Promise<Store>} */
|
|
71
|
+
async function readStore(file) {
|
|
72
|
+
try {
|
|
73
|
+
const j = JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
74
|
+
if (j && Array.isArray(j.items)) {
|
|
75
|
+
return {
|
|
76
|
+
items: j.items.filter((/** @type {unknown} */ i) => i && typeof i === 'object'),
|
|
77
|
+
nextSince: typeof j.nextSince === 'string' ? j.nextSince : null,
|
|
78
|
+
nextModeratedSince: typeof j.nextModeratedSince === 'string' ? j.nextModeratedSince : null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
} catch { /* missing or corrupt reads as empty */ }
|
|
82
|
+
return { items: [], nextSince: null, nextModeratedSince: null };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let writeQueue = Promise.resolve();
|
|
86
|
+
/**
|
|
87
|
+
* Serialized read-modify-write over the mirror.
|
|
88
|
+
* @template T
|
|
89
|
+
* @param {string} file
|
|
90
|
+
* @param {(s: Store) => T} mutate mutates the store in place, returns a result
|
|
91
|
+
* @returns {Promise<T>}
|
|
92
|
+
*/
|
|
93
|
+
function mutateStore(file, mutate) {
|
|
94
|
+
const run = async () => {
|
|
95
|
+
const store = await readStore(file);
|
|
96
|
+
const result = mutate(store);
|
|
97
|
+
store.items = sortItems(store.items).slice(0, MAX_ITEMS);
|
|
98
|
+
await atomicFs.writeJsonAtomic(file, { schemaVersion: SCHEMA_VERSION, ...store }, { mode: 0o600 });
|
|
99
|
+
return result;
|
|
100
|
+
};
|
|
101
|
+
const tail = writeQueue.then(run, run);
|
|
102
|
+
writeQueue = tail.then(() => {}, () => {});
|
|
103
|
+
return tail;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @param {InboxItem[]} items */
|
|
107
|
+
function sortItems(items) {
|
|
108
|
+
const t = (/** @type {InboxItem} */ i) => (i.receivedAt == null ? 0 : (typeof i.receivedAt === 'number' ? i.receivedAt : Date.parse(i.receivedAt) || 0));
|
|
109
|
+
return items.slice().sort((a, b) => t(b) - t(a));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @param {string} s @param {string} token */
|
|
113
|
+
function redact(s, token) {
|
|
114
|
+
return token ? s.split(token).join('[redacted]') : s;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @param {unknown} e @param {string} token */
|
|
118
|
+
function netError(e, token) {
|
|
119
|
+
return `Could not reach the feedback service: ${redact(e instanceof Error ? e.message : String(e), token)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @param {Response} res @param {string} fallback */
|
|
123
|
+
async function errorText(res, fallback) {
|
|
124
|
+
try {
|
|
125
|
+
/** @type {any} */
|
|
126
|
+
const j = await res.json();
|
|
127
|
+
if (j && typeof j.error === 'string' && j.error) return j.error;
|
|
128
|
+
} catch { /* no body */ }
|
|
129
|
+
return fallback;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @param {number} status */
|
|
133
|
+
function httpError(status) {
|
|
134
|
+
if (status === 401) return 'Feedback owner token was rejected (401).';
|
|
135
|
+
if (status === 503) return 'Host has no feedback owner token configured (503).';
|
|
136
|
+
return `Feedback service returned ${status}.`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @param {any} w @param {InboxItem|undefined} prev @returns {InboxItem} */
|
|
140
|
+
function toItem(w, prev) {
|
|
141
|
+
const client = w.client && typeof w.client === 'object' ? w.client : {};
|
|
142
|
+
const st = w.status && typeof w.status === 'object' ? w.status : null;
|
|
143
|
+
return {
|
|
144
|
+
id: w.id,
|
|
145
|
+
receivedAt: w.receivedAt ?? null,
|
|
146
|
+
tag: fromWireType(String(w.type)),
|
|
147
|
+
title: String(w.title ?? ''),
|
|
148
|
+
body: String(w.description ?? ''),
|
|
149
|
+
clientVersion: typeof client.version === 'string' ? client.version : null,
|
|
150
|
+
clientPlatform: typeof client.platform === 'string' ? client.platform : null,
|
|
151
|
+
moderation: w.moderation && typeof w.moderation.action === 'string' ? w.moderation.action : null,
|
|
152
|
+
status: st && typeof st.value === 'string' ? st.value : 'open',
|
|
153
|
+
statusNote: st && typeof st.note === 'string' ? st.note : null,
|
|
154
|
+
statusAt: st ? (st.at ?? null) : null,
|
|
155
|
+
epicId: prev && prev.epicId ? prev.epicId : null,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {Deps} [deps]
|
|
161
|
+
* @returns {Promise<{ ok: true, fetched: number, pages: number } | { ok: false, error: string }>}
|
|
162
|
+
*/
|
|
163
|
+
async function pull(deps) {
|
|
164
|
+
const cfg = loadOwnerConfig(deps);
|
|
165
|
+
if (!cfg) return { ok: false, error: NO_TOKEN };
|
|
166
|
+
const c = ctx(deps);
|
|
167
|
+
const base = `${resolveFeedbackBase()}/api/projects/${FEEDBACK_SLUG}/feedback`;
|
|
168
|
+
const prior = await readStore(c.storeFile);
|
|
169
|
+
let since = prior.nextSince;
|
|
170
|
+
let moderatedSince = prior.nextModeratedSince;
|
|
171
|
+
/** @type {any[]} */
|
|
172
|
+
const fetched = [];
|
|
173
|
+
let pages = 0;
|
|
174
|
+
try {
|
|
175
|
+
for (; pages < MAX_PAGES;) {
|
|
176
|
+
const qs = new URLSearchParams();
|
|
177
|
+
if (since) qs.set('since', since);
|
|
178
|
+
if (moderatedSince) qs.set('moderatedSince', moderatedSince);
|
|
179
|
+
qs.set('images', 'none');
|
|
180
|
+
qs.set('limit', String(PAGE_LIMIT));
|
|
181
|
+
const res = await c.fetchImpl(`${base}?${qs.toString()}`, {
|
|
182
|
+
headers: { authorization: `Bearer ${cfg.token}` },
|
|
183
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
184
|
+
});
|
|
185
|
+
if (!res.ok) return { ok: false, error: await errorText(res, httpError(res.status)) };
|
|
186
|
+
/** @type {any} */
|
|
187
|
+
const j = await res.json().catch(() => null);
|
|
188
|
+
if (!j || !Array.isArray(j.items)) return { ok: false, error: 'Feedback service returned an unexpected response.' };
|
|
189
|
+
pages++;
|
|
190
|
+
fetched.push(...j.items.filter((/** @type {any} */ i) => i && typeof i.id === 'string' && i.id));
|
|
191
|
+
if (typeof j.nextSince === 'string') since = j.nextSince;
|
|
192
|
+
if (typeof j.nextModeratedSince === 'string') moderatedSince = j.nextModeratedSince;
|
|
193
|
+
if (j.items.length < PAGE_LIMIT) break;
|
|
194
|
+
}
|
|
195
|
+
} catch (e) {
|
|
196
|
+
return { ok: false, error: netError(e, cfg.token) };
|
|
197
|
+
}
|
|
198
|
+
const finalSince = since;
|
|
199
|
+
const finalModerated = moderatedSince;
|
|
200
|
+
await mutateStore(c.storeFile, (store) => {
|
|
201
|
+
const byId = new Map(store.items.map((i) => [i.id, i]));
|
|
202
|
+
for (const w of fetched) byId.set(w.id, toItem(w, byId.get(w.id)));
|
|
203
|
+
store.items = [...byId.values()];
|
|
204
|
+
store.nextSince = finalSince;
|
|
205
|
+
store.nextModeratedSince = finalModerated;
|
|
206
|
+
});
|
|
207
|
+
return { ok: true, fetched: fetched.length, pages };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @param {{ includeHidden?: boolean }} [opts] @param {Deps} [deps]
|
|
212
|
+
* @returns {Promise<{ ok: true, items: InboxItem[] } | { ok: false, error: string }>}
|
|
213
|
+
*/
|
|
214
|
+
async function list(opts, deps) {
|
|
215
|
+
if (!loadOwnerConfig(deps)) return { ok: false, error: NO_TOKEN };
|
|
216
|
+
const store = await readStore(ctx(deps).storeFile);
|
|
217
|
+
const includeHidden = !!(opts && opts.includeHidden);
|
|
218
|
+
const items = sortItems(store.items).filter((i) => includeHidden || !(i.moderation && HIDDEN.has(i.moderation)));
|
|
219
|
+
return { ok: true, items };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @param {string} id @param {string} status @param {string} [note] @param {Deps} [deps]
|
|
224
|
+
* @returns {Promise<{ ok: true, item: InboxItem|null } | { ok: false, error: string }>}
|
|
225
|
+
*/
|
|
226
|
+
async function setStatus(id, status, note, deps) {
|
|
227
|
+
const cfg = loadOwnerConfig(deps);
|
|
228
|
+
if (!cfg) return { ok: false, error: NO_TOKEN };
|
|
229
|
+
if (typeof id !== 'string' || !id) return { ok: false, error: 'Feedback id is required.' };
|
|
230
|
+
if (!STATUSES.includes(/** @type {any} */ (status))) return { ok: false, error: `Status must be one of: ${STATUSES.join(', ')}.` };
|
|
231
|
+
if (note !== undefined && note !== null && (typeof note !== 'string' || note.length > MAX_NOTE)) {
|
|
232
|
+
return { ok: false, error: `Note must be ${MAX_NOTE} characters or fewer.` };
|
|
233
|
+
}
|
|
234
|
+
const c = ctx(deps);
|
|
235
|
+
try {
|
|
236
|
+
const res = await c.fetchImpl(`${resolveFeedbackBase()}/api/projects/${FEEDBACK_SLUG}/feedback/${encodeURIComponent(id)}/status`, {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.token}` },
|
|
239
|
+
body: JSON.stringify(note ? { status, note } : { status }),
|
|
240
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
241
|
+
});
|
|
242
|
+
if (res.status === 404) return { ok: false, error: NO_STATUS_SUPPORT };
|
|
243
|
+
if (res.status !== 200) return { ok: false, error: await errorText(res, httpError(res.status)) };
|
|
244
|
+
/** @type {any} */
|
|
245
|
+
const j = await res.json().catch(() => null);
|
|
246
|
+
const statusAt = j && j.statusAt != null ? j.statusAt : c.now();
|
|
247
|
+
const item = await mutateStore(c.storeFile, (store) => {
|
|
248
|
+
const it = store.items.find((i) => i.id === id);
|
|
249
|
+
if (!it) return null;
|
|
250
|
+
it.status = status;
|
|
251
|
+
it.statusNote = note || null;
|
|
252
|
+
it.statusAt = statusAt;
|
|
253
|
+
return it;
|
|
254
|
+
});
|
|
255
|
+
return { ok: true, item };
|
|
256
|
+
} catch (e) {
|
|
257
|
+
return { ok: false, error: netError(e, cfg.token) };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Local-only: record which Epic an item was turned into.
|
|
263
|
+
* @param {string} id @param {string} epicId @param {Deps} [deps]
|
|
264
|
+
* @returns {Promise<{ ok: true } | { ok: false, error: string }>}
|
|
265
|
+
*/
|
|
266
|
+
async function linkEpic(id, epicId, deps) {
|
|
267
|
+
if (!loadOwnerConfig(deps)) return { ok: false, error: NO_TOKEN };
|
|
268
|
+
if (typeof epicId !== 'string' || !epicId) return { ok: false, error: 'Epic id is required.' };
|
|
269
|
+
const found = await mutateStore(ctx(deps).storeFile, (store) => {
|
|
270
|
+
const it = store.items.find((i) => i.id === id);
|
|
271
|
+
if (!it) return false;
|
|
272
|
+
it.epicId = epicId;
|
|
273
|
+
return true;
|
|
274
|
+
});
|
|
275
|
+
return found ? { ok: true } : { ok: false, error: 'Unknown feedback id.' };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = {
|
|
279
|
+
STATUSES,
|
|
280
|
+
loadOwnerConfig,
|
|
281
|
+
getOwnerInfo,
|
|
282
|
+
pull,
|
|
283
|
+
list,
|
|
284
|
+
setStatus,
|
|
285
|
+
linkEpic,
|
|
286
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const REFRESH_DELAY_MS = 30_000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Renderer-facing IPC for the customer-feedback client + owner inbox.
|
|
8
|
+
* Handlers return the libs' `{ ok, ... }` results unchanged.
|
|
9
|
+
* @param {{ ipcMain?: any, client?: any, inbox?: any, setTimeoutImpl?: typeof setTimeout }} [deps]
|
|
10
|
+
*/
|
|
11
|
+
function registerCustomerFeedbackHandlers(deps = {}) {
|
|
12
|
+
const ipcMain = deps.ipcMain || require('electron').ipcMain;
|
|
13
|
+
const { schemas: s, validated: v } = require('../ipcSchemas.cjs');
|
|
14
|
+
const client = deps.client || require('./customerFeedbackClient.cjs');
|
|
15
|
+
const inbox = deps.inbox || require('./customerFeedbackInbox.cjs');
|
|
16
|
+
const timer = deps.setTimeoutImpl || setTimeout;
|
|
17
|
+
|
|
18
|
+
ipcMain.handle('customerFeedback:submit', v(s.customerFeedbackSubmit, (p) => client.submit(p)));
|
|
19
|
+
ipcMain.handle('customerFeedback:list', () => client.list());
|
|
20
|
+
ipcMain.handle('customerFeedback:refresh-status', () => client.refreshStatuses());
|
|
21
|
+
ipcMain.handle('customerFeedback:mark-seen', v(s.customerFeedbackMarkSeen, ({ ids }) => client.markSeen(ids)));
|
|
22
|
+
ipcMain.handle('customerFeedback:owner-info', () => inbox.getOwnerInfo());
|
|
23
|
+
ipcMain.handle('customerFeedback:inbox-pull', () => inbox.pull());
|
|
24
|
+
ipcMain.handle('customerFeedback:inbox-list', v(s.customerFeedbackInboxList, ({ includeHidden }) => inbox.list({ includeHidden })));
|
|
25
|
+
ipcMain.handle('customerFeedback:inbox-set-status', v(s.customerFeedbackInboxSetStatus, ({ id, status, note }) => inbox.setStatus(id, status, note)));
|
|
26
|
+
ipcMain.handle('customerFeedback:inbox-link-epic', v(s.customerFeedbackInboxLinkEpic, ({ id, epicId }) => inbox.linkEpic(id, epicId)));
|
|
27
|
+
|
|
28
|
+
// One-shot deferred refresh so users see resolutions without opening anything.
|
|
29
|
+
try {
|
|
30
|
+
const t = timer(() => {
|
|
31
|
+
Promise.resolve()
|
|
32
|
+
.then(() => client.refreshStatuses())
|
|
33
|
+
.catch((/** @type {any} */ e) => {
|
|
34
|
+
console.warn('[customerFeedback] boot status refresh failed:', e && e.name ? e.name : 'error');
|
|
35
|
+
});
|
|
36
|
+
}, REFRESH_DELAY_MS);
|
|
37
|
+
if (t && typeof t.unref === 'function') t.unref();
|
|
38
|
+
} catch {
|
|
39
|
+
/* never throw at boot */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { registerCustomerFeedbackHandlers, REFRESH_DELAY_MS };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure E2E session state machine for the web-remote relay.
|
|
3
|
+
* No Electron, no I/O — importable in unit tests.
|
|
4
|
+
*
|
|
5
|
+
* State transitions:
|
|
6
|
+
* idle → pending_sas : successful deriveSessionKey + deriveSas
|
|
7
|
+
* idle → failed : crypto derivation error
|
|
8
|
+
* pending_sas → authenticated : user confirms SAS
|
|
9
|
+
* pending_sas → failed : deriveSas threw after sessionKey succeeded
|
|
10
|
+
* any → idle : disconnect / reset
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** @returns {{ state: string, sessionKey: Buffer|null, pendingSas: string|null }} */
|
|
14
|
+
function makeState(state = 'idle', sessionKey = null, pendingSas = null) {
|
|
15
|
+
return { state, sessionKey, pendingSas };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Attempt to confirm the SAS. Pure — does not mutate; returns the next state.
|
|
20
|
+
* @param {{ state: string, sessionKey: Buffer|null, pendingSas: string|null }} e2eState
|
|
21
|
+
* @returns {{ ok: boolean, error?: string, next: { state: string, sessionKey: Buffer|null, pendingSas: string|null } }}
|
|
22
|
+
*/
|
|
23
|
+
function confirmSas(e2eState) {
|
|
24
|
+
if (e2eState.state !== 'pending_sas') {
|
|
25
|
+
const errorMap = {
|
|
26
|
+
idle: 'no_e2e_session',
|
|
27
|
+
failed: 'e2e_failed',
|
|
28
|
+
authenticated: 'already_authenticated',
|
|
29
|
+
};
|
|
30
|
+
const error = errorMap[e2eState.state] ?? 'unexpected_state';
|
|
31
|
+
return { ok: false, error, next: e2eState };
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
ok: true,
|
|
35
|
+
next: makeState('authenticated', e2eState.sessionKey, null),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { makeState, confirmSas };
|
|
@@ -64,7 +64,7 @@ function isContainedWin32(target, home) {
|
|
|
64
64
|
|
|
65
65
|
function isContained(realPath, home = HOME_REAL, platform = process.platform) {
|
|
66
66
|
if (platform === 'win32') return isContainedWin32(realPath, home);
|
|
67
|
-
return realPath === home || realPath.startsWith(home + path.sep);
|
|
67
|
+
return realPath === home || realPath.startsWith(home + path.posix.sep);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
function assertInsideHome(p) {
|
|
@@ -23,7 +23,7 @@ const { z } = require('zod');
|
|
|
23
23
|
|
|
24
24
|
const CatalogEntrySchema = z.object({
|
|
25
25
|
name: z.string().min(1),
|
|
26
|
-
group: z.enum(['scheduler', 'chat', 'feedback', 'help', 'project-home', 'macros']),
|
|
26
|
+
group: z.enum(['scheduler', 'chat', 'feedback', 'help', 'project-home', 'macros', 'customer-feedback']),
|
|
27
27
|
purpose: z.string().min(1),
|
|
28
28
|
whenToUse: z.string().min(1),
|
|
29
29
|
whenNotToUse: z.string().min(1),
|
|
@@ -50,11 +50,11 @@ const MCP_TOOL_CATALOG = [
|
|
|
50
50
|
{
|
|
51
51
|
name: 'scheduler_pause',
|
|
52
52
|
group: 'scheduler',
|
|
53
|
-
purpose: "Pause
|
|
54
|
-
whenToUse: 'Use to stop
|
|
55
|
-
whenNotToUse: 'Do not use to cancel a running job (use scheduler_cancel_job) or as a workaround for a stuck queue.
|
|
56
|
-
exampleArgs: {},
|
|
57
|
-
notes: '
|
|
53
|
+
purpose: "Pause NEW job dispatch for every project on this machine (machine-wide, not per-repo) via the session-manager app's admin API.",
|
|
54
|
+
whenToUse: 'Use only when the human asks you to stop dispatch everywhere, or for a machine-wide emergency. Pass a `reason`. Running jobs are never killed.',
|
|
55
|
+
whenNotToUse: "NEVER use it to protect one repo's git/branch state or a single Epic's work — it freezes every project's scheduler. Do not use it to cancel a running job (use scheduler_cancel_job) or as a workaround for a stuck queue.",
|
|
56
|
+
exampleArgs: { reason: 'machine-wide emergency: disk full' },
|
|
57
|
+
notes: 'An agent pause auto-expires after 30 minutes, is cleared on app restart, and is audited with the caller (session id + cwd). A human pause outranks it and is only cleared by scheduler_resume.',
|
|
58
58
|
},
|
|
59
59
|
{
|
|
60
60
|
name: 'scheduler_resume',
|
|
@@ -293,6 +293,31 @@ const MCP_TOOL_CATALOG = [
|
|
|
293
293
|
notes: "cwd is optional — defaults to the calling session's own project root (SM_PROJECT_ROOT or process.cwd()) when omitted. "
|
|
294
294
|
+ 'The document is CSP-stamped after it is written, so the viewer sandbox is the real network fence — these checks are defence in depth.',
|
|
295
295
|
},
|
|
296
|
+
{
|
|
297
|
+
name: 'customer_feedback_list',
|
|
298
|
+
group: 'customer-feedback',
|
|
299
|
+
purpose: "List the customers' feedback in the owner's local inbox (bug / feature / discussion items submitted "
|
|
300
|
+
+ 'from the {F} feedback panel), optionally pulling new items from the feedback service first.',
|
|
301
|
+
whenToUse: 'Use to inspect what customers have submitted and each item\'s status. `pull` defaults to true '
|
|
302
|
+
+ '(fetch new items first); pass `includeHidden: true` to include moderated-away items.',
|
|
303
|
+
whenNotToUse: 'WHEN NOT TO USE IT: this only works on the owner\'s machine (it needs the owner token) and is read-only. '
|
|
304
|
+
+ 'Turning an item into an Epic is done by the human via Open as Epic in the {F} feedback panel — there is no tool '
|
|
305
|
+
+ 'for it. A finding that belongs to another local project uses feedback_open_session instead.',
|
|
306
|
+
exampleArgs: { pull: true, includeHidden: false },
|
|
307
|
+
notes: 'Returns { ok, items, pulled? }. Not-configured machines get { ok:false, error } as an error result.',
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
name: 'customer_feedback_set_status',
|
|
311
|
+
group: 'customer-feedback',
|
|
312
|
+
purpose: 'Set the status of one customer feedback item (open | in_progress | resolved | wontfix) with an optional '
|
|
313
|
+
+ 'note the customer sees, via the session-manager app\'s admin API.',
|
|
314
|
+
whenToUse: 'Use after inspecting an item with customer_feedback_list, to mark it in progress, resolved, or wontfix.',
|
|
315
|
+
whenNotToUse: 'WHEN NOT TO USE IT: this changes status only. Turning an item into an Epic is done by the human via '
|
|
316
|
+
+ 'Open as Epic in the {F} feedback panel — there is no tool for it. Cross-project findings use '
|
|
317
|
+
+ 'feedback_open_session. Never mark an item resolved unless the work has actually landed.',
|
|
318
|
+
exampleArgs: { id: 'fb_123', status: 'resolved', note: 'Fixed in 0.97.0' },
|
|
319
|
+
notes: 'The note is shown to the customer; keep it short (the inbox enforces a maximum length).',
|
|
320
|
+
},
|
|
296
321
|
{
|
|
297
322
|
name: 'macro_list',
|
|
298
323
|
group: 'macros',
|
|
@@ -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 };
|
|
@@ -137,6 +137,20 @@ function degradedBudget(lastGoodPayload, executorEvidence = {}) {
|
|
|
137
137
|
return { utilization, concurrencyCap };
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Budget while the METER (not the account) is rate-limited: a 429 from the
|
|
142
|
+
* usage endpoint says nothing about account usage, so a recent-enough cached
|
|
143
|
+
* payload is trusted as-is. Returns `{ utilization, degraded: false }` from
|
|
144
|
+
* the cache, or null (no/old cache) so the caller falls back to degradedBudget().
|
|
145
|
+
*/
|
|
146
|
+
function meterFailureBudget({ cachedPayload, staleSinceMs, now = Date.now(), maxStaleMs = 30 * 60 * 1000 } = {}) {
|
|
147
|
+
if (!cachedPayload || !Number.isFinite(staleSinceMs)) return null;
|
|
148
|
+
if (now - staleSinceMs > maxStaleMs) return null;
|
|
149
|
+
const window = bindingWindow(cachedPayload);
|
|
150
|
+
if (!Number.isFinite(window.utilization)) return null;
|
|
151
|
+
return { utilization: window.utilization, degraded: false, name: window.name };
|
|
152
|
+
}
|
|
153
|
+
|
|
140
154
|
/** Wraps `fn` so concurrent callers awaiting an in-flight call share one promise. */
|
|
141
155
|
function singleFlight(fn) {
|
|
142
156
|
let inFlight = null;
|
|
@@ -229,6 +243,7 @@ module.exports = {
|
|
|
229
243
|
isResetFresh,
|
|
230
244
|
bindingWindow,
|
|
231
245
|
degradedBudget,
|
|
246
|
+
meterFailureBudget,
|
|
232
247
|
degradedConcurrencyCap,
|
|
233
248
|
singleFlight,
|
|
234
249
|
};
|
|
@@ -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 };
|