claude-code-session-manager 0.105.0 → 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.
- package/dist/assets/{AgentLibrary-BDtr4oI-.js → AgentLibrary-CKOZzc2W.js} +1 -1
- package/dist/assets/{Button-CuXBCQDp.js → Button-yuqFpQsK.js} +1 -1
- package/dist/assets/{DataModel-DmvRipT5.js → DataModel-Bwdc_gaJ.js} +1 -1
- package/dist/assets/{History-B-v9pwwr.js → History-D1Z0lydo.js} +1 -1
- package/dist/assets/{Hooks-S_0oHHlr.js → Hooks-CyD6l4Is.js} +1 -1
- package/dist/assets/{Library-BcbGJxQ3.js → Library-fnod5cPe.js} +1 -1
- package/dist/assets/{ListDetail-DtqHqJHt.js → ListDetail-BKO68SJ4.js} +1 -1
- package/dist/assets/{MarkdownEditor-B56sPp_l.js → MarkdownEditor-DrYMnMBy.js} +1 -1
- package/dist/assets/{McpServers-D5VnmxAn.js → McpServers-CgfItWhF.js} +1 -1
- package/dist/assets/{Memory-0yGZLyRP.js → Memory-4M3gO3_r.js} +1 -1
- package/dist/assets/{Panel-NoAADJwx.js → Panel-CKbXo-LT.js} +1 -1
- package/dist/assets/{Permissions-D2Dh-ee8.js → Permissions-Bc9EpjXL.js} +1 -1
- package/dist/assets/{Plugins-kGoUgS9b.js → Plugins-B_-PlEIx.js} +2 -2
- package/dist/assets/{ProvenanceBadge-CHwDFTX7.js → ProvenanceBadge-Cxev7XSA.js} +1 -1
- package/dist/assets/{SaveBar-BtKNMIYK.js → SaveBar-DiqscG6q.js} +1 -1
- package/dist/assets/{Scheduler-B0_kMMDs.js → Scheduler-CCcJzuu8.js} +1 -1
- package/dist/assets/{ScopeSwitcher-BusohWZ5.js → ScopeSwitcher-BsM4-jHQ.js} +1 -1
- package/dist/assets/{Settings-DuRJntaE.js → Settings-C3-VL-T8.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-BHOdIpsy.js → SkillReferenceGraph-B9YlOuPo.js} +1 -1
- package/dist/assets/{Skills-DSTLZimI.js → Skills-Dtf-z_zN.js} +1 -1
- package/dist/assets/{SystemPrompt-BG7wFWdb.js → SystemPrompt-HJDn0E8C.js} +1 -1
- package/dist/assets/{TagLibrary-Bx27aE3_.js → TagLibrary-DLGOfauU.js} +1 -1
- package/dist/assets/{TiptapBody-j0O8J7YI.js → TiptapBody-Cdmv1CuM.js} +1 -1
- package/dist/assets/{Toggle-DzVZ6rC7.js → Toggle-CQFhOo1F.js} +1 -1
- package/dist/assets/{ViewTabs-BizjuynT.js → ViewTabs-CxjDCHjH.js} +1 -1
- package/dist/assets/index-Blo38vfX.js +3077 -0
- package/dist/assets/{index-BLVzrc4k.css → index-DkJv4l6L.css} +1 -1
- package/dist/assets/{settingsSchema-7MRAxaJk.js → settingsSchema-B0uoL_1R.js} +1 -1
- package/dist/assets/useAgentsDirWatch-e3TJUFPP.js +1 -0
- package/dist/assets/{useConfigDirWatch-ot0VYwQC.js → useConfigDirWatch-Dxv7JbCU.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/scheduler-mcp-server.cjs +44 -0
- 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 +39 -3
- package/src/main/ipcSchemas.cjs +27 -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/insideHome.cjs +1 -1
- package/src/main/lib/mcpToolCatalog.cjs +26 -1
- package/src/main/lib/projectFolder.cjs +89 -0
- package/src/main/lib/winUserData.cjs +22 -0
- package/src/main/scheduler.cjs +11 -9
- package/src/preload/api.d.ts +68 -0
- package/src/preload/index.cjs +12 -0
- 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 };
|
|
@@ -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),
|
|
@@ -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 };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Chromium resolves 'userData' via Windows known folders; with a stripped profile
|
|
2
|
+
// (no AppData) app.getPath('userData') throws "Failed to get 'userData' path" and the
|
|
3
|
+
// process dies 0x80000003. Fall back to an explicit %APPDATA%-based path.
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
function ensureWinUserDataPath({ app, env, platform, fs, os, log = () => {} }) {
|
|
7
|
+
if (platform !== 'win32') return false;
|
|
8
|
+
try {
|
|
9
|
+
app.getPath('userData');
|
|
10
|
+
return false;
|
|
11
|
+
} catch (err) {
|
|
12
|
+
const appData = (env && env.APPDATA) || path.win32.join(os.homedir(), 'AppData', 'Roaming');
|
|
13
|
+
const userData = path.win32.join(appData, 'Session Manager');
|
|
14
|
+
fs.mkdirSync(userData, { recursive: true });
|
|
15
|
+
app.setPath('appData', appData);
|
|
16
|
+
app.setPath('userData', userData);
|
|
17
|
+
log(`[win-userdata] getPath('userData') failed (${err && err.message}); using ${userData}`);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { ensureWinUserDataPath };
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -6322,7 +6322,15 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
|
|
|
6322
6322
|
|
|
6323
6323
|
// Distinct comm (`sm-claude-job`) + slug-labelled argv0 for System Monitor.
|
|
6324
6324
|
// Both keep the word `claude`, which the /\bclaude\b/ reaper gates need.
|
|
6325
|
-
const
|
|
6325
|
+
const jobArgs = buildClaudeSpawnArgs({
|
|
6326
|
+
prompt,
|
|
6327
|
+
model: personaResolution.model,
|
|
6328
|
+
effort: personaEffort,
|
|
6329
|
+
sessionId,
|
|
6330
|
+
resume: !!resumeTarget,
|
|
6331
|
+
systemPrompt: personaResolution.systemPrompt,
|
|
6332
|
+
});
|
|
6333
|
+
const jobSpawn = claudeSpawnTarget('job', job.slug, claudeBin, jobArgs);
|
|
6326
6334
|
|
|
6327
6335
|
const { child } = withChildAndLog({
|
|
6328
6336
|
fd,
|
|
@@ -6334,18 +6342,12 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
|
|
|
6334
6342
|
// Resume mode passes `--resume <sessionId>` (reconnect to the SAME
|
|
6335
6343
|
// session) INSTEAD of `--session-id <sessionId>` (mint a new one) —
|
|
6336
6344
|
// never both, see buildClaudeSpawnArgs.
|
|
6337
|
-
args:
|
|
6338
|
-
prompt,
|
|
6339
|
-
model: personaResolution.model,
|
|
6340
|
-
effort: personaEffort,
|
|
6341
|
-
sessionId,
|
|
6342
|
-
resume: !!resumeTarget,
|
|
6343
|
-
systemPrompt: personaResolution.systemPrompt,
|
|
6344
|
-
}),
|
|
6345
|
+
args: jobSpawn.args || jobArgs,
|
|
6345
6346
|
options: {
|
|
6346
6347
|
cwd: spawnCwd,
|
|
6347
6348
|
env: childEnv,
|
|
6348
6349
|
...(jobSpawn.argv0 ? { argv0: jobSpawn.argv0 } : {}),
|
|
6350
|
+
...(jobSpawn.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
|
|
6349
6351
|
// detached:true puts the child in its own process group so we can kill
|
|
6350
6352
|
// the entire descendant tree (including any stray background bashes the
|
|
6351
6353
|
// agent spawned) with killTree(pid). Without this, child.kill()
|
package/src/preload/api.d.ts
CHANGED
|
@@ -474,6 +474,44 @@ export interface OtelSetConfigResult {
|
|
|
474
474
|
status: OtelStatus;
|
|
475
475
|
}
|
|
476
476
|
|
|
477
|
+
// Customer feedback — client submissions + owner inbox. Shapes mirror
|
|
478
|
+
// src/main/lib/customerFeedbackClient.cjs and customerFeedbackInbox.cjs.
|
|
479
|
+
export type CustomerFeedbackTag = 'bug' | 'feature' | 'discussion';
|
|
480
|
+
export type CustomerFeedbackStatus = 'open' | 'in_progress' | 'resolved' | 'wontfix';
|
|
481
|
+
|
|
482
|
+
export interface CustomerFeedbackItem {
|
|
483
|
+
id: string;
|
|
484
|
+
receipt: string | null;
|
|
485
|
+
tag: CustomerFeedbackTag;
|
|
486
|
+
title: string;
|
|
487
|
+
body: string;
|
|
488
|
+
submittedAt: number;
|
|
489
|
+
status: CustomerFeedbackStatus;
|
|
490
|
+
statusNote: string | null;
|
|
491
|
+
statusAt: number | string | null;
|
|
492
|
+
seenStatusAt: number | string | null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export interface CustomerFeedbackInboxItem {
|
|
496
|
+
id: string;
|
|
497
|
+
receivedAt: string | number | null;
|
|
498
|
+
tag: CustomerFeedbackTag;
|
|
499
|
+
title: string;
|
|
500
|
+
body: string;
|
|
501
|
+
clientVersion: string | null;
|
|
502
|
+
clientPlatform: string | null;
|
|
503
|
+
moderation: string | null;
|
|
504
|
+
status: CustomerFeedbackStatus;
|
|
505
|
+
statusNote: string | null;
|
|
506
|
+
statusAt: string | number | null;
|
|
507
|
+
epicId: string | null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export interface CustomerFeedbackOwnerInfo {
|
|
511
|
+
ownerMode: boolean;
|
|
512
|
+
projectCwd: string | null;
|
|
513
|
+
}
|
|
514
|
+
|
|
477
515
|
// Product telemetry (bilko.run) — anonymous, on-by-default, opt-out. See
|
|
478
516
|
// session-manager-operations/architecture/telemetry.md for the data model.
|
|
479
517
|
export interface TelemetryConfig {
|
|
@@ -1540,6 +1578,10 @@ export interface ChatExternalSendEvent {
|
|
|
1540
1578
|
prompt: string;
|
|
1541
1579
|
}
|
|
1542
1580
|
|
|
1581
|
+
export type CreateProjectFolderResult =
|
|
1582
|
+
| { ok: true; path: string }
|
|
1583
|
+
| { ok: false; code: 'invalid-name' | 'invalid-parent' | 'exists' | 'io'; error: string; path?: string };
|
|
1584
|
+
|
|
1543
1585
|
export interface SessionManagerAPI {
|
|
1544
1586
|
app: {
|
|
1545
1587
|
version: () => Promise<string>;
|
|
@@ -1549,6 +1591,8 @@ export interface SessionManagerAPI {
|
|
|
1549
1591
|
cwd: () => Promise<string>;
|
|
1550
1592
|
engageRulesPath: () => Promise<string | null>;
|
|
1551
1593
|
pickDirectory: () => Promise<string | null>;
|
|
1594
|
+
/** Create `<parentDir>/<name>` for a new project; main validates the name. */
|
|
1595
|
+
createProjectFolder: (parentDir: string, name: string) => Promise<CreateProjectFolderResult>;
|
|
1552
1596
|
gitBranch: (cwd: string) => Promise<string | null>;
|
|
1553
1597
|
/** Default: request a drain-first restart. `force: true` restarts immediately, killing running jobs. */
|
|
1554
1598
|
rebootApp: (opts?: { force?: boolean }) => void;
|
|
@@ -1766,6 +1810,30 @@ export interface SessionManagerAPI {
|
|
|
1766
1810
|
status: () => Promise<OtelStatus>;
|
|
1767
1811
|
configPath: () => Promise<string>;
|
|
1768
1812
|
};
|
|
1813
|
+
customerFeedback: {
|
|
1814
|
+
submit: (p: { title: string; body: string; tag: CustomerFeedbackTag }) => Promise<
|
|
1815
|
+
{ ok: true; item: CustomerFeedbackItem } | { ok: false; error: string }
|
|
1816
|
+
>;
|
|
1817
|
+
list: () => Promise<CustomerFeedbackItem[]>;
|
|
1818
|
+
refreshStatus: () => Promise<
|
|
1819
|
+
{ ok: true; updated: number; unsupported?: true } | { ok: false; error: string }
|
|
1820
|
+
>;
|
|
1821
|
+
/** Omit `ids` to mark everything seen. Resolves to the updated items. */
|
|
1822
|
+
markSeen: (p: { ids?: string[] }) => Promise<CustomerFeedbackItem[]>;
|
|
1823
|
+
ownerInfo: () => Promise<CustomerFeedbackOwnerInfo>;
|
|
1824
|
+
inboxPull: () => Promise<
|
|
1825
|
+
{ ok: true; fetched: number; pages: number } | { ok: false; error: string }
|
|
1826
|
+
>;
|
|
1827
|
+
inboxList: (p: { includeHidden?: boolean }) => Promise<
|
|
1828
|
+
{ ok: true; items: CustomerFeedbackInboxItem[] } | { ok: false; error: string }
|
|
1829
|
+
>;
|
|
1830
|
+
inboxSetStatus: (p: { id: string; status: CustomerFeedbackStatus; note?: string }) => Promise<
|
|
1831
|
+
{ ok: true; item: CustomerFeedbackInboxItem | null } | { ok: false; error: string }
|
|
1832
|
+
>;
|
|
1833
|
+
inboxLinkEpic: (p: { id: string; epicId: string }) => Promise<
|
|
1834
|
+
{ ok: true } | { ok: false; error: string }
|
|
1835
|
+
>;
|
|
1836
|
+
};
|
|
1769
1837
|
telemetry: {
|
|
1770
1838
|
getConfig: () => Promise<TelemetryConfig>;
|
|
1771
1839
|
setConfig: (cfg: TelemetryConfig) => Promise<TelemetrySetConfigResult>;
|
package/src/preload/index.cjs
CHANGED
|
@@ -8,6 +8,7 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
8
8
|
cwd: () => ipcRenderer.invoke('app:cwd'),
|
|
9
9
|
engageRulesPath: () => ipcRenderer.invoke('app:engage-rules-path'),
|
|
10
10
|
pickDirectory: () => ipcRenderer.invoke('app:pick-directory'),
|
|
11
|
+
createProjectFolder: (parentDir, name) => ipcRenderer.invoke('app:create-project-folder', { parentDir, name }),
|
|
11
12
|
gitBranch: (cwd) => ipcRenderer.invoke('app:git-branch', { cwd }),
|
|
12
13
|
rebootApp: (opts) => ipcRenderer.send('app:reboot-app', opts),
|
|
13
14
|
archiveProject: (encoded) => ipcRenderer.invoke('app:archive-project', { encoded }),
|
|
@@ -213,6 +214,17 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
213
214
|
recentRecords: () => ipcRenderer.invoke('telemetry:recent-records'),
|
|
214
215
|
flushNow: () => ipcRenderer.invoke('telemetry:flush-now'),
|
|
215
216
|
},
|
|
217
|
+
customerFeedback: {
|
|
218
|
+
submit: (p) => ipcRenderer.invoke('customerFeedback:submit', p),
|
|
219
|
+
list: () => ipcRenderer.invoke('customerFeedback:list'),
|
|
220
|
+
refreshStatus: () => ipcRenderer.invoke('customerFeedback:refresh-status'),
|
|
221
|
+
markSeen: (p) => ipcRenderer.invoke('customerFeedback:mark-seen', p),
|
|
222
|
+
ownerInfo: () => ipcRenderer.invoke('customerFeedback:owner-info'),
|
|
223
|
+
inboxPull: () => ipcRenderer.invoke('customerFeedback:inbox-pull'),
|
|
224
|
+
inboxList: (p) => ipcRenderer.invoke('customerFeedback:inbox-list', p),
|
|
225
|
+
inboxSetStatus: (p) => ipcRenderer.invoke('customerFeedback:inbox-set-status', p),
|
|
226
|
+
inboxLinkEpic: (p) => ipcRenderer.invoke('customerFeedback:inbox-link-epic', p),
|
|
227
|
+
},
|
|
216
228
|
// Diagnostic only — no handler is registered unless the main process was
|
|
217
229
|
// launched with SM_HEAP_SNAPSHOT=1, so this rejects by default.
|
|
218
230
|
diagnostics: {
|