mtech-ai-local-runner 0.1.46 → 0.1.48
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/README.md +107 -2
- package/dist/src/adapters/claude-code/adapter.js +57 -36
- package/dist/src/adapters/claude-code/runtime-options.js +114 -33
- package/dist/src/asks/commands.js +100 -0
- package/dist/src/asks/doctor.js +106 -0
- package/dist/src/asks/hook-client.js +236 -0
- package/dist/src/asks/hub-protocol.js +97 -0
- package/dist/src/asks/hub.js +202 -0
- package/dist/src/asks/inbox.js +347 -0
- package/dist/src/asks/integrations.js +433 -0
- package/dist/src/asks/messenger.js +135 -0
- package/dist/src/asks/notifier.js +73 -0
- package/dist/src/asks/routing.js +239 -0
- package/dist/src/asks/runtime.js +198 -0
- package/dist/src/asks/shim.js +78 -0
- package/dist/src/asks/store.js +210 -0
- package/dist/src/asks/templates.js +44 -0
- package/dist/src/asks/types.js +64 -0
- package/dist/src/cli.js +90 -31
- package/dist/src/config.js +22 -2
- package/dist/src/daemon/runtime.js +36 -4
- package/dist/src/gateway-client.js +18 -3
- package/dist/src/registration/local-config.js +7 -1
- package/dist/src/safety/claude-binary-identity.js +81 -0
- package/dist/src/safety/local-edit-content.js +14 -0
- package/dist/src/safety/local-tool-policy.js +3 -0
- package/dist/src/safety/local-tool-server.js +2 -2
- package/dist/src/safety/local-tool-worker.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { chmod, copyFile, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { hookCommand, removeShim, writeShim } from "./shim.js";
|
|
7
|
+
import { APPS } from "./types.js";
|
|
8
|
+
/*
|
|
9
|
+
* Installing the AI Gateway asks hooks into the employee's own Claude Code and Codex (Work Item e1c08b33, spec
|
|
10
|
+
* section 8.4). Only on request: setup --integrations or integrations install. Setup never asks.
|
|
11
|
+
*
|
|
12
|
+
* Claude Code: a skills-directory plugin, ~/.claude/skills/mtec-ai-gateway-asks/ with .claude-plugin/plugin.json
|
|
13
|
+
* and hooks/hooks.json. It loads as mtec-ai-gateway-asks@skills-dir with no install step; settings.json is never
|
|
14
|
+
* touched. Codex: four handlers appended to ~/.codex/hooks.json after a backup of the file. Codex parses that file
|
|
15
|
+
* strictly (unknown top-level keys make it skip the whole file) and keys hook trust by position, so the handlers
|
|
16
|
+
* carry no extra fields, are appended after the person's own hooks, and are found again by their command: a
|
|
17
|
+
* quoted path ending in /mtec-ai-local-runner/bin/gateway-hook, then "codex" and the event. Every handler runs the
|
|
18
|
+
* stable shim only, so a runner upgrade changes no hook and Codex never asks for trust again.
|
|
19
|
+
*
|
|
20
|
+
* The apps' homes can move (CLAUDE_CONFIG_DIR, CODEX_HOME), but the LaunchAgent that runs the daemon gets only PATH.
|
|
21
|
+
* So every install adds the dir it used to a list per app in <stateRoot>/integrations.json, and every check and removal
|
|
22
|
+
* looks in the dir the current environment names and in every recorded one. A dir leaves the list only when nothing of
|
|
23
|
+
* this runner's is left in it, so a second install into another dir never orphans the first.
|
|
24
|
+
*/
|
|
25
|
+
export const CLAUDE_PLUGIN_NAME = "mtec-ai-gateway-asks";
|
|
26
|
+
const CLAUDE_HOOKS_DESCRIPTION = "AI Gateway asks from colleagues, shown by mtech-ai-local-runner. Remove with: npx mtech-ai-local-runner integrations remove claude";
|
|
27
|
+
const CODEX_FILE_DESCRIPTION = "Hooks written by mtech-ai-local-runner for AI Gateway asks.";
|
|
28
|
+
const RUNNER_CODEX_COMMAND = /^"[^"]*\/mtec-ai-local-runner\/bin\/gateway-hook" codex (session-start|prompt|stop|session-end)$/;
|
|
29
|
+
/** <stateRoot>/integrations.json: the app dirs that installs used, for the daemon, doctor, remove and uninstall. */
|
|
30
|
+
export const INTEGRATIONS_FILE = "integrations.json";
|
|
31
|
+
const RECORD_KEYS = { claude: "claude_config_dir", codex: "codex_home" };
|
|
32
|
+
/** The person's home: HOME, else the account's home directory. Never a relative path. */
|
|
33
|
+
function homeDirectory(env) {
|
|
34
|
+
let account = "";
|
|
35
|
+
try {
|
|
36
|
+
account = os.homedir();
|
|
37
|
+
}
|
|
38
|
+
catch { /* No home for this account either. */ }
|
|
39
|
+
for (const candidate of [env.HOME, account]) {
|
|
40
|
+
if (candidate && isAbsolute(candidate))
|
|
41
|
+
return candidate;
|
|
42
|
+
}
|
|
43
|
+
throw new Error("INTEGRATION_HOME_UNKNOWN: no absolute home directory was found. Set HOME, or CLAUDE_CONFIG_DIR and CODEX_HOME.");
|
|
44
|
+
}
|
|
45
|
+
/** CLAUDE_CONFIG_DIR moves Claude Code's home; the default is ~/.claude. */
|
|
46
|
+
export function claudeConfigDir(env) {
|
|
47
|
+
return env.CLAUDE_CONFIG_DIR ? resolve(env.CLAUDE_CONFIG_DIR) : join(homeDirectory(env), ".claude");
|
|
48
|
+
}
|
|
49
|
+
/** CODEX_HOME moves Codex's home; the default is ~/.codex. */
|
|
50
|
+
export function codexHome(env) {
|
|
51
|
+
return env.CODEX_HOME ? resolve(env.CODEX_HOME) : join(homeDirectory(env), ".codex");
|
|
52
|
+
}
|
|
53
|
+
const claudePluginDirIn = (configDir) => join(configDir, "skills", CLAUDE_PLUGIN_NAME);
|
|
54
|
+
const codexHooksFileIn = (home) => join(home, "hooks.json");
|
|
55
|
+
/**
|
|
56
|
+
* Where the current environment says the integrations live. Throws INTEGRATION_HOME_UNKNOWN when it names no home.
|
|
57
|
+
* It ignores <stateRoot>/integrations.json: callers that want what is installed must use integrationStatus or installedApps.
|
|
58
|
+
*/
|
|
59
|
+
export function integrationPaths(env) {
|
|
60
|
+
return { claudePluginDir: claudePluginDirIn(claudeConfigDir(env)), codexHooksFile: codexHooksFileIn(codexHome(env)) };
|
|
61
|
+
}
|
|
62
|
+
export function claudePluginManifest() {
|
|
63
|
+
return { name: CLAUDE_PLUGIN_NAME, version: "1.0.0", description: "Shows AI Gateway asks and answers from colleagues in this session.",
|
|
64
|
+
author: { name: "MTEC AI Gateway" } };
|
|
65
|
+
}
|
|
66
|
+
/** hooks/hooks.json of the Claude plugin. The Stop hook is the asyncRewake watcher, with a 3600 s timeout. */
|
|
67
|
+
export function claudeHooksFile(stateRoot) {
|
|
68
|
+
const handler = (event, extra = {}) => ({ type: "command", command: hookCommand(stateRoot, "claude", event), ...extra });
|
|
69
|
+
return { description: CLAUDE_HOOKS_DESCRIPTION, hooks: {
|
|
70
|
+
SessionStart: [{ hooks: [handler("session-start", { timeout: 10 })] }],
|
|
71
|
+
UserPromptSubmit: [{ hooks: [handler("prompt", { timeout: 10 })] }],
|
|
72
|
+
Stop: [{ hooks: [handler("stop", { asyncRewake: true, timeout: 3600 })] }],
|
|
73
|
+
SessionEnd: [{ hooks: [handler("session-end")] }],
|
|
74
|
+
} };
|
|
75
|
+
}
|
|
76
|
+
/** The four Codex matcher groups. Never change them: Codex trusts a hook by its hash, which covers these fields. */
|
|
77
|
+
export function codexHookGroups(stateRoot) {
|
|
78
|
+
const group = (event, timeout) => ({ hooks: [{ type: "command", command: hookCommand(stateRoot, "codex", event), timeout }] });
|
|
79
|
+
return { SessionStart: group("session-start", 10), UserPromptSubmit: group("prompt", 10), Stop: group("stop", 10), SessionEnd: group("session-end", 3) };
|
|
80
|
+
}
|
|
81
|
+
function isRunnerCodexHandler(handler) {
|
|
82
|
+
const command = handler?.command;
|
|
83
|
+
return typeof command === "string" && RUNNER_CODEX_COMMAND.test(command);
|
|
84
|
+
}
|
|
85
|
+
/** Codex's hooks.json as a plain object, refusing any shape this code would not edit safely. */
|
|
86
|
+
export function parseCodexHooks(raw) {
|
|
87
|
+
let value;
|
|
88
|
+
try {
|
|
89
|
+
value = JSON.parse(raw);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new Error("CODEX_HOOKS_JSON_INVALID");
|
|
93
|
+
}
|
|
94
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
95
|
+
throw new Error("CODEX_HOOKS_JSON_INVALID");
|
|
96
|
+
const hooks = value.hooks;
|
|
97
|
+
if (hooks !== undefined && (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)))
|
|
98
|
+
throw new Error("CODEX_HOOKS_JSON_INVALID");
|
|
99
|
+
for (const groups of Object.values(hooks ?? {})) {
|
|
100
|
+
if (!Array.isArray(groups) || groups.some(group => group === null || typeof group !== "object" || !Array.isArray(group.hooks))) {
|
|
101
|
+
throw new Error("CODEX_HOOKS_JSON_INVALID");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
/** This runner's handlers in a document, as one string per handler (its event and the handler), whichever state root wrote them. */
|
|
107
|
+
function runnerCodexEntries(document) {
|
|
108
|
+
return Object.entries(document.hooks ?? {}).flatMap(([event, groups]) => groups.flatMap(group => group.hooks.filter(isRunnerCodexHandler).map(handler => JSON.stringify({ event, handler }))));
|
|
109
|
+
}
|
|
110
|
+
/** True when the document holds exactly the four handlers this runner writes for this state root, wherever they sit. */
|
|
111
|
+
function hasCurrentRunnerCodexHooks(document, stateRoot) {
|
|
112
|
+
const ours = runnerCodexEntries(document);
|
|
113
|
+
const expected = Object.entries(codexHookGroups(stateRoot)).map(([event, group]) => JSON.stringify({ event, handler: group.hooks[0] }));
|
|
114
|
+
return ours.length === expected.length && expected.every(entry => ours.includes(entry));
|
|
115
|
+
}
|
|
116
|
+
/** The document without this runner's handlers. A group or event list left empty by that is dropped too. */
|
|
117
|
+
export function withoutRunnerCodexHooks(document) {
|
|
118
|
+
const hooks = {};
|
|
119
|
+
let changed = false;
|
|
120
|
+
for (const [event, groups] of Object.entries(document.hooks ?? {})) {
|
|
121
|
+
const kept = [];
|
|
122
|
+
for (const group of groups) {
|
|
123
|
+
const handlers = group.hooks.filter(handler => !isRunnerCodexHandler(handler));
|
|
124
|
+
if (handlers.length === group.hooks.length) {
|
|
125
|
+
kept.push(group);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
changed = true;
|
|
129
|
+
if (handlers.length > 0)
|
|
130
|
+
kept.push({ ...group, hooks: handlers });
|
|
131
|
+
}
|
|
132
|
+
if (kept.length > 0 || groups.length === 0)
|
|
133
|
+
hooks[event] = kept;
|
|
134
|
+
}
|
|
135
|
+
return { document: document.hooks === undefined ? document : { ...document, hooks }, changed };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The document with this runner's four groups appended after everything else, replacing any older copies.
|
|
139
|
+
* A document that already holds exactly the current four is left as it is: moving them behind hooks the person
|
|
140
|
+
* added since would move those hooks' trust keys.
|
|
141
|
+
*/
|
|
142
|
+
export function withRunnerCodexHooks(document, stateRoot) {
|
|
143
|
+
if (hasCurrentRunnerCodexHooks(document, stateRoot))
|
|
144
|
+
return { document, changed: false };
|
|
145
|
+
const stripped = withoutRunnerCodexHooks(document).document;
|
|
146
|
+
const hooks = { ...stripped.hooks ?? {} };
|
|
147
|
+
for (const [event, group] of Object.entries(codexHookGroups(stateRoot))) {
|
|
148
|
+
hooks[event] = [...hooks[event] ?? [], group];
|
|
149
|
+
}
|
|
150
|
+
const next = { ...stripped, hooks };
|
|
151
|
+
return { document: next, changed: JSON.stringify(next) !== JSON.stringify(document) };
|
|
152
|
+
}
|
|
153
|
+
async function readOptional(path) {
|
|
154
|
+
try {
|
|
155
|
+
return await readFile(path, "utf8");
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error.code === "ENOENT")
|
|
159
|
+
return null;
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function writeAtomic(path, content, mode) {
|
|
164
|
+
await mkdir(dirname(path), { recursive: true });
|
|
165
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
166
|
+
try {
|
|
167
|
+
await writeFile(temporary, content, { mode, flag: "wx" });
|
|
168
|
+
await chmod(temporary, mode);
|
|
169
|
+
await rename(temporary, path);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
await rm(temporary, { force: true });
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** Copies the Codex hooks file aside before it is changed. A backup is never overwritten. */
|
|
177
|
+
async function backup(path, now) {
|
|
178
|
+
const target = `${path}.mtec-ai-local-runner-${new Date(now).toISOString().replace(/[:.]/g, "-")}.bak`;
|
|
179
|
+
await copyFile(path, target, constants.COPYFILE_EXCL);
|
|
180
|
+
return target;
|
|
181
|
+
}
|
|
182
|
+
const pretty = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
183
|
+
/** 0600 temporary file, fsync, rename, directory fsync: the idiom of the ask store. */
|
|
184
|
+
async function writeDurable(path, content) {
|
|
185
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
186
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
187
|
+
try {
|
|
188
|
+
const file = await open(temporary, "wx", 0o600);
|
|
189
|
+
try {
|
|
190
|
+
await file.writeFile(content);
|
|
191
|
+
await file.sync();
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
await file.close().catch(() => undefined);
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
await file.close();
|
|
198
|
+
await rename(temporary, path);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
const directory = await open(dirname(path), "r");
|
|
205
|
+
try {
|
|
206
|
+
await directory.sync();
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
await directory.close();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The recorded dirs. A missing, unreadable or malformed record counts as no record, and a value that is not an absolute
|
|
214
|
+
* path is left out. A record written before the lists held one dir per app as a string: that is a list of one.
|
|
215
|
+
*/
|
|
216
|
+
async function readRecord(stateRoot) {
|
|
217
|
+
const record = { claude_config_dir: [], codex_home: [] };
|
|
218
|
+
let parsed;
|
|
219
|
+
try {
|
|
220
|
+
parsed = JSON.parse(await readFile(join(stateRoot, INTEGRATIONS_FILE), "utf8"));
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return record;
|
|
224
|
+
}
|
|
225
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
226
|
+
return record;
|
|
227
|
+
for (const key of [RECORD_KEYS.claude, RECORD_KEYS.codex]) {
|
|
228
|
+
const value = parsed[key];
|
|
229
|
+
const dirs = typeof value === "string" ? [value] : Array.isArray(value) ? value : [];
|
|
230
|
+
for (const dir of dirs) {
|
|
231
|
+
if (typeof dir === "string" && isAbsolute(dir) && !record[key].includes(dir))
|
|
232
|
+
record[key].push(dir);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return record;
|
|
236
|
+
}
|
|
237
|
+
/** Saves the record; an app with no dir left is left out, and the file is deleted when neither has one. */
|
|
238
|
+
async function saveRecord(stateRoot, record) {
|
|
239
|
+
const path = join(stateRoot, INTEGRATIONS_FILE);
|
|
240
|
+
if (record.claude_config_dir.length === 0 && record.codex_home.length === 0) {
|
|
241
|
+
await rm(path, { force: true });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
await writeDurable(path, pretty({
|
|
245
|
+
...(record.claude_config_dir.length === 0 ? {} : { claude_config_dir: record.claude_config_dir }),
|
|
246
|
+
...(record.codex_home.length === 0 ? {} : { codex_home: record.codex_home }),
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
/** Adds a dir to the app's list; one already there is not added again. */
|
|
250
|
+
async function recordDirectory(app, dir, stateRoot) {
|
|
251
|
+
const record = await readRecord(stateRoot);
|
|
252
|
+
const key = RECORD_KEYS[app];
|
|
253
|
+
if (record[key].includes(dir))
|
|
254
|
+
return;
|
|
255
|
+
await saveRecord(stateRoot, { ...record, [key]: [...record[key], dir] });
|
|
256
|
+
}
|
|
257
|
+
/** The plugin dir or hooks file of an app in one of its dirs. */
|
|
258
|
+
const placeIn = (app, dir) => (app === "claude" ? claudePluginDirIn(dir) : codexHooksFileIn(dir));
|
|
259
|
+
/** The plugin dir or hooks file in each place to look: the one the environment names first, then every recorded one. */
|
|
260
|
+
async function placesOf(app, env, stateRoot) {
|
|
261
|
+
const places = [placeIn(app, app === "claude" ? claudeConfigDir(env) : codexHome(env))];
|
|
262
|
+
for (const dir of (await readRecord(stateRoot))[RECORD_KEYS[app]]) {
|
|
263
|
+
const place = placeIn(app, dir);
|
|
264
|
+
if (!places.includes(place))
|
|
265
|
+
places.push(place);
|
|
266
|
+
}
|
|
267
|
+
return places;
|
|
268
|
+
}
|
|
269
|
+
async function claudeState(pluginDir, stateRoot) {
|
|
270
|
+
let manifest;
|
|
271
|
+
let hooks;
|
|
272
|
+
// Only a missing file means nothing is installed; any other read error leaves the state unknown.
|
|
273
|
+
try {
|
|
274
|
+
manifest = await readOptional(join(pluginDir, ".claude-plugin", "plugin.json"));
|
|
275
|
+
hooks = await readOptional(join(pluginDir, "hooks", "hooks.json"));
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return "unreadable";
|
|
279
|
+
}
|
|
280
|
+
if (manifest === null && hooks === null)
|
|
281
|
+
return "not_installed";
|
|
282
|
+
return manifest === pretty(claudePluginManifest()) && hooks === pretty(claudeHooksFile(stateRoot)) ? "installed" : "outdated";
|
|
283
|
+
}
|
|
284
|
+
async function codexState(hooksFile, stateRoot) {
|
|
285
|
+
let raw;
|
|
286
|
+
try {
|
|
287
|
+
raw = await readOptional(hooksFile);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return "unreadable";
|
|
291
|
+
}
|
|
292
|
+
if (raw === null)
|
|
293
|
+
return "not_installed";
|
|
294
|
+
let document;
|
|
295
|
+
try {
|
|
296
|
+
document = parseCodexHooks(raw);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return "unreadable";
|
|
300
|
+
}
|
|
301
|
+
if (runnerCodexEntries(document).length === 0)
|
|
302
|
+
return "not_installed";
|
|
303
|
+
return hasCurrentRunnerCodexHooks(document, stateRoot) ? "installed" : "outdated";
|
|
304
|
+
}
|
|
305
|
+
const STATE_ORDER = ["installed", "outdated", "unreadable", "not_installed"];
|
|
306
|
+
/**
|
|
307
|
+
* An app counts as installed when the dir the environment names or any recorded dir has this runner's hooks.
|
|
308
|
+
* The report names the place behind the strongest state, in the order installed, outdated, unreadable, not installed.
|
|
309
|
+
*/
|
|
310
|
+
export async function integrationStatus(app, env, stateRoot) {
|
|
311
|
+
const places = await placesOf(app, env, stateRoot);
|
|
312
|
+
const states = await Promise.all(places.map(place => app === "claude" ? claudeState(place, stateRoot) : codexState(place, stateRoot)));
|
|
313
|
+
for (const state of STATE_ORDER) {
|
|
314
|
+
const index = states.indexOf(state);
|
|
315
|
+
if (index >= 0)
|
|
316
|
+
return { app, state, path: places[index] };
|
|
317
|
+
}
|
|
318
|
+
return { app, state: "not_installed", path: places[0] };
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Installs one app's hooks in the dir the environment names. An app already installed there exactly as this runner
|
|
322
|
+
* writes it is skipped and reported installed: its files are not rewritten. The shim (a no-op when it already runs this
|
|
323
|
+
* release) and the record of the dir are still made sure of, so the hooks there keep working.
|
|
324
|
+
*/
|
|
325
|
+
export async function installIntegration(app, env, stateRoot, options = {}) {
|
|
326
|
+
const shim = () => writeShim({ stateRoot, nodePath: options.nodePath, clientPath: options.clientPath });
|
|
327
|
+
if (app === "claude") {
|
|
328
|
+
// A home that cannot be found throws here, before anything is written.
|
|
329
|
+
const configDir = claudeConfigDir(env);
|
|
330
|
+
const pluginDir = claudePluginDirIn(configDir);
|
|
331
|
+
await shim();
|
|
332
|
+
// The dir is recorded before the hooks are written, so the daemon never misses hooks that exist.
|
|
333
|
+
await recordDirectory("claude", configDir, stateRoot);
|
|
334
|
+
if (await claudeState(pluginDir, stateRoot) === "installed")
|
|
335
|
+
return { app, state: "installed", path: pluginDir };
|
|
336
|
+
await writeAtomic(join(pluginDir, ".claude-plugin", "plugin.json"), pretty(claudePluginManifest()), 0o644);
|
|
337
|
+
await writeAtomic(join(pluginDir, "hooks", "hooks.json"), pretty(claudeHooksFile(stateRoot)), 0o644);
|
|
338
|
+
return { app, state: "installed", path: pluginDir };
|
|
339
|
+
}
|
|
340
|
+
const home = codexHome(env);
|
|
341
|
+
const hooksFile = codexHooksFileIn(home);
|
|
342
|
+
// The Codex file is read and checked before anything is written, so one that does not parse leaves no shim behind either.
|
|
343
|
+
const raw = await readOptional(hooksFile);
|
|
344
|
+
const current = raw === null ? { description: CODEX_FILE_DESCRIPTION } : parseCodexHooks(raw);
|
|
345
|
+
const { document, changed } = withRunnerCodexHooks(current, stateRoot);
|
|
346
|
+
await shim();
|
|
347
|
+
await recordDirectory("codex", home, stateRoot);
|
|
348
|
+
if (!changed)
|
|
349
|
+
return { app, state: "installed", path: hooksFile };
|
|
350
|
+
const saved = raw === null ? undefined : await backup(hooksFile, options.now ?? Date.now());
|
|
351
|
+
const mode = raw === null ? 0o600 : (await stat(hooksFile)).mode & 0o777;
|
|
352
|
+
await writeAtomic(hooksFile, pretty(document), mode);
|
|
353
|
+
return { app, state: "installed", path: hooksFile, ...(saved ? { backup: saved } : {}) };
|
|
354
|
+
}
|
|
355
|
+
async function removeClaudeAt(pluginDir) {
|
|
356
|
+
const manifest = await readOptional(join(pluginDir, ".claude-plugin", "plugin.json"));
|
|
357
|
+
// Only a directory this runner wrote is removed.
|
|
358
|
+
if (manifest === null || JSON.parse(manifest).name !== CLAUDE_PLUGIN_NAME)
|
|
359
|
+
return { changed: false };
|
|
360
|
+
await rm(pluginDir, { recursive: true, force: true });
|
|
361
|
+
return { changed: true };
|
|
362
|
+
}
|
|
363
|
+
async function removeCodexAt(hooksFile, now) {
|
|
364
|
+
const raw = await readOptional(hooksFile);
|
|
365
|
+
if (raw === null)
|
|
366
|
+
return { changed: false };
|
|
367
|
+
const { document, changed } = withoutRunnerCodexHooks(parseCodexHooks(raw));
|
|
368
|
+
if (!changed)
|
|
369
|
+
return { changed: false };
|
|
370
|
+
const saved = await backup(hooksFile, now);
|
|
371
|
+
if (Object.keys(document.hooks ?? {}).length === 0 && document.description === CODEX_FILE_DESCRIPTION && Object.keys(document).length === 2) {
|
|
372
|
+
await rm(hooksFile, { force: true });
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
await writeAtomic(hooksFile, pretty(document), (await stat(hooksFile)).mode & 0o777);
|
|
376
|
+
}
|
|
377
|
+
return { changed: true, backup: saved };
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Removes this runner's hooks from the dir the environment names and from every recorded one, then drops from the
|
|
381
|
+
* record each dir where nothing of ours is left. A place that fails does not stop the others, and its dir stays
|
|
382
|
+
* recorded; the first failure is thrown at the end.
|
|
383
|
+
*/
|
|
384
|
+
export async function removeIntegration(app, env, stateRoot, options = {}) {
|
|
385
|
+
const places = await placesOf(app, env, stateRoot);
|
|
386
|
+
const now = options.now ?? Date.now();
|
|
387
|
+
const cleaned = [];
|
|
388
|
+
let failure;
|
|
389
|
+
for (const place of places) {
|
|
390
|
+
try {
|
|
391
|
+
const result = app === "claude" ? await removeClaudeAt(place) : await removeCodexAt(place, now);
|
|
392
|
+
if (result.changed)
|
|
393
|
+
cleaned.push({ path: place, ...(result.backup ? { backup: result.backup } : {}) });
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
failure ??= error;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
const record = await readRecord(stateRoot);
|
|
401
|
+
const key = RECORD_KEYS[app];
|
|
402
|
+
const kept = [];
|
|
403
|
+
for (const dir of record[key]) {
|
|
404
|
+
const place = placeIn(app, dir);
|
|
405
|
+
// A dir leaves the record only when nothing of this runner's is left there.
|
|
406
|
+
if ((app === "claude" ? await claudeState(place, stateRoot) : await codexState(place, stateRoot)) !== "not_installed")
|
|
407
|
+
kept.push(dir);
|
|
408
|
+
}
|
|
409
|
+
if (kept.length !== record[key].length)
|
|
410
|
+
await saveRecord(stateRoot, { ...record, [key]: kept });
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
failure ??= error;
|
|
414
|
+
}
|
|
415
|
+
if (failure !== undefined)
|
|
416
|
+
throw failure;
|
|
417
|
+
const [first, ...others] = cleaned;
|
|
418
|
+
return { app, state: (await integrationStatus(app, env, stateRoot)).state, path: first?.path ?? places[0],
|
|
419
|
+
...(first?.backup ? { backup: first.backup } : {}), ...(others.length > 0 ? { alsoCleaned: others } : {}) };
|
|
420
|
+
}
|
|
421
|
+
/** ask_inbox.apps: the integrations installed exactly as this runner writes them. */
|
|
422
|
+
export async function installedApps(env, stateRoot) {
|
|
423
|
+
const reports = await Promise.all(APPS.map(app => integrationStatus(app, env, stateRoot)));
|
|
424
|
+
return reports.filter(report => report.state === "installed").map(report => report.app);
|
|
425
|
+
}
|
|
426
|
+
/** Removes the shim once no integration uses it any more: no place the environment names or the record lists has our hooks. */
|
|
427
|
+
export async function removeShimIfUnused(env, stateRoot) {
|
|
428
|
+
const reports = await Promise.all(APPS.map(app => integrationStatus(app, env, stateRoot)));
|
|
429
|
+
if (reports.some(report => report.state !== "not_installed"))
|
|
430
|
+
return false;
|
|
431
|
+
await removeShim(stateRoot);
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { McpGatewayClient } from "../gateway-client.js";
|
|
4
|
+
import { Daemon } from "../daemon/daemon.js";
|
|
5
|
+
import { ProcessLock } from "../daemon/process-lock.js";
|
|
6
|
+
import { runnerDisplayName } from "../daemon/runtime.js";
|
|
7
|
+
import { SessionOpener, sessionOpenAdvertisement } from "../daemon/session-opener.js";
|
|
8
|
+
import { SessionOpenPoller, abortableDelay } from "../daemon/session-open-poller.js";
|
|
9
|
+
import { packageVersion } from "../package-version.js";
|
|
10
|
+
import { AskRuntime, withAsks } from "./runtime.js";
|
|
11
|
+
/*
|
|
12
|
+
* Messenger mode (Work Item e1c08b33, spec section 8.1). Setup chose it because no executor is admitted on
|
|
13
|
+
* this Mac. The daemon runs the session-open loop only: there is no jobs loop, no adapter probe, no PreToolUse
|
|
14
|
+
* self-test and no executor host, so nothing built here can start an executor.
|
|
15
|
+
*/
|
|
16
|
+
/** A messenger has no jobs loop, so its session-open loop sends the heartbeat: at daemon start, then every 600 s. */
|
|
17
|
+
export const MESSENGER_HEARTBEAT_MS = 600_000;
|
|
18
|
+
const HEARTBEAT_RETRY_MS = 60_000;
|
|
19
|
+
/**
|
|
20
|
+
* The waits before resending a heartbeat that had to leave session_open out, one after another: 60, 120, 240 s, then the
|
|
21
|
+
* regular 600 s for as long as session-open detection keeps failing. A heartbeat that carries session_open resets it.
|
|
22
|
+
* Retrying every minute would cost about ten times the heartbeat calls of an idle runner.
|
|
23
|
+
*/
|
|
24
|
+
export const PARTIAL_HEARTBEAT_RETRY_MS = [60_000, 120_000, 240_000, MESSENGER_HEARTBEAT_MS];
|
|
25
|
+
/** Stands in for the job poller: it keeps the daemon alive until stop() and never asks for a job. */
|
|
26
|
+
export function idleJobPoller() {
|
|
27
|
+
let release;
|
|
28
|
+
const stopped = new Promise(resolve => { release = resolve; });
|
|
29
|
+
return {
|
|
30
|
+
async run() {
|
|
31
|
+
// A pending promise alone does not keep Node alive; this referenced timer does until stop().
|
|
32
|
+
const timer = setInterval(() => undefined, 60_000);
|
|
33
|
+
try {
|
|
34
|
+
await stopped;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
clearInterval(timer);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
stop() { release(); },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Sends the heartbeat when it is due: at once, then every 600 s, and also as soon as what it advertises changes
|
|
45
|
+
* (an integration installed or removed). A failed one is retried a minute later. One that went out without session_open
|
|
46
|
+
* (send resolves false) is retried after PARTIAL_HEARTBEAT_RETRY_MS: the Gateway then keeps the session_open, and the
|
|
47
|
+
* ask_inbox, it stored last, so what this beat would have advertised does not count as sent.
|
|
48
|
+
*/
|
|
49
|
+
export function messengerHeartbeat(send, options = {}) {
|
|
50
|
+
const now = options.now ?? Date.now;
|
|
51
|
+
let dueAt = Number.NEGATIVE_INFINITY;
|
|
52
|
+
/** What the Gateway was last told; unset until a beat carried session_open. Unset, only the time decides. */
|
|
53
|
+
let sentKey;
|
|
54
|
+
/** Heartbeats in a row that had to leave session_open out. */
|
|
55
|
+
let partials = 0;
|
|
56
|
+
return async (signal) => {
|
|
57
|
+
let key = sentKey ?? "";
|
|
58
|
+
try {
|
|
59
|
+
key = options.advertised ? await options.advertised() : "";
|
|
60
|
+
}
|
|
61
|
+
catch { /* Unchanged as far as we know. */ }
|
|
62
|
+
if (now() < dueAt && (sentKey === undefined || key === sentKey))
|
|
63
|
+
return;
|
|
64
|
+
sentKey = key;
|
|
65
|
+
try {
|
|
66
|
+
if (await send(signal) === false) {
|
|
67
|
+
sentKey = undefined;
|
|
68
|
+
dueAt = now() + PARTIAL_HEARTBEAT_RETRY_MS[Math.min(partials, PARTIAL_HEARTBEAT_RETRY_MS.length - 1)];
|
|
69
|
+
partials += 1;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
partials = 0;
|
|
73
|
+
dueAt = now() + MESSENGER_HEARTBEAT_MS;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
dueAt = now() + HEARTBEAT_RETRY_MS;
|
|
78
|
+
const code = error?.code;
|
|
79
|
+
if (!signal.aborted)
|
|
80
|
+
options.reportError?.(typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code) ? code : "UNKNOWN");
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export async function buildMessengerDaemon(input) {
|
|
85
|
+
const log = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); };
|
|
86
|
+
const home = input.env.HOME ?? "";
|
|
87
|
+
const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
|
|
88
|
+
const asks = new AskRuntime({ stateRoot: input.stateRoot, env: input.env, now: input.now, log, installedApps: input.installedApps,
|
|
89
|
+
report: (deliveryId, report, signal) => gateway.reportAskDelivery({ deliveryId, ...report }, signal) });
|
|
90
|
+
const heartbeat = messengerHeartbeat(async (signal) => {
|
|
91
|
+
let sessionOpen;
|
|
92
|
+
// A detection error only leaves session_open out; the Gateway keeps what it stored.
|
|
93
|
+
try {
|
|
94
|
+
sessionOpen = await sessionOpenAdvertisement({ home });
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
sessionOpen = undefined;
|
|
98
|
+
}
|
|
99
|
+
const askInbox = await asks.advertisement();
|
|
100
|
+
await gateway.heartbeatLocalRunner({
|
|
101
|
+
deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
|
|
102
|
+
displayName: runnerDisplayName(), packageVersion: packageVersion(), capabilities: [], mode: "messenger",
|
|
103
|
+
// Without an integration, session_open still goes out (clients only): the Gateway keeps the last one it stored, and would keep long-polling for asks.
|
|
104
|
+
...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
|
|
105
|
+
}, signal);
|
|
106
|
+
// Without session_open the Gateway keeps its stored ask_inbox, which may be stale: send again, backing off.
|
|
107
|
+
return sessionOpen !== undefined;
|
|
108
|
+
}, { now: input.now, reportError: code => log({ event: "messenger_heartbeat_failed", code }),
|
|
109
|
+
advertised: async () => JSON.stringify(await asks.advertisement() ?? null) });
|
|
110
|
+
const opener = new SessionOpener({ home });
|
|
111
|
+
const sessionOpens = new SessionOpenPoller({
|
|
112
|
+
wait: async (signal) => {
|
|
113
|
+
await heartbeat(signal);
|
|
114
|
+
const result = await gateway.waitForSessionOpens(25, signal);
|
|
115
|
+
await asks.handleWait(result);
|
|
116
|
+
return result;
|
|
117
|
+
},
|
|
118
|
+
open: request => opener.open(request),
|
|
119
|
+
report: async (openId, result, signal) => {
|
|
120
|
+
await gateway.reportSessionOpen({ openId, status: result.status, surface: result.surface,
|
|
121
|
+
failureCode: result.status === "failed" ? result.failureCode : undefined }, signal);
|
|
122
|
+
},
|
|
123
|
+
delay: abortableDelay,
|
|
124
|
+
reportError: diagnostic => log({ event: "session_open_poller_failed", ...diagnostic }),
|
|
125
|
+
});
|
|
126
|
+
const daemon = new Daemon({
|
|
127
|
+
lock: new ProcessLock(join(input.stateRoot, "daemon.lock")),
|
|
128
|
+
// Job state belongs to a full runner: a messenger never reads, reconciles or suspends on it.
|
|
129
|
+
state: { load: async () => null },
|
|
130
|
+
reconcile: async () => undefined,
|
|
131
|
+
poller: idleJobPoller(),
|
|
132
|
+
sessionOpens: withAsks(asks, sessionOpens),
|
|
133
|
+
});
|
|
134
|
+
return { daemon, close: async () => { daemon.stop(); } };
|
|
135
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFile as nodeExecFile } from "node:child_process";
|
|
2
|
+
/*
|
|
3
|
+
* The macOS notification for a delivery with no live session to go to (Work Item e1c08b33, spec section 8.5).
|
|
4
|
+
* One per delivery, a fixed text per subject type and direction, no buttons. /usr/bin/osascript runs through
|
|
5
|
+
* execFile, never a shell, and the text reaches AppleScript as an argument, never as script source.
|
|
6
|
+
*/
|
|
7
|
+
const ASK_TEXT = {
|
|
8
|
+
decision: "AI Gateway: a colleague asked you for a decision. Open Claude or ChatGPT to see it.",
|
|
9
|
+
review: "AI Gateway: a colleague asked you for a review. Open Claude or ChatGPT to see it.",
|
|
10
|
+
request: "AI Gateway: a colleague asked you for an answer. Open Claude or ChatGPT to see it.",
|
|
11
|
+
};
|
|
12
|
+
const ANSWER_TEXT = {
|
|
13
|
+
decision: "AI Gateway: your decision request has been answered. Open Claude or ChatGPT to continue.",
|
|
14
|
+
review: "AI Gateway: your review request has been answered. Open Claude or ChatGPT to continue.",
|
|
15
|
+
request: "AI Gateway: your work request has been answered. Open Claude or ChatGPT to continue.",
|
|
16
|
+
};
|
|
17
|
+
export const OSASCRIPT = "/usr/bin/osascript";
|
|
18
|
+
const SCRIPT = ["on run argv", "display notification (item 1 of argv) with title \"AI Gateway\"", "end run"];
|
|
19
|
+
/**
|
|
20
|
+
* How long one notification may take. A hung osascript is killed after this, and a notifier that is still not done
|
|
21
|
+
* by then is given up on, so a delivery's notification can never hold AskInbox.idle(), and so a shutdown, for longer.
|
|
22
|
+
* Shorter than the asks runtime's shutdown wait (5 s), so the notification gives up before the shutdown has to.
|
|
23
|
+
*/
|
|
24
|
+
export const NOTIFY_TIMEOUT_MS = 3_000;
|
|
25
|
+
export function notificationText(item) {
|
|
26
|
+
return (item.direction === "ask" ? ASK_TEXT : ANSWER_TEXT)[item.subjectType];
|
|
27
|
+
}
|
|
28
|
+
/** The arguments osascript gets: the fixed script, then the text as its only argument. */
|
|
29
|
+
export function notificationArguments(text) {
|
|
30
|
+
return [...SCRIPT.flatMap(line => ["-e", line]), text];
|
|
31
|
+
}
|
|
32
|
+
const TIMEOUT_CODE = "NOTIFIER_TIMEOUT";
|
|
33
|
+
function failureCode(error) {
|
|
34
|
+
const failure = error;
|
|
35
|
+
// execFile kills a process that outlives its timeout and reports it as killed, with no code of its own.
|
|
36
|
+
if (failure?.killed === true)
|
|
37
|
+
return TIMEOUT_CODE;
|
|
38
|
+
return typeof failure?.code === "string" && /^[A-Z0-9_]{1,64}$/.test(failure.code) ? failure.code : "UNKNOWN";
|
|
39
|
+
}
|
|
40
|
+
/** Resolves when the work does, and rejects with the timeout code when it has not by then. Never leaves the timer running. */
|
|
41
|
+
function within(work, timeoutMs) {
|
|
42
|
+
let timer;
|
|
43
|
+
const limit = new Promise((_, reject) => {
|
|
44
|
+
timer = setTimeout(() => reject(Object.assign(new Error(TIMEOUT_CODE), { code: TIMEOUT_CODE })), timeoutMs);
|
|
45
|
+
});
|
|
46
|
+
// A run that throws before it returns a promise is a failure like any other.
|
|
47
|
+
const started = new Promise(resolve => resolve(work()));
|
|
48
|
+
return Promise.race([started, limit]).then(() => undefined).finally(() => clearTimeout(timer));
|
|
49
|
+
}
|
|
50
|
+
/** Resolves true when macOS took the notification; false off macOS or when osascript fails or hangs. */
|
|
51
|
+
export function createNotifier(deps = {}) {
|
|
52
|
+
const platform = deps.platform ?? process.platform;
|
|
53
|
+
const timeoutMs = deps.timeoutMs ?? NOTIFY_TIMEOUT_MS;
|
|
54
|
+
const exec = deps.execFile ?? nodeExecFile;
|
|
55
|
+
const run = deps.run ?? ((file, args) => new Promise((resolve, reject) => {
|
|
56
|
+
exec(file, args, { timeout: timeoutMs, killSignal: "SIGKILL" }, error => (error ? reject(error) : resolve(undefined)));
|
|
57
|
+
}));
|
|
58
|
+
return async (item) => {
|
|
59
|
+
if (platform !== "darwin")
|
|
60
|
+
return false;
|
|
61
|
+
try {
|
|
62
|
+
await within(() => run(OSASCRIPT, notificationArguments(notificationText(item))), timeoutMs);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
try {
|
|
67
|
+
deps.log?.({ event: "ask_notifier_failed", code: failureCode(error) });
|
|
68
|
+
}
|
|
69
|
+
catch { /* Logging never stops delivery. */ }
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|