mtech-ai-local-runner 0.1.47 → 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 +94 -2
- 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/daemon/runtime.js +14 -3
- package/dist/src/gateway-client.js +18 -3
- package/dist/src/registration/local-config.js +7 -1
- 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
package/README.md
CHANGED
|
@@ -1,3 +1,92 @@
|
|
|
1
|
+
## Unreleased: AI-first ask delivery and messenger mode (disabled)
|
|
2
|
+
|
|
3
|
+
The runner can put an AI Gateway ask (a Work Decision, a named review or a work request), and the answer to it,
|
|
4
|
+
into the employee's own running Claude Code or Codex session before any Slack message is sent (Work Item e1c08b33,
|
|
5
|
+
owner Work Decision 266f9475, option A). Deliveries arrive as ids only on the existing session-open wait. The daemon
|
|
6
|
+
keeps them in `asks.json` (mode 0600) and reports `received`, `shown`, `seen` or `dropped` with
|
|
7
|
+
`local_agent_ask_delivery_report`; the runner sends the Gateway no session id, cwd, prompt or text. Hooks reach the
|
|
8
|
+
daemon through `hub.sock` (0600, in the 0700 state root) and the stable shim `bin/gateway-hook`, which runs a small
|
|
9
|
+
hook client that prints nothing unless the daemon answers within 300 ms.
|
|
10
|
+
|
|
11
|
+
Only a session that a person is attending is served. A Claude Code session counts when `CLAUDE_CODE_SESSION_ATTENDED`
|
|
12
|
+
is `1` or, where Claude Code does not export that variable, when `CLAUDE_CODE_ENTRYPOINT` names a terminal, the Claude
|
|
13
|
+
desktop app or the VS Code extension. A headless `claude -p` or Agent SDK run gets nothing and is never counted as
|
|
14
|
+
having seen an ask. Codex exports no such marker, so every Codex hook session counts. The runner's own jobs never
|
|
15
|
+
count: a hook whose `HOME` is under `<stateRoot>/claude-jobs/` (where every Claude job runs) is refused, and Codex jobs
|
|
16
|
+
run with `features.hooks=false`, so they run no hook at all.
|
|
17
|
+
|
|
18
|
+
An ask is shown at a session start, at the next prompt (where it also counts as seen), at a turn end, or by waking an
|
|
19
|
+
idle Claude session through an `asyncRewake` Stop watcher. An ask shown at a session start, at a turn end or by a wake
|
|
20
|
+
counts as seen at the next prompt in that same session. A session is woken only within 30 minutes of a human prompt,
|
|
21
|
+
and at most 6 times an hour. With no such session the ask becomes one macOS notification, which gives up after 3
|
|
22
|
+
seconds and logs only an error code, and the ask waits for the next session start or prompt. Hook text is fixed and
|
|
23
|
+
carries validated ids only; the AI reads the ask itself with the Gateway show tools, which mark its text as untrusted.
|
|
24
|
+
|
|
25
|
+
The Gateway sends the ask keys on the wait only while asks are on for this runner. When a wait result comes without
|
|
26
|
+
them, every stored delivery ends: it is dropped from `asks.json` with no report, and the Gateway's Slack fallback
|
|
27
|
+
covers it. A stored delivery is forgotten an hour after it arrived, by which time the fallback (at most 15 minutes) has
|
|
28
|
+
long run.
|
|
29
|
+
|
|
30
|
+
Integrations are installed only on request, with `setup --integrations` or `integrations install`; setup never asks
|
|
31
|
+
about them. An app already installed in the directory the environment names is skipped and reported installed, without
|
|
32
|
+
rewriting its files:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx mtech-ai-local-runner setup --integrations claude,codex
|
|
36
|
+
npx mtech-ai-local-runner integrations status
|
|
37
|
+
npx mtech-ai-local-runner integrations install codex
|
|
38
|
+
npx mtech-ai-local-runner integrations remove claude
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Claude Code gets the skills-directory plugin `~/.claude/skills/mtec-ai-gateway-asks/`, which loads as
|
|
42
|
+
`mtec-ai-gateway-asks@skills-dir` in a new session or after `/reload-plugins`; `settings.json` is not touched. Codex gets
|
|
43
|
+
four handlers appended to `~/.codex/hooks.json` after a backup of that file; trust them once with `/hooks`. A
|
|
44
|
+
`hooks.json` that is not valid JSON, or whose hooks do not have the shape Codex reads, is left as it is and the install
|
|
45
|
+
reports `CODEX_HOOKS_JSON_INVALID`. `uninstall` removes both integrations, the shim, `asks.json` and any temporary
|
|
46
|
+
`asks.json.*.tmp` file a crash left behind, and it also works on a setup that never finished.
|
|
47
|
+
|
|
48
|
+
`CLAUDE_CONFIG_DIR` and `CODEX_HOME` are honoured. The LaunchAgent that runs the daemon gets only `PATH`, so every
|
|
49
|
+
install adds the directory it used to a list per app in `integrations.json` in the state root, and the daemon,
|
|
50
|
+
`doctor`, `integrations remove` and `uninstall` look in every recorded directory as well as in the environment. A
|
|
51
|
+
directory leaves the list only when nothing of the runner's is left in it, so installing into a second directory never
|
|
52
|
+
orphans the first, and the shim stays while any directory still has the hooks. When no absolute home directory can be
|
|
53
|
+
found, the command reports `INTEGRATION_HOME_UNKNOWN` for that app and writes nothing; set `HOME`, or `CLAUDE_CONFIG_DIR`
|
|
54
|
+
and `CODEX_HOME`.
|
|
55
|
+
|
|
56
|
+
On a fresh Mac without an admitted executor, or one already set up as a messenger, `setup` now completes in messenger
|
|
57
|
+
mode. It registers with no adapter capability and stores `mode: "messenger"` in `config.json`. The daemon then runs only
|
|
58
|
+
the session-open loop, for session opens and asks, and sends its own heartbeat at start, every 600 seconds and whenever
|
|
59
|
+
the installed integrations change. It has no jobs loop, no adapter probe and no PreToolUse self-test, and `manual-codex`
|
|
60
|
+
and `publication-recover` are refused with `MESSENGER_MODE_HAS_NO_EXECUTOR`. A full runner works as before and now
|
|
61
|
+
reports `mode: "full"` in its heartbeat. `doctor` reports the mode, the hub and each integration, including whether
|
|
62
|
+
Claude Code loads the plugin; for a messenger it requires neither an adapter nor the PreToolUse self-test.
|
|
63
|
+
|
|
64
|
+
Setup never turns a Mac set up as a full runner into a messenger. With no usable adapter it fails as 0.1.47 did, before
|
|
65
|
+
the login, and leaves `config.json`, the registration and the LaunchAgent as they were. Fix the adapter, or run
|
|
66
|
+
`uninstall` and then `setup` to set the Mac up as a messenger.
|
|
67
|
+
|
|
68
|
+
No ask is delivered until the Gateway's `AI_OPS_GATEWAY_WORK_ASK_AI_FIRST_ENABLED` switch is on and the employee is on
|
|
69
|
+
its pilot list. Until then an existing runner works as before: setup never turns a full runner into a messenger and
|
|
70
|
+
installs no integration it was not asked for. The one change without the switch is that setup on a fresh Mac without an
|
|
71
|
+
admitted executor completes as a messenger instead of failing. Deploy the Gateway receivers before upgrading a runner.
|
|
72
|
+
|
|
73
|
+
## 0.1.48: local_edit takes the complete file, not a patch
|
|
74
|
+
|
|
75
|
+
Read the existing file first, then pass its complete desired new contents in
|
|
76
|
+
`local_edit.content`, including unchanged text. The operation replaces the whole
|
|
77
|
+
file or creates it; it never applies a diff. Recognizable apply_patch directives,
|
|
78
|
+
unified hunk lines and diff headers return `DENIED_PATCH_CONTENT` before a write.
|
|
79
|
+
The isolated writer enforces the same guard. Literal patch blocks in a document
|
|
80
|
+
are also rejected by this conservative API; ordinary source string literals and
|
|
81
|
+
YAML separators remain accepted. No partial old/new edit tool is introduced.
|
|
82
|
+
|
|
83
|
+
Gateway and Runner must agree on the new tool inventory hash. Package runtime
|
|
84
|
+
receipts change, while the reviewed Codex CLI release manifest identity stays
|
|
85
|
+
unchanged. Keep existing -2G allowed paths; deploy the matching Gateway registry
|
|
86
|
+
and exact runtime pins before switching a daemon. The candidate contains the
|
|
87
|
+
newer development ask/messenger code documented in the Unreleased section and
|
|
88
|
+
the 0.1.46/0.1.47 fixes; no integration is installed merely by preparing it.
|
|
89
|
+
|
|
1
90
|
## 0.1.47: Claude security host wiring remains gated
|
|
2
91
|
|
|
3
92
|
Claude now shares the job security session and checks a live opaque executable
|
|
@@ -296,8 +385,11 @@ npx mtech-ai-local-runner setup
|
|
|
296
385
|
The package stores safe runner configuration locally and keeps OAuth refresh credentials in macOS Keychain when available.
|
|
297
386
|
|
|
298
387
|
Public npm installation requires no npm account or publishing OTP. Setup requires
|
|
299
|
-
the employee's own Gateway OAuth login and a
|
|
300
|
-
CLI.
|
|
388
|
+
the employee's own Gateway OAuth login and, for a full runner, a supported,
|
|
389
|
+
authenticated selected CLI. Since the AI-first ask delivery release (Unreleased,
|
|
390
|
+
above), a fresh Mac without an admitted executor is set up as a messenger
|
|
391
|
+
instead; a Mac already set up as a full runner still fails setup without one.
|
|
392
|
+
Codex uses the employee's ChatGPT account. Claude requires its own login
|
|
301
393
|
and a separately verified host admission; it is currently not admitted.
|
|
302
394
|
|
|
303
395
|
For the current unattended Codex pilot, an administrator must separately install
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { readdir, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ASKS_FILE } from "./store.js";
|
|
4
|
+
import { installIntegration, integrationStatus, removeIntegration, removeShimIfUnused } from "./integrations.js";
|
|
5
|
+
import { APPS, isApp } from "./types.js";
|
|
6
|
+
const NEXT_STEP = {
|
|
7
|
+
claude: "Start a new Claude Code session, or run /reload-plugins in an open one, to load mtec-ai-gateway-asks@skills-dir.",
|
|
8
|
+
codex: "Open Codex and trust the four AI Gateway hooks once with /hooks. Until then Codex skips them and the Slack message covers the gap.",
|
|
9
|
+
};
|
|
10
|
+
function errorCode(error) {
|
|
11
|
+
const code = error?.code;
|
|
12
|
+
if (typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code))
|
|
13
|
+
return code;
|
|
14
|
+
const message = error instanceof Error ? error.message.split(":")[0] : "";
|
|
15
|
+
return /^[A-Z0-9_]{1,64}$/.test(message) ? message : "INTEGRATION_FAILED";
|
|
16
|
+
}
|
|
17
|
+
/** integrations status|install|remove [claude|codex]; null for anything else. */
|
|
18
|
+
export function parseIntegrationsArguments(argv) {
|
|
19
|
+
const [action, app, ...extra] = argv;
|
|
20
|
+
if (extra.length > 0 || (action !== "status" && action !== "install" && action !== "remove"))
|
|
21
|
+
return null;
|
|
22
|
+
if (app !== undefined && !isApp(app))
|
|
23
|
+
return null;
|
|
24
|
+
return { action, apps: app === undefined ? [...APPS] : [app] };
|
|
25
|
+
}
|
|
26
|
+
/** setup --integrations claude,codex (or --integrations=claude): undefined when absent, null when malformed. */
|
|
27
|
+
export function parseSetupIntegrations(argv) {
|
|
28
|
+
const index = argv.findIndex(arg => arg === "--integrations" || arg.startsWith("--integrations="));
|
|
29
|
+
if (index < 0)
|
|
30
|
+
return undefined;
|
|
31
|
+
const value = argv[index] === "--integrations" ? argv[index + 1] : argv[index].slice("--integrations=".length);
|
|
32
|
+
const apps = (value ?? "").split(",").map(app => app.trim()).filter(app => app !== "");
|
|
33
|
+
if (apps.length === 0 || !apps.every(isApp) || new Set(apps).size !== apps.length)
|
|
34
|
+
return null;
|
|
35
|
+
return apps;
|
|
36
|
+
}
|
|
37
|
+
/** The state of one app; a status that cannot be read (no home directory, say) is reported with its error, never thrown. */
|
|
38
|
+
export async function statusOf(app, env, stateRoot, error) {
|
|
39
|
+
try {
|
|
40
|
+
return { ...await integrationStatus(app, env, stateRoot), ...(error === undefined ? {} : { error }) };
|
|
41
|
+
}
|
|
42
|
+
catch (failure) {
|
|
43
|
+
return { app, state: "unreadable", path: "", error: error ?? errorCode(failure) };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function attempt(app, env, stateRoot, change) {
|
|
47
|
+
try {
|
|
48
|
+
return await change();
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
return statusOf(app, env, stateRoot, errorCode(error));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function installIntegrations(apps, env, stateRoot) {
|
|
55
|
+
const results = [];
|
|
56
|
+
for (const app of apps) {
|
|
57
|
+
const result = await attempt(app, env, stateRoot, () => installIntegration(app, env, stateRoot));
|
|
58
|
+
results.push(result.error === undefined ? { ...result, action: NEXT_STEP[app] } : result);
|
|
59
|
+
}
|
|
60
|
+
return results;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* uninstall: removes both integrations, then the shim once nothing uses it, asks.json and the store's temporary files
|
|
64
|
+
* that a crash left behind (asks.json.<pid>.<uuid>.tmp), which can hold raw session ids.
|
|
65
|
+
*/
|
|
66
|
+
export async function removeAsksInstall(env, stateRoot) {
|
|
67
|
+
const results = [];
|
|
68
|
+
for (const app of APPS)
|
|
69
|
+
results.push(await attempt(app, env, stateRoot, () => removeIntegration(app, env, stateRoot)));
|
|
70
|
+
// A hook left behind (an unreadable Codex hooks file) keeps its shim, which then prints nothing and exits 0.
|
|
71
|
+
await removeShimIfUnused(env, stateRoot).catch(() => false);
|
|
72
|
+
await rm(join(stateRoot, ASKS_FILE), { force: true });
|
|
73
|
+
for (const name of await readdir(stateRoot).catch(() => [])) {
|
|
74
|
+
if (name.startsWith(`${ASKS_FILE}.`) && name.endsWith(".tmp"))
|
|
75
|
+
await rm(join(stateRoot, name), { force: true });
|
|
76
|
+
}
|
|
77
|
+
return results;
|
|
78
|
+
}
|
|
79
|
+
export async function runIntegrationsCommand(argv, io) {
|
|
80
|
+
const parsed = parseIntegrationsArguments(argv);
|
|
81
|
+
if (parsed === null) {
|
|
82
|
+
io.stderr("Usage: mtec-ai-local-runner integrations status|install|remove [claude|codex]");
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
let results;
|
|
86
|
+
if (parsed.action === "install") {
|
|
87
|
+
results = await installIntegrations(parsed.apps, io.env, io.stateRoot);
|
|
88
|
+
}
|
|
89
|
+
else if (parsed.action === "remove") {
|
|
90
|
+
results = [];
|
|
91
|
+
for (const app of parsed.apps)
|
|
92
|
+
results.push(await attempt(app, io.env, io.stateRoot, () => removeIntegration(app, io.env, io.stateRoot)));
|
|
93
|
+
await removeShimIfUnused(io.env, io.stateRoot).catch(() => false);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
results = await Promise.all(parsed.apps.map(app => statusOf(app, io.env, io.stateRoot)));
|
|
97
|
+
}
|
|
98
|
+
io.stdout(JSON.stringify({ integrations: results }));
|
|
99
|
+
return results.some(result => result.error !== undefined) ? 1 : 0;
|
|
100
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { connect } from "node:net";
|
|
3
|
+
import { statusOf } from "./commands.js";
|
|
4
|
+
import { hubSocketPath } from "./hub.js";
|
|
5
|
+
import { MAX_LINE_BYTES, encodeLine } from "./hub-protocol.js";
|
|
6
|
+
import { CLAUDE_PLUGIN_NAME } from "./integrations.js";
|
|
7
|
+
import { APPS } from "./types.js";
|
|
8
|
+
const PLUGIN_ID = `${CLAUDE_PLUGIN_NAME}@skills-dir`;
|
|
9
|
+
const CLAUDE_NOT_LOADED = `Claude Code does not load ${PLUGIN_ID}. Managed settings can block skills-directory plugins `
|
|
10
|
+
+ "(strictPluginOnlyCustomization, a strictKnownMarketplaces list without the skills-dir source, or blockedMarketplaces): "
|
|
11
|
+
+ "ask your administrator, or install the plugin from a directory marketplace your organization allows (claude plugin marketplace add <dir>). "
|
|
12
|
+
+ "A Claude Code release without skills-directory plugins does not load it either.";
|
|
13
|
+
const CODEX_TRUST = "Codex runs these hooks only after you trust them once with /hooks; until then the Slack message covers each ask.";
|
|
14
|
+
/** Runs the Claude CLI with the environment doctor was given on top of the process's own (CLAUDE_CONFIG_DIR, for one). */
|
|
15
|
+
const defaultRun = (env) => (file, args) => new Promise((resolve, reject) => {
|
|
16
|
+
execFile(file, args, { encoding: "utf8", timeout: 10_000, maxBuffer: 1024 * 1024, env: { ...process.env, ...env } }, (error, stdout) => (error ? reject(error) : resolve({ stdout })));
|
|
17
|
+
});
|
|
18
|
+
/** The three counts of a hub status answer, or null when it is anything else. */
|
|
19
|
+
function parseStatus(value) {
|
|
20
|
+
if (value === null || typeof value !== "object")
|
|
21
|
+
return null;
|
|
22
|
+
const { deliveries, live_sessions: liveSessions, watchers } = value;
|
|
23
|
+
const counts = [deliveries, liveSessions, watchers];
|
|
24
|
+
if (!counts.every(count => typeof count === "number" && Number.isSafeInteger(count) && count >= 0))
|
|
25
|
+
return null;
|
|
26
|
+
return { deliveries: deliveries, live_sessions: liveSessions, watchers: watchers };
|
|
27
|
+
}
|
|
28
|
+
/** The running hub's counts (its status op), or null when nothing answers with a status within the time limit. Never rejects. */
|
|
29
|
+
export function hubStatus(stateRoot, timeoutMs = 1000) {
|
|
30
|
+
return new Promise(resolve => {
|
|
31
|
+
let socket;
|
|
32
|
+
let buffer = "";
|
|
33
|
+
let done = false;
|
|
34
|
+
const finish = (value) => {
|
|
35
|
+
if (done)
|
|
36
|
+
return;
|
|
37
|
+
done = true;
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
socket?.destroy();
|
|
40
|
+
resolve(value);
|
|
41
|
+
};
|
|
42
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
43
|
+
try {
|
|
44
|
+
socket = connect(hubSocketPath(stateRoot));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
finish(null);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
socket.setEncoding("utf8");
|
|
51
|
+
socket.on("error", () => finish(null));
|
|
52
|
+
socket.on("close", () => finish(null));
|
|
53
|
+
socket.on("data", (chunk) => {
|
|
54
|
+
buffer += chunk;
|
|
55
|
+
const newline = buffer.indexOf("\n");
|
|
56
|
+
if (newline < 0) {
|
|
57
|
+
if (buffer.length > MAX_LINE_BYTES)
|
|
58
|
+
finish(null);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const response = JSON.parse(buffer.slice(0, newline));
|
|
63
|
+
finish(response.ok === true ? parseStatus(response.status) : null);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
finish(null);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
socket.write(encodeLine({ v: 1, op: "status" }));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** true or false from `claude plugin list --json`; null when the Claude CLI cannot tell. Never rejects. */
|
|
73
|
+
export async function claudePluginLoaded(env, run = defaultRun(env)) {
|
|
74
|
+
try {
|
|
75
|
+
const { stdout } = await run(env.AI_GATEWAY_LOCAL_RUNNER_CLAUDE_BINARY ?? "claude", ["plugin", "list", "--json"]);
|
|
76
|
+
const plugins = JSON.parse(stdout);
|
|
77
|
+
if (!Array.isArray(plugins))
|
|
78
|
+
return null;
|
|
79
|
+
const plugin = plugins.find(entry => entry?.id === PLUGIN_ID);
|
|
80
|
+
return plugin !== undefined && plugin.enabled !== false;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The asks section of doctor. The integrations go through statusOf, which reads the dir the environment names and the dir
|
|
88
|
+
* install recorded, and reports an app whose home cannot be found (INTEGRATION_HOME_UNKNOWN) on its own entry.
|
|
89
|
+
*/
|
|
90
|
+
export async function asksDoctorReport(input) {
|
|
91
|
+
const [status, statuses] = await Promise.all([hubStatus(input.stateRoot), Promise.all(APPS.map(app => statusOf(app, input.env, input.stateRoot)))]);
|
|
92
|
+
const integrations = [];
|
|
93
|
+
for (const report of statuses) {
|
|
94
|
+
if (report.state !== "installed") {
|
|
95
|
+
integrations.push(report);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (report.app === "codex") {
|
|
99
|
+
integrations.push({ ...report, action: CODEX_TRUST });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const loaded = await claudePluginLoaded(input.env, input.run);
|
|
103
|
+
integrations.push({ ...report, ...(loaded === null ? {} : { loaded }), ...(loaded === false ? { action: CLAUDE_NOT_LOADED } : {}) });
|
|
104
|
+
}
|
|
105
|
+
return { mode: input.mode, hub: status === null ? { status: "not_running" } : { status: "running", ...status }, integrations };
|
|
106
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { connect } from "node:net";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { HUB_SOCKET, HUB_TIMEOUT_MS, MAX_HOOK_INPUT_BYTES, MAX_LINE_BYTES, WATCH_LIMIT_MS, encodeLine, parseEventResponse, parseWatchMessage, } from "./hub-protocol.js";
|
|
7
|
+
import { MAX_CWD_LENGTH, SESSION_ID, isApp, isHookEvent } from "./types.js";
|
|
8
|
+
const STDIN_TIMEOUT_MS = 1000;
|
|
9
|
+
const STATE_ROOT_FLAG = "--state-root=";
|
|
10
|
+
/**
|
|
11
|
+
* Values of CLAUDE_CODE_ENTRYPOINT under which a person types the prompts: a terminal, the Claude desktop app and
|
|
12
|
+
* the VS Code extension. Claude Code exports the variable itself (sdk-cli for claude -p, sdk-ts and sdk-py for the
|
|
13
|
+
* Agent SDK, mcp, local-agent, remote and the GitHub action for the rest), so any other value is not a person.
|
|
14
|
+
*/
|
|
15
|
+
const INTERACTIVE_ENTRYPOINTS = new Set(["cli", "claude-desktop", "claude-vscode"]);
|
|
16
|
+
/** Where the Claude adapter puts each job's HOME: <stateRoot>/claude-jobs/<id>/home (src/adapters/claude-code/runtime-options.ts). */
|
|
17
|
+
const CLAUDE_JOBS_DIR = "claude-jobs";
|
|
18
|
+
/** True when HOME resolves inside <stateRoot>/claude-jobs/: a Claude job this runner started, never a person's session. */
|
|
19
|
+
function isRunnerJobHome(env, stateRoot) {
|
|
20
|
+
const home = env.HOME;
|
|
21
|
+
if (!home || !isAbsolute(home))
|
|
22
|
+
return false;
|
|
23
|
+
const roots = new Set([resolve(stateRoot)]);
|
|
24
|
+
// The adapter builds job homes from the real state root; the shim may name it through a symlink.
|
|
25
|
+
try {
|
|
26
|
+
roots.add(realpathSync(stateRoot));
|
|
27
|
+
}
|
|
28
|
+
catch { /* A state root that does not exist holds no jobs. */ }
|
|
29
|
+
const resolved = resolve(home);
|
|
30
|
+
return [...roots].some(root => resolved.startsWith(join(root, CLAUDE_JOBS_DIR) + sep));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* True when a person is attending the session that runs this hook (owner decision 2026-10-23). The one place that
|
|
34
|
+
* decides it, so it can be adjusted after the pilot.
|
|
35
|
+
*
|
|
36
|
+
* The runner's own jobs never count. A Claude job runs with its HOME under <stateRoot>/claude-jobs/, so a hook that
|
|
37
|
+
* sees such a HOME is refused whatever else the environment says. Codex jobs run with features.hooks=false (the Codex
|
|
38
|
+
* adapter's isolation overrides), so they never run a hook at all.
|
|
39
|
+
*
|
|
40
|
+
* Claude Code: CLAUDE_CODE_SESSION_ATTENDED, where the Claude Code version exports it, is the answer: "1" is a
|
|
41
|
+
* person, any other value is not, whatever the entrypoint says (a scheduled desktop task has the desktop entrypoint
|
|
42
|
+
* and nobody at the keyboard). A version that does not export it is judged by CLAUDE_CODE_ENTRYPOINT, and an unset
|
|
43
|
+
* or unknown entrypoint is not a person.
|
|
44
|
+
*
|
|
45
|
+
* Codex: no headless marker is exported to its hooks or present in its hook input that has been verified, so every
|
|
46
|
+
* Codex hook session counts as a person's. Claude's variables say nothing about Codex and are not read for it.
|
|
47
|
+
* `input` is the parsed hook input, passed for the day Codex exposes such a marker.
|
|
48
|
+
*/
|
|
49
|
+
export function isHumanSession(app, env, _input, stateRoot) {
|
|
50
|
+
if (isRunnerJobHome(env, stateRoot))
|
|
51
|
+
return false;
|
|
52
|
+
if (app !== "claude")
|
|
53
|
+
return true;
|
|
54
|
+
const attended = env.CLAUDE_CODE_SESSION_ATTENDED;
|
|
55
|
+
if (attended !== undefined && attended !== "")
|
|
56
|
+
return attended === "1";
|
|
57
|
+
return INTERACTIVE_ENTRYPOINTS.has(env.CLAUDE_CODE_ENTRYPOINT ?? "");
|
|
58
|
+
}
|
|
59
|
+
/** Same rule as defaultStateRootFromEnv in src/config.ts, repeated so this client loads nothing else. */
|
|
60
|
+
function stateRootFrom(env) {
|
|
61
|
+
return env.XDG_STATE_HOME ? join(env.XDG_STATE_HOME, "mtec-ai-local-runner") : join(env.HOME ?? "", ".local", "state", "mtec-ai-local-runner");
|
|
62
|
+
}
|
|
63
|
+
function parseArguments(argv, env) {
|
|
64
|
+
const rest = [...argv];
|
|
65
|
+
let stateRoot = stateRootFrom(env);
|
|
66
|
+
if (rest[0]?.startsWith(STATE_ROOT_FLAG)) {
|
|
67
|
+
stateRoot = rest.shift().slice(STATE_ROOT_FLAG.length);
|
|
68
|
+
if (!isAbsolute(stateRoot))
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const [app, event, ...extra] = rest;
|
|
72
|
+
if (extra.length > 0 || !isApp(app) || !isHookEvent(event))
|
|
73
|
+
return null;
|
|
74
|
+
return { stateRoot, app, event };
|
|
75
|
+
}
|
|
76
|
+
/** The hook's JSON from stdin, at most 64 KiB, within a second; null otherwise. */
|
|
77
|
+
async function readInput(stdin, timeoutMs) {
|
|
78
|
+
let timer;
|
|
79
|
+
const timeout = new Promise(resolve => { timer = setTimeout(() => resolve(null), timeoutMs); });
|
|
80
|
+
const read = (async () => {
|
|
81
|
+
const chunks = [];
|
|
82
|
+
let size = 0;
|
|
83
|
+
for await (const chunk of stdin) {
|
|
84
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
85
|
+
size += bytes.length;
|
|
86
|
+
if (size > MAX_HOOK_INPUT_BYTES)
|
|
87
|
+
return null;
|
|
88
|
+
chunks.push(bytes);
|
|
89
|
+
}
|
|
90
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
91
|
+
})().catch(() => null);
|
|
92
|
+
try {
|
|
93
|
+
return await Promise.race([read, timeout]);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** The hook input as an object, or null when it is not one. */
|
|
100
|
+
function parseRecord(raw) {
|
|
101
|
+
let value;
|
|
102
|
+
try {
|
|
103
|
+
value = JSON.parse(raw);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
109
|
+
}
|
|
110
|
+
/** Takes the session id, the cwd and Codex's stop_hook_active. Every other field, the prompt included, is ignored. */
|
|
111
|
+
function hookFields(input) {
|
|
112
|
+
const { session_id: sessionId, cwd, stop_hook_active: stopHookActive } = input;
|
|
113
|
+
if (typeof sessionId !== "string" || !SESSION_ID.test(sessionId))
|
|
114
|
+
return null;
|
|
115
|
+
return { sessionId, cwd: typeof cwd === "string" && isAbsolute(cwd) && cwd.length <= MAX_CWD_LENGTH ? cwd : "", stopHookActive: stopHookActive === true };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Writes one line to the hub and calls onLine for each line it answers. The first line must arrive within
|
|
119
|
+
* firstMs; after that the connection may stay open until limitMs. Resolves with what onLine settled on, or null.
|
|
120
|
+
* A connection the hub closes without a message (a refused or late watch ends that way) resolves null.
|
|
121
|
+
*/
|
|
122
|
+
function converse(socketPath, line, firstMs, limitMs, onLine) {
|
|
123
|
+
return new Promise(resolve => {
|
|
124
|
+
const socket = connect(socketPath);
|
|
125
|
+
let buffer = "";
|
|
126
|
+
let first = true;
|
|
127
|
+
let settled = false;
|
|
128
|
+
let timer = setTimeout(() => finish(null), firstMs);
|
|
129
|
+
const finish = (value) => {
|
|
130
|
+
if (settled)
|
|
131
|
+
return;
|
|
132
|
+
settled = true;
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
socket.destroy();
|
|
135
|
+
resolve(value);
|
|
136
|
+
};
|
|
137
|
+
socket.setEncoding("utf8");
|
|
138
|
+
socket.on("error", () => finish(null));
|
|
139
|
+
socket.on("close", () => finish(null));
|
|
140
|
+
socket.on("data", (chunk) => {
|
|
141
|
+
buffer += chunk;
|
|
142
|
+
if (Buffer.byteLength(buffer) > MAX_LINE_BYTES * 4) {
|
|
143
|
+
finish(null);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
let newline;
|
|
147
|
+
while (!settled && (newline = buffer.indexOf("\n")) >= 0) {
|
|
148
|
+
const text = buffer.slice(0, newline);
|
|
149
|
+
buffer = buffer.slice(newline + 1);
|
|
150
|
+
const step = onLine(text, first);
|
|
151
|
+
if (first) {
|
|
152
|
+
first = false;
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
timer = setTimeout(() => finish(null), limitMs);
|
|
155
|
+
}
|
|
156
|
+
if (step.done)
|
|
157
|
+
finish(step.value);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
socket.write(line);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
function contextOutput(event, text) {
|
|
164
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } });
|
|
165
|
+
}
|
|
166
|
+
/** Returns the exit code: 2 only when a Claude Stop watcher wakes its session, else 0. Never throws. */
|
|
167
|
+
export async function runHookClient(argv, io) {
|
|
168
|
+
try {
|
|
169
|
+
const args = parseArguments(argv, io.env);
|
|
170
|
+
if (args === null)
|
|
171
|
+
return 0;
|
|
172
|
+
const raw = await readInput(io.stdin, io.stdinTimeoutMs ?? STDIN_TIMEOUT_MS);
|
|
173
|
+
const record = raw === null ? null : parseRecord(raw);
|
|
174
|
+
const input = record === null ? null : hookFields(record);
|
|
175
|
+
if (record === null || input === null)
|
|
176
|
+
return 0;
|
|
177
|
+
// Before the first byte goes to the hub: a session nobody attends is not registered, shown an ask or counted as seen.
|
|
178
|
+
if (!isHumanSession(args.app, io.env, record, args.stateRoot))
|
|
179
|
+
return 0;
|
|
180
|
+
const socketPath = join(args.stateRoot, HUB_SOCKET);
|
|
181
|
+
const hubTimeoutMs = io.hubTimeoutMs ?? HUB_TIMEOUT_MS;
|
|
182
|
+
if (args.app === "claude" && args.event === "stop") {
|
|
183
|
+
// The asyncRewake watcher: exit 2 with the text on stderr wakes Claude, even when the session is idle.
|
|
184
|
+
const text = await converse(socketPath, encodeLine({ v: 1, op: "watch", app: "claude", session_id: input.sessionId, cwd: input.cwd }), hubTimeoutMs, io.watchLimitMs ?? WATCH_LIMIT_MS, (line, first) => {
|
|
185
|
+
const message = parseWatchMessage(line);
|
|
186
|
+
if (message?.type === "wake")
|
|
187
|
+
return { done: true, value: message.text };
|
|
188
|
+
if (message?.type === "watching" && first)
|
|
189
|
+
return { done: false };
|
|
190
|
+
return { done: true, value: null };
|
|
191
|
+
});
|
|
192
|
+
if (text === null)
|
|
193
|
+
return 0;
|
|
194
|
+
io.stderr(text);
|
|
195
|
+
return 2;
|
|
196
|
+
}
|
|
197
|
+
const request = args.event === "stop"
|
|
198
|
+
? { v: 1, op: "event", app: args.app, event: args.event, session_id: input.sessionId, cwd: input.cwd, stop_hook_active: input.stopHookActive }
|
|
199
|
+
: { v: 1, op: "event", app: args.app, event: args.event, session_id: input.sessionId, cwd: input.cwd };
|
|
200
|
+
const text = await converse(socketPath, encodeLine(request), hubTimeoutMs, hubTimeoutMs, line => ({ done: true, value: parseEventResponse(line)?.text ?? null }));
|
|
201
|
+
if (text === null || text === "")
|
|
202
|
+
return 0;
|
|
203
|
+
if (args.event === "session-start")
|
|
204
|
+
io.stdout(contextOutput("SessionStart", text));
|
|
205
|
+
else if (args.event === "prompt")
|
|
206
|
+
io.stdout(contextOutput("UserPromptSubmit", text));
|
|
207
|
+
else if (args.event === "stop")
|
|
208
|
+
io.stdout(JSON.stringify({ decision: "block", reason: text }));
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** True when Node runs this file as its entry point, directly or through a symlink. */
|
|
216
|
+
function isDirectInvocation(moduleUrl, argvPath) {
|
|
217
|
+
if (!argvPath)
|
|
218
|
+
return false;
|
|
219
|
+
try {
|
|
220
|
+
return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(argvPath);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (isDirectInvocation(import.meta.url, process.argv[1])) {
|
|
227
|
+
void runHookClient(process.argv.slice(2), {
|
|
228
|
+
stdin: process.stdin,
|
|
229
|
+
stdout: text => { process.stdout.write(`${text}\n`); },
|
|
230
|
+
stderr: text => { process.stderr.write(`${text}\n`); },
|
|
231
|
+
env: process.env,
|
|
232
|
+
}).then(code => {
|
|
233
|
+
process.exitCode = code;
|
|
234
|
+
process.stdin.destroy();
|
|
235
|
+
});
|
|
236
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { MAX_CWD_LENGTH, SESSION_ID, isApp, isHookEvent } from "./types.js";
|
|
2
|
+
/*
|
|
3
|
+
* The line protocol between the hook client and the daemon's local hub (Work Item e1c08b33, spec section 8.2):
|
|
4
|
+
* one JSON object per line over the Unix socket <stateRoot>/hub.sock. The hook client imports this module, so it
|
|
5
|
+
* may import nothing but ./types.js.
|
|
6
|
+
*
|
|
7
|
+
* Client to hub, one line, then the client waits without closing its side:
|
|
8
|
+
* {"v":1,"op":"event","app":"claude"|"codex","event":"session-start"|"prompt"|"session-end","session_id":…,"cwd":…}
|
|
9
|
+
* {"v":1,"op":"event","app":"codex","event":"stop","session_id":…,"cwd":…,"stop_hook_active":false}
|
|
10
|
+
* {"v":1,"op":"watch","app":"claude","session_id":…,"cwd":…} (the Claude Stop watcher)
|
|
11
|
+
* {"v":1,"op":"status"} (doctor)
|
|
12
|
+
* Hub to client:
|
|
13
|
+
* event: {"v":1,"ok":true,"text":"…"|null}, then the hub closes the connection
|
|
14
|
+
* status: {"v":1,"ok":true,"status":{"deliveries":n,"live_sessions":n,"watchers":n}}
|
|
15
|
+
* error: {"v":1,"ok":false,"code":"invalid_request"|"unavailable"}
|
|
16
|
+
* watch: {"v":1,"type":"wake","text":"…"} at once, or {"v":1,"type":"watching"} and later at most one of
|
|
17
|
+
* {"v":1,"type":"wake","text":"…"} or {"v":1,"type":"release","reason":"replaced"|"session_ended"|"shutdown"}
|
|
18
|
+
* A watch the hub refuses, or answers too late, ends with a bare close and no message; the client exits 0 on that too.
|
|
19
|
+
*/
|
|
20
|
+
export const HUB_SOCKET = "hub.sock";
|
|
21
|
+
/** The hook client prints nothing and exits 0 when the hub has not answered within 300 ms. */
|
|
22
|
+
export const HUB_TIMEOUT_MS = 300;
|
|
23
|
+
/** A Claude Stop watcher exits 0 after 3500 s; its hook timeout is 3600 s. */
|
|
24
|
+
export const WATCH_LIMIT_MS = 3_500_000;
|
|
25
|
+
/** The most hook input the client reads from stdin. */
|
|
26
|
+
export const MAX_HOOK_INPUT_BYTES = 64 * 1024;
|
|
27
|
+
/** The longest request or response line. */
|
|
28
|
+
export const MAX_LINE_BYTES = 16 * 1024;
|
|
29
|
+
/** macOS keeps 104 bytes for a Unix socket path, including the terminating NUL; Node silently truncates longer ones. */
|
|
30
|
+
export const MAX_SOCKET_PATH_BYTES = 103;
|
|
31
|
+
export function encodeLine(message) {
|
|
32
|
+
return `${JSON.stringify(message)}\n`;
|
|
33
|
+
}
|
|
34
|
+
function record(line) {
|
|
35
|
+
try {
|
|
36
|
+
const value = JSON.parse(line);
|
|
37
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A request from the hook client, or null when anything is missing, unknown or out of range. The session id and the
|
|
45
|
+
* cwd are held to the limits the ask store applies on a load (SESSION_ID, MAX_CWD_LENGTH): a session the store would
|
|
46
|
+
* drop at the next start must never be registered, because it would orphan every delivery pinned to it.
|
|
47
|
+
*/
|
|
48
|
+
export function parseHubRequest(line) {
|
|
49
|
+
const value = record(line);
|
|
50
|
+
if (value === null || value.v !== 1)
|
|
51
|
+
return null;
|
|
52
|
+
if (value.op === "status")
|
|
53
|
+
return Object.keys(value).length === 2 ? { v: 1, op: "status" } : null;
|
|
54
|
+
if (typeof value.session_id !== "string" || !SESSION_ID.test(value.session_id))
|
|
55
|
+
return null;
|
|
56
|
+
if (typeof value.cwd !== "string" || value.cwd.length > MAX_CWD_LENGTH)
|
|
57
|
+
return null;
|
|
58
|
+
if (value.op === "watch") {
|
|
59
|
+
if (value.app !== "claude" || Object.keys(value).length !== 5)
|
|
60
|
+
return null;
|
|
61
|
+
return { v: 1, op: "watch", app: "claude", session_id: value.session_id, cwd: value.cwd };
|
|
62
|
+
}
|
|
63
|
+
if (value.op !== "event" || !isApp(value.app) || !isHookEvent(value.event))
|
|
64
|
+
return null;
|
|
65
|
+
// A Claude turn end is always a watch; only Codex sends a plain stop, with its loop guard.
|
|
66
|
+
if (value.event === "stop") {
|
|
67
|
+
if (value.app !== "codex" || typeof value.stop_hook_active !== "boolean" || Object.keys(value).length !== 7)
|
|
68
|
+
return null;
|
|
69
|
+
return { v: 1, op: "event", app: "codex", event: "stop", session_id: value.session_id, cwd: value.cwd, stop_hook_active: value.stop_hook_active };
|
|
70
|
+
}
|
|
71
|
+
if (Object.keys(value).length !== 6)
|
|
72
|
+
return null;
|
|
73
|
+
return { v: 1, op: "event", app: value.app, event: value.event, session_id: value.session_id, cwd: value.cwd };
|
|
74
|
+
}
|
|
75
|
+
/** The hub's answer to an event request, or null when it is not one. */
|
|
76
|
+
export function parseEventResponse(line) {
|
|
77
|
+
const value = record(line);
|
|
78
|
+
if (value === null || value.v !== 1 || value.ok !== true)
|
|
79
|
+
return null;
|
|
80
|
+
if (value.text === null)
|
|
81
|
+
return { text: null };
|
|
82
|
+
return typeof value.text === "string" ? { text: value.text } : null;
|
|
83
|
+
}
|
|
84
|
+
/** One message on a watch connection, or null when it is not one. */
|
|
85
|
+
export function parseWatchMessage(line) {
|
|
86
|
+
const value = record(line);
|
|
87
|
+
if (value === null || value.v !== 1)
|
|
88
|
+
return null;
|
|
89
|
+
if (value.type === "watching")
|
|
90
|
+
return { v: 1, type: "watching" };
|
|
91
|
+
if (value.type === "wake" && typeof value.text === "string" && value.text !== "")
|
|
92
|
+
return { v: 1, type: "wake", text: value.text };
|
|
93
|
+
if (value.type === "release" && (value.reason === "replaced" || value.reason === "session_ended" || value.reason === "shutdown")) {
|
|
94
|
+
return { v: 1, type: "release", reason: value.reason };
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|