@icebergsnow/patchwork-cli 0.0.0-stage → 0.2.1

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/lib/config.js ADDED
@@ -0,0 +1,139 @@
1
+ import { parse, stringify } from "smol-toml";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import { hookConfiguration } from "./connections.js";
4
+ const object = (x) => x && typeof x === "object" && !Array.isArray(x);
5
+ const normalize = (x) =>
6
+ Array.isArray(x)
7
+ ? x.map(normalize)
8
+ : object(x) &&
9
+ (Object.getPrototypeOf(x) === null ||
10
+ Object.getPrototypeOf(x) === Object.prototype)
11
+ ? Object.fromEntries(Object.entries(x).map(([k, v]) => [k, normalize(v)]))
12
+ : x;
13
+ const same = (a, b) => isDeepStrictEqual(normalize(a), normalize(b));
14
+ export function parseConfig(text) {
15
+ try {
16
+ return parse(text, { integersAsBigInt: true });
17
+ } catch {
18
+ throw new Error(
19
+ "Cannot parse Codex config.toml. Repair its syntax before connecting; no settings were replaced.",
20
+ );
21
+ }
22
+ }
23
+ export function parseJSON(text) {
24
+ let x;
25
+ try {
26
+ x = text.trim() ? JSON.parse(text) : {};
27
+ } catch {
28
+ throw new Error(
29
+ "Cannot parse agent JSON settings. Repair their syntax before connecting; no settings were replaced.",
30
+ );
31
+ }
32
+ if (!object(x)) throw new Error("Expected a JSON object in agent settings.");
33
+ return x;
34
+ }
35
+ export function mergeCodex(text, desired, previous, remove = false) {
36
+ const config = parseConfig(text);
37
+ if (config.mcp_servers !== undefined && !object(config.mcp_servers))
38
+ throw new Error("Invalid Codex MCP settings.");
39
+ const existing = config.mcp_servers?.patchwork;
40
+ if (existing && !same(existing, desired) && !same(existing, previous))
41
+ throw new Error(
42
+ "An unowned or modified Patchwork MCP entry exists. Preserve it and resolve this conflict before connecting.",
43
+ );
44
+ if (remove) {
45
+ if (!existing) return text;
46
+ delete config.mcp_servers.patchwork;
47
+ } else {
48
+ if (same(existing, desired)) return text;
49
+ config.mcp_servers ??= {};
50
+ config.mcp_servers.patchwork = desired;
51
+ }
52
+ if (!existing && !remove) {
53
+ const snippet = `\n[mcp_servers.patchwork]\ncommand = ${JSON.stringify(desired.command)}\nargs = ${JSON.stringify(desired.args)}\n`;
54
+ try {
55
+ const appended = text + snippet;
56
+ if (same(parseConfig(appended), config)) return appended;
57
+ } catch {}
58
+ }
59
+ const result = stringify(config, { numbersAsFloat: true });
60
+ if (!same(parseConfig(result), config))
61
+ throw new Error("Could not safely preserve Codex settings.");
62
+ return result;
63
+ }
64
+ export function mergeClaude(text, root, desired, previous, remove = false) {
65
+ const config = parseJSON(text);
66
+ if (config.projects !== undefined && !object(config.projects))
67
+ throw new Error("Invalid Claude project settings.");
68
+ const project = config.projects?.[root];
69
+ if (project !== undefined && !object(project))
70
+ throw new Error("Invalid Claude project settings.");
71
+ if (project?.mcpServers !== undefined && !object(project.mcpServers))
72
+ throw new Error("Invalid Claude MCP settings.");
73
+ const existing = project?.mcpServers?.patchwork;
74
+ if (existing && !same(existing, desired) && !same(existing, previous))
75
+ throw new Error(
76
+ "An unowned or modified Claude Patchwork entry exists. Resolve the conflict first.",
77
+ );
78
+ if (remove) {
79
+ if (!existing) return text;
80
+ delete project.mcpServers.patchwork;
81
+ } else {
82
+ if (same(existing, desired)) return text;
83
+ config.projects ??= {};
84
+ config.projects[root] ??= {};
85
+ config.projects[root].mcpServers ??= {};
86
+ config.projects[root].mcpServers.patchwork = desired;
87
+ }
88
+ return JSON.stringify(config, null, 2) + "\n";
89
+ }
90
+ const mcpHook = (h) =>
91
+ h?.type === "mcp_tool" &&
92
+ h.server === "patchwork" &&
93
+ h.tool === "check_board";
94
+ export function mergeHooks(
95
+ text,
96
+ entry,
97
+ file,
98
+ previous = [],
99
+ remove = false,
100
+ inline = {},
101
+ ) {
102
+ const config = parseJSON(text);
103
+ if (config.hooks !== undefined && !object(config.hooks))
104
+ throw new Error("Invalid hook settings.");
105
+ for (const groups of Object.values(inline))
106
+ if (Array.isArray(groups) && groups.some((g) => g.hooks?.some(mcpHook)))
107
+ throw new Error(
108
+ "Patchwork hooks also exist inline in Codex config. Resolve them before installing another source.",
109
+ );
110
+ const desired = hookConfiguration(entry, file).hooks;
111
+ const owned = [
112
+ ...previous,
113
+ ...Object.values(desired).flatMap((groups) =>
114
+ groups.flatMap((g) => g.hooks),
115
+ ),
116
+ ];
117
+ config.hooks ??= {};
118
+ for (const [event, groups] of Object.entries(config.hooks)) {
119
+ if (
120
+ !Array.isArray(groups) ||
121
+ groups.some((g) => !object(g) || !Array.isArray(g.hooks))
122
+ )
123
+ throw new Error(`Invalid ${event} hooks.`);
124
+ config.hooks[event] = groups.flatMap((group) => {
125
+ for (const h of group.hooks)
126
+ if (mcpHook(h) && !owned.some((x) => same(x, h)))
127
+ throw new Error(
128
+ "A modified Patchwork hook exists. Resolve it before changing settings.",
129
+ );
130
+ const hooks = group.hooks.filter((h) => !owned.some((x) => same(x, h)));
131
+ return hooks.length || !group.hooks.length ? [{ ...group, hooks }] : [];
132
+ });
133
+ }
134
+ if (!remove)
135
+ for (const [event, groups] of Object.entries(desired))
136
+ config.hooks[event] = [...(config.hooks[event] || []), ...groups];
137
+ if (same(parseJSON(text), config)) return text;
138
+ return JSON.stringify(config, null, 2) + "\n";
139
+ }
package/lib/connect.js ADDED
@@ -0,0 +1,508 @@
1
+ import path from "node:path";
2
+ import os from "node:os";
3
+ import { fileURLToPath } from "node:url";
4
+ import { realpath, unlink } from "node:fs/promises";
5
+ import { run, GitHub, repository } from "./github.js";
6
+ import {
7
+ createBoard,
8
+ findBoard,
9
+ register,
10
+ reconcileIntervention,
11
+ commentOnce,
12
+ } from "./board.js";
13
+ import {
14
+ connectionFile,
15
+ read,
16
+ readJSON,
17
+ write,
18
+ writeJSON,
19
+ locked,
20
+ directory,
21
+ digest,
22
+ } from "./store.js";
23
+ import { mergeCodex, mergeClaude, mergeHooks, parseConfig } from "./config.js";
24
+ import { hookConfiguration, serverConfiguration } from "./connections.js";
25
+ import { disabledFile, stateFile } from "./runtime.js";
26
+ import { withBoard, accountFile } from "./sync.js";
27
+ export const ENTRY = fileURLToPath(
28
+ new URL("../bin/patchwork.js", import.meta.url),
29
+ );
30
+ export async function projectRoot(cwd = process.cwd()) {
31
+ return realpath(
32
+ (await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"])).trim(),
33
+ );
34
+ }
35
+ export async function inferRepo(root) {
36
+ const remote = (
37
+ await run("git", ["-C", root, "remote", "get-url", "origin"])
38
+ ).trim();
39
+ const match = remote.match(
40
+ /^(?:git@github\.com:|https:\/\/github\.com\/)([^/]+\/[^/]+?)(?:\.git)?$/,
41
+ );
42
+ if (!match)
43
+ throw new Error("Specify --repo OWNER/REPO for a github.com repository.");
44
+ return repository(match[1]);
45
+ }
46
+ function settings(root, client, claudeFile) {
47
+ return client === "codex"
48
+ ? {
49
+ config: path.join(root, ".codex/config.toml"),
50
+ hooks: path.join(root, ".codex/hooks.json"),
51
+ }
52
+ : {
53
+ config: claudeFile || path.join(os.homedir(), ".claude.json"),
54
+ hooks: path.join(root, ".claude/settings.local.json"),
55
+ };
56
+ }
57
+ async function checkUntracked(root, file) {
58
+ const relative = path.relative(root, file);
59
+ if (relative.startsWith("..")) return;
60
+ if ((await run("git", ["-C", root, "ls-files", "--", relative])).trim())
61
+ throw new Error(
62
+ `Refusing to write machine-specific connection data into tracked file ${relative}. Keep this file local first.`,
63
+ );
64
+ }
65
+ async function ignoreLocal(root, files) {
66
+ const gitPath = (
67
+ await run("git", ["-C", root, "rev-parse", "--git-path", "info/exclude"])
68
+ ).trim();
69
+ const file = path.resolve(root, gitPath),
70
+ original = await read(file);
71
+ const paths = files
72
+ .map((f) => path.relative(root, f).split(path.sep).join("/"))
73
+ .filter((p) => !p.startsWith(".."));
74
+ const additions = paths
75
+ .map((p) => "/" + p)
76
+ .filter((p) => !original.split(/\r?\n/).includes(p));
77
+ if (additions.length)
78
+ await write(
79
+ file,
80
+ original +
81
+ (original && !original.endsWith("\n") ? "\n" : "") +
82
+ additions.join("\n") +
83
+ "\n",
84
+ );
85
+ }
86
+ export async function connect({
87
+ repo,
88
+ client,
89
+ name,
90
+ agent,
91
+ cwd,
92
+ create = false,
93
+ confirm = async () => false,
94
+ log = console.log,
95
+ github,
96
+ claudeFile,
97
+ entry = ENTRY,
98
+ } = {}) {
99
+ const root = await projectRoot(cwd);
100
+ if (!["codex", "claude"].includes(client))
101
+ throw new Error("Choose --client codex or --client claude.");
102
+ repo = repository(repo || (await inferRepo(root)));
103
+ const file = connectionFile(root, client);
104
+ return locked(file, async () => {
105
+ let existing = await readJSON(file);
106
+ // The disable marker also protects an in-progress installation. Retrying
107
+ // that installation must not reactivate remotely retired chats. Preserve
108
+ // explicit project-reconnect intent across an interrupted install instead.
109
+ const resuming =
110
+ !!existing &&
111
+ (!existing.enabled || (existing.installing && existing.resumeChats));
112
+ if (
113
+ agent &&
114
+ !Object.values((await readJSON(stateFile(file)))?.chats || {}).some(
115
+ (a) => a.id === agent,
116
+ )
117
+ )
118
+ throw new Error(
119
+ "No matching local chat. Use status --repo OWNER/REPO to find and retire a lost identity, then start a new chat.",
120
+ );
121
+ if (
122
+ existing &&
123
+ (existing.repo.toLowerCase() !== repo.toLowerCase() ||
124
+ existing.root !== root)
125
+ ) {
126
+ const previous = await readJSON(stateFile(file));
127
+ if (
128
+ existing.enabled ||
129
+ Object.values(previous?.chats || {}).some((a) => a.registered)
130
+ )
131
+ throw new Error(
132
+ "This project is connected elsewhere. Finish disconnecting it online before choosing another board.",
133
+ );
134
+ await writeJSON(stateFile(file), { chats: {} });
135
+ existing = null;
136
+ }
137
+ const files = settings(root, client, claudeFile);
138
+ for (const f of Object.values(files)) {
139
+ await directory(path.dirname(f));
140
+ await checkUntracked(root, f);
141
+ }
142
+ const before = {
143
+ config: await read(files.config),
144
+ hooks: await read(files.hooks),
145
+ };
146
+ const server = serverConfiguration(entry, file),
147
+ claudeServer = { type: "stdio", ...server };
148
+ const desired = client === "codex" ? server : claudeServer;
149
+ const previousServer = existing?.installing
150
+ ? existing.previousServer
151
+ : existing?.server;
152
+ const previousHooks = [
153
+ ...(existing?.ownedHooks || []),
154
+ ...(existing?.previousHooks || []),
155
+ ];
156
+ const nextConfig =
157
+ client === "codex"
158
+ ? mergeCodex(before.config, desired, previousServer)
159
+ : mergeClaude(before.config, root, desired, previousServer);
160
+ const nextHooks = mergeHooks(
161
+ before.hooks,
162
+ entry,
163
+ file,
164
+ previousHooks,
165
+ false,
166
+ client === "codex" ? parseConfig(before.config).hooks || {} : {},
167
+ );
168
+ const gh = github || new GitHub(repo);
169
+ const { user, repo: remote } = await gh.verify(existing || {});
170
+ let board = await findBoard(gh);
171
+ if (!board) {
172
+ if (
173
+ !create &&
174
+ !(await confirm(`Create the shared Patchwork board in ${repo}?`))
175
+ )
176
+ throw new Error(
177
+ "No board exists. Ask a teammate to create it, or rerun with --create-board.",
178
+ );
179
+ board = await createBoard(gh);
180
+ }
181
+ if (existing?.board && existing.board !== board.number)
182
+ throw new Error(
183
+ "The repository board changed. Review the new board before replacing this connection.",
184
+ );
185
+ const c = {
186
+ ...existing,
187
+ version: 1,
188
+ enabled: true,
189
+ installing: true,
190
+ resumeChats: !!resuming,
191
+ previousServer,
192
+ previousHooks,
193
+ root,
194
+ client,
195
+ repo,
196
+ repoId: remote.id,
197
+ ownerId: user.id,
198
+ login: user.login,
199
+ board: board.number,
200
+ boardUrl: board.html_url,
201
+ name: (name || existing?.name || `${user.login} / ${client}`).trim(),
202
+ files,
203
+ server: desired,
204
+ entry,
205
+ ownedHooks: Object.values(hookConfiguration(entry, file).hooks).flatMap(
206
+ (g) => g.flatMap((x) => x.hooks),
207
+ ),
208
+ };
209
+ if (!c.name || c.name.length > 60)
210
+ throw new Error("Use a connection name between 1 and 60 characters.");
211
+ await write(disabledFile(file), "installing\n");
212
+ await writeJSON(file, c);
213
+ await ignoreLocal(root, Object.values(files));
214
+ if (
215
+ (await read(files.config)) !== before.config ||
216
+ (await read(files.hooks)) !== before.hooks
217
+ )
218
+ throw new Error(
219
+ "Agent settings changed during installation. Rerun to merge the latest files.",
220
+ );
221
+ await checkUntracked(root, files.config);
222
+ await checkUntracked(root, files.hooks);
223
+ if (nextConfig !== before.config) await write(files.config, nextConfig);
224
+ if (nextHooks !== before.hooks) await write(files.hooks, nextHooks);
225
+ if (resuming || agent)
226
+ await locked(stateFile(file), async () => {
227
+ const sessions = await readJSON(stateFile(file));
228
+ for (const chat of Object.values(sessions?.chats || {})) {
229
+ if (
230
+ agent
231
+ ? chat.id !== agent
232
+ : !!(await read(disabledFile(file, chat.id)))
233
+ )
234
+ continue;
235
+ chat.registered = false;
236
+ chat.registrationOperation = crypto.randomUUID();
237
+ chat.retireOperation = null;
238
+ chat.retirementPending = false;
239
+ chat.retryAfter = 0;
240
+ chat.lastCheck = 0;
241
+ chat.contextHash = null;
242
+ // Keep seen/initialized: a resumed chat must receive missed messages.
243
+ }
244
+ if (sessions) await writeJSON(stateFile(file), sessions);
245
+ if (agent)
246
+ await unlink(disabledFile(file, agent)).catch((e) => {
247
+ if (e.code !== "ENOENT") throw e;
248
+ });
249
+ });
250
+ delete c.previousServer;
251
+ delete c.previousHooks;
252
+ delete c.resumeChats;
253
+ c.installing = false;
254
+ await writeJSON(file, c);
255
+ await unlink(disabledFile(file)).catch((e) => {
256
+ if (e.code !== "ENOENT") throw e;
257
+ });
258
+ log(`Connected ${client} for chats in ${root}.`);
259
+ log(`Board: ${board.html_url}`);
260
+ log(
261
+ "Restart/open a new chat and review required MCP/hook permissions. Status will show the first successful board check.",
262
+ );
263
+ return { file, connection: c };
264
+ });
265
+ }
266
+ async function remoteBoard(repo, github, fn) {
267
+ repo = repository(repo);
268
+ const gh = github || new GitHub(repo);
269
+ const { user, repo: remote } = await gh.verify();
270
+ const board = await findBoard(gh);
271
+ if (!board) throw new Error("No open Patchwork board in this repository.");
272
+ const c = { repo, repoId: remote.id, ownerId: user.id, board: board.number };
273
+ return withBoard(c, gh, async (sync) => {
274
+ await sync.poll(0);
275
+ return fn({ gh, sync, c, user });
276
+ });
277
+ }
278
+ export async function status({ cwd, client, repo, github } = {}) {
279
+ if (repo)
280
+ return remoteBoard(repo, github, async ({ sync }) => {
281
+ const state = await sync.view();
282
+ return {
283
+ repo,
284
+ board: state.board,
285
+ agents: state.agents,
286
+ warnings: state.warnings,
287
+ };
288
+ });
289
+ const root = await projectRoot(cwd),
290
+ results = [];
291
+ for (const app of client ? [client] : ["codex", "claude"]) {
292
+ const file = connectionFile(root, app),
293
+ c = await readJSON(file);
294
+ if (!c) continue;
295
+ const sessions = await readJSON(stateFile(file));
296
+ let configured = false;
297
+ try {
298
+ const config = await read(c.files.config),
299
+ hooks = await read(c.files.hooks);
300
+ configured =
301
+ (app === "codex"
302
+ ? mergeCodex(config, c.server, c.server)
303
+ : mergeClaude(config, root, c.server, c.server)) === config &&
304
+ mergeHooks(hooks, c.entry, file, c.ownedHooks) === hooks;
305
+ } catch {}
306
+ const usage = await readJSON(accountFile(c));
307
+ results.push({
308
+ apiUsage: usage
309
+ ? {
310
+ ...usage.stats,
311
+ cooldownUntil:
312
+ usage.retryAt > Date.now()
313
+ ? new Date(usage.retryAt).toISOString()
314
+ : null,
315
+ }
316
+ : null,
317
+ client: app,
318
+ project: root,
319
+ repo: c.repo,
320
+ board: c.boardUrl,
321
+ enabled: c.enabled && !(await read(disabledFile(file))),
322
+ chats: await Promise.all(
323
+ Object.values(sessions?.chats || {}).map(async (a) => ({
324
+ id: a.id,
325
+ name: a.name,
326
+ enabled:
327
+ c.enabled &&
328
+ !(await read(disabledFile(file))) &&
329
+ !(await read(disabledFile(file, a.id))),
330
+ lastBoardCheck: a.lastSuccess || null,
331
+ lastError: a.lastError || null,
332
+ pendingWrite: !!a.pending,
333
+ pendingRetirement: !!a.retirementPending,
334
+ })),
335
+ ),
336
+ configured,
337
+ });
338
+ }
339
+ return results;
340
+ }
341
+ export async function disconnect({
342
+ cwd,
343
+ client,
344
+ agent,
345
+ repo,
346
+ github,
347
+ log = console.log,
348
+ } = {}) {
349
+ if (repo) {
350
+ if (!agent)
351
+ throw new Error(
352
+ "Remote retirement requires --agent CHAT_ID. List IDs with status --repo OWNER/REPO.",
353
+ );
354
+ return remoteBoard(repo, github, async ({ gh, sync, c, user }) => {
355
+ const state = await sync.view(),
356
+ target = state.agents.find((a) => a.agent_id === agent);
357
+ if (!target)
358
+ throw new Error("No matching registered chat on this board.");
359
+ const authorize = async () => {
360
+ await sync.authorize();
361
+ if (target.owner_id !== user.id) {
362
+ const p = await gh.api(
363
+ "GET",
364
+ `/collaborators/${encodeURIComponent(user.login)}/permission`,
365
+ );
366
+ if (
367
+ !["admin", "maintain"].includes(p.permission) &&
368
+ !p.user?.permissions?.admin &&
369
+ !p.user?.permissions?.maintain
370
+ )
371
+ throw new Error(
372
+ "Only the chat's GitHub owner or a repository maintainer/admin can retire it.",
373
+ );
374
+ }
375
+ };
376
+ await authorize();
377
+ // The explicit command just checked current permissions; do not let an
378
+ // older cached role reject a newly granted maintainer's retirement.
379
+ delete sync.account.permissions[c.repoId]?.[`${user.id}:${user.login}`];
380
+ if (target.active) {
381
+ const result = await commentOnce(
382
+ gh,
383
+ c.board,
384
+ `${user.login} retired ${target.name} (${agent}).`,
385
+ {
386
+ type: "retire",
387
+ agent_id: agent,
388
+ owner_id: user.id,
389
+ operation_id: digest(
390
+ `retire:${agent}:${target.comment_id}:${user.id}`,
391
+ ),
392
+ },
393
+ authorize,
394
+ );
395
+ await sync.accept(result);
396
+ }
397
+ const updated = await sync.view();
398
+ if (updated.agents.find((a) => a.agent_id === agent)?.active)
399
+ throw new Error(
400
+ "Retirement could not be confirmed. Read the roster and retry.",
401
+ );
402
+ await reconcileIntervention(gh, updated, authorize, (r) =>
403
+ sync.labels(r),
404
+ );
405
+ log(
406
+ `Retired chat ${agent} on GitHub. Its next board check will require explicit reconnection.`,
407
+ );
408
+ });
409
+ }
410
+ const root = await projectRoot(cwd);
411
+ let found = false;
412
+ for (const app of client ? [client] : ["codex", "claude"]) {
413
+ const file = connectionFile(root, app);
414
+ let c = await readJSON(file);
415
+ if (!c) continue;
416
+ const original = await readJSON(stateFile(file));
417
+ if (
418
+ agent &&
419
+ !Object.values(original?.chats || {}).some((a) => a.id === agent)
420
+ )
421
+ continue;
422
+ found = true;
423
+ await write(disabledFile(file, agent), "disconnected\n");
424
+ await locked(file, async () => {
425
+ c = await readJSON(file);
426
+ await write(disabledFile(file, agent), "disconnected\n");
427
+ if (!agent) {
428
+ c.enabled = false;
429
+ await writeJSON(file, c);
430
+ try {
431
+ const before = {
432
+ config: await read(c.files.config),
433
+ hooks: await read(c.files.hooks),
434
+ };
435
+ const next =
436
+ app === "codex"
437
+ ? mergeCodex(before.config, c.server, c.server, true)
438
+ : mergeClaude(before.config, root, c.server, c.server, true);
439
+ const hooks = mergeHooks(
440
+ before.hooks,
441
+ c.entry,
442
+ file,
443
+ c.ownedHooks,
444
+ true,
445
+ );
446
+ if (next !== before.config) await write(c.files.config, next);
447
+ if (hooks !== before.hooks) await write(c.files.hooks, hooks);
448
+ } catch {
449
+ log(
450
+ "Disabled locally. Modified agent settings were preserved; remove the remaining Patchwork entries manually.",
451
+ );
452
+ }
453
+ }
454
+ await locked(stateFile(file), async () => {
455
+ const state = await readJSON(stateFile(file));
456
+ const chats = Object.values(state?.chats || {}).filter(
457
+ (a) => !agent || a.id === agent,
458
+ );
459
+ for (const chat of chats) {
460
+ chat.retireOperation ??= crypto.randomUUID();
461
+ chat.retirementPending = true;
462
+ }
463
+ if (state) await writeJSON(stateFile(file), state);
464
+ try {
465
+ const gh = github || new GitHub(c.repo);
466
+ if (chats.length)
467
+ await withBoard(c, gh, async (sync) => {
468
+ await sync.poll();
469
+ for (const chat of chats) {
470
+ const departed = await register(
471
+ gh,
472
+ c.board,
473
+ chat,
474
+ false,
475
+ chat.retireOperation,
476
+ () => sync.authorize(),
477
+ );
478
+ await sync.accept(departed);
479
+ chat.registered = false;
480
+ chat.retirementPending = false;
481
+ chat.registrationOperation = crypto.randomUUID();
482
+ await writeJSON(stateFile(file), state);
483
+ }
484
+ await reconcileIntervention(
485
+ gh,
486
+ await sync.view(),
487
+ () => sync.authorize(),
488
+ (request) => sync.labels(request),
489
+ );
490
+ });
491
+ } catch {
492
+ log(
493
+ "Local disconnect succeeded. GitHub roster cleanup is pending; rerun disconnect when connectivity/access returns.",
494
+ );
495
+ }
496
+ });
497
+ });
498
+ log(
499
+ agent
500
+ ? `Disconnected chat ${agent}.`
501
+ : `Disconnected ${app} for this project. Restart existing chats to unload tools. GitHub history and login are unchanged.`,
502
+ );
503
+ }
504
+ if (!found)
505
+ throw new Error(
506
+ "No matching local Patchwork connection or chat was found.",
507
+ );
508
+ }
@@ -0,0 +1,56 @@
1
+ // Encoded argv keeps startup paths with quotes/spaces safe on Windows and macOS.
2
+ export function startupCommand(entry, file) {
3
+ const args = Buffer.from(
4
+ JSON.stringify([entry, "hook", "--connection", file]),
5
+ ).toString("base64");
6
+ return `node -e "const r=require('node:child_process').spawnSync(process.execPath,JSON.parse(Buffer.from('${args}','base64').toString()),{stdio:'inherit'});process.exit(r.status===null?1:r.status)"`;
7
+ }
8
+ export function hookConfiguration(entry, file) {
9
+ return {
10
+ hooks: {
11
+ SessionStart: [
12
+ {
13
+ hooks: [
14
+ {
15
+ type: "command",
16
+ command: startupCommand(entry, file),
17
+ timeout: 60,
18
+ },
19
+ ],
20
+ },
21
+ ],
22
+ ...Object.fromEntries(
23
+ ["UserPromptSubmit", "PostToolUse"].map((event) => [
24
+ event,
25
+ [
26
+ {
27
+ ...(event === "PostToolUse"
28
+ ? {
29
+ matcher:
30
+ "^(Bash|PowerShell|apply_patch|Edit|Write|Read|Glob|Grep)$",
31
+ }
32
+ : {}),
33
+ hooks: [
34
+ {
35
+ type: "mcp_tool",
36
+ server: "patchwork",
37
+ tool: "check_board",
38
+ timeout: 60,
39
+ input: {
40
+ session_id: "${session_id}",
41
+ hook_event_name: "${hook_event_name}",
42
+ transcript_path: "${transcript_path}",
43
+ },
44
+ },
45
+ ],
46
+ },
47
+ ],
48
+ ]),
49
+ ),
50
+ },
51
+ };
52
+ }
53
+ export const serverConfiguration = (entry, file) => ({
54
+ command: process.execPath,
55
+ args: [entry, "mcp", "--connection", file],
56
+ });