mtech-ai-local-runner 0.1.80 → 0.1.82

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 CHANGED
@@ -269,7 +269,7 @@ covers it. A stored delivery is forgotten an hour after it arrived, by which tim
269
269
  long run.
270
270
 
271
271
  Integrations are installed only on request, with `setup --integrations` or `integrations install`; setup never asks
272
- about them. An app already installed in the directory the environment names is skipped and reported installed, without
272
+ about them. The Work Room pane, below, is the one exception: every setup installs it. An app already installed in the directory the environment names is skipped and reported installed, without
273
273
  rewriting its files:
274
274
 
275
275
  ```bash
@@ -298,10 +298,16 @@ your own AI Gateway connector in Claude, and it never starts a model turn: its O
298
298
  conversation. Without the connector the pane says so. The Claude mobile app draws no input field, so there the pane
299
299
  reads, shares and marks read only.
300
300
 
301
- The pane refreshes with no model turn, so a Claude session in auto mode would refuse those calls. The mod therefore
302
- ships a PreToolUse hook that allows exactly the two reads, `work_room_read` and `work_inbox`, on any AI Gateway
303
- connector. That hook allows them for every caller in the session, the model included. Every other call the pane makes
304
- carries the press that raised it: Open for a Room and its presented receipt, and the confirm press for each write.
301
+ The pane calls the connector with no model turn, so a Claude session in auto mode would refuse every one of those
302
+ calls. The mod therefore ships PreToolUse hooks that match any AI Gateway connector and apply to every caller in the
303
+ session, the model included:
304
+
305
+ - They allow the two reads, `work_room_read` and `work_inbox`.
306
+ - They allow `work_room_receipt_report` only when its `state` is `presented`, the record that the pane showed you a
307
+ message.
308
+ - They ask you in Claude's own permission dialog before every write: `work_room_write`, `work_request_answer`, and
309
+ `work_room_receipt_report` for marking messages read. A write from the pane therefore needs its confirm press and
310
+ then your answer in that dialog.
305
311
 
306
312
  The runner copies the mod to `claude-mod/<version>/` in the state root, without its tests, and installs it with the
307
313
  Claude CLI as a local marketplace (`plugin marketplace add` and `plugin install --scope user`). This needs a Claude CLI
@@ -310,6 +316,12 @@ bundles. Without one, the hooks are installed as before and the result reports t
310
316
  only a missing or older copy, and it never turns on a mod you turned off. `integrations status` and `doctor` report the
311
317
  mod's state under `mod`; `integrations remove claude` and `uninstall` remove it. The daemon never runs the Claude CLI.
312
318
 
319
+ Every `setup` installs or updates the mod, with or without `--integrations`, and reports it as `workRoomPane`. So
320
+ rerunning `npx mtech-ai-local-runner@<version> setup` is enough to get the newest pane. With `--integrations claude`
321
+ the Claude integration installs it and reports it under its own `mod` instead. After `integrations remove claude`,
322
+ setup leaves the mod out, and its result says so. `integrations install claude` adds the mod again, and from then
323
+ setup keeps it up to date once more. `uninstall` forgets the removal, so a later setup starts fresh.
324
+
313
325
  `CLAUDE_CONFIG_DIR` and `CODEX_HOME` are honoured. The LaunchAgent that runs the daemon gets only `PATH`, so every
314
326
  install adds the directory it used to a list per app in `integrations.json` in the state root, and the daemon,
315
327
  `doctor`, `integrations remove` and `uninstall` look in every recorded directory as well as in the environment. A
@@ -1 +1 @@
1
- { "name": "ai-gateway-work-room", "version": "1.1.0", "description": "Work Room shares and requests from colleagues beside the conversation, installed by mtech-ai-local-runner (Work Item 98da2635).", "types": "./types/index.d.ts" }
1
+ { "name": "ai-gateway-work-room", "version": "1.2.0", "description": "Work Room shares and requests from colleagues beside the conversation, installed by mtech-ai-local-runner (Work Item 98da2635).", "types": "./types/index.d.ts" }
@@ -10,6 +10,24 @@
10
10
  "command": "printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Work Room pane: reading your own AI Gateway Rooms and requests is allowed.\"}}'"
11
11
  }
12
12
  ]
13
+ },
14
+ {
15
+ "matcher": "^mcp__.+__work_room_receipt_report$",
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "if grep -Eq '\"state\"[[:space:]]*:[[:space:]]*\"presented\"'; then printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Work Room pane: recording that it showed you these Room messages is allowed.\"}}'; else printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"Work Room: marking Room messages read tells the AI Gateway you read them. Allow it?\"}}'; fi"
20
+ }
21
+ ]
22
+ },
23
+ {
24
+ "matcher": "^mcp__.+__(work_room_write|work_request_answer)$",
25
+ "hooks": [
26
+ {
27
+ "type": "command",
28
+ "command": "printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"Work Room: this sends what you wrote to colleagues through the AI Gateway. Allow it?\"}}'"
29
+ }
30
+ ]
13
31
  }
14
32
  ]
15
33
  }
@@ -10,9 +10,11 @@ import { bandText, chatPrompt, findPayload, parseActivity, parseInbox, parseMess
10
10
  * conversation, read and answered through the person's own AI Gateway connector with no model turn. The band above
11
11
  * the prompt says what waits; the pane opens only from the band or /work-room, never at session start.
12
12
  *
13
- * The refresh runs with no request of the person's behind it, which auto mode refuses, and a mod's own module hooks
14
- * cannot approve its own calls. So hooks/hooks.json carries a PreToolUse hook that allows the two reads, work_room_read
15
- * and work_inbox, for every caller (owner decision 2026-10-31). Every other call carries the press that raised it.
13
+ * Auto mode refuses every call the pane makes, since none comes from a request of the person's in the conversation, and
14
+ * a mod's own module hooks cannot approve its own calls. So hooks/hooks.json carries PreToolUse hooks for every caller
15
+ * (owner decisions 2026-10-31): they allow the reads (work_room_read, work_inbox) and the presented receipt, and they
16
+ * put each write (answer, post, share, mark read) to the person in Claude's own permission dialog, after the pane's
17
+ * confirm press.
16
18
  */
17
19
 
18
20
  export const PANE = 'work-room'
@@ -1,6 +1,6 @@
1
1
  import { renderBundleCorrectionBrief } from '../../verification/continuation-runner-contract.js';
2
2
  import { compilePrompt as compileBrief } from "../prompt-compiler.js";
3
- import { LOCAL_TOOL_GUIDANCE, repositoryScope } from "../local-tool-prompt.js";
3
+ import { LOCAL_FIND_DISCOVERY, LOCAL_TOOL_GUIDANCE, repositoryScope } from "../local-tool-prompt.js";
4
4
  import { localToolNames } from "../../safety/local-tool-policy.js";
5
5
  // Claude's working directory is the job's private control directory, so without these rules it sends that directory's
6
6
  // absolute path or shell commands to the ai_local tools (P4 Investigate of Work Item 1c8026f1, 2026-10-28). Without the
@@ -9,11 +9,7 @@ import { localToolNames } from "../../safety/local-tool-policy.js";
9
9
  const CLAUDE_PATH_DISCOVERY = Object.freeze({
10
10
  "local-tools-v1": "local_search searches only inside that one file; there is no directory listing or repository-wide search, so take " +
11
11
  "file paths from the objective and from files you have read. ",
12
- "local-tools-v2": "local_search searches only inside that one file. To find a file, use local_find with name for a literal part of " +
13
- "its path, such as MigrateChannelLedgerCommand, or text for a literal it contains, such as migrate:channel-ledger, then " +
14
- "local_search and local_read on the file found. local_find's path is an optional directory inside the approved scope, not a " +
15
- "file; it skips vendor/ and node_modules/ unless that directory is inside them, and truncated: true means it stopped at a " +
16
- "bound, so narrow path, name or text. "
12
+ "local-tools-v2": LOCAL_FIND_DISCOVERY
17
13
  });
18
14
  const claudePathRules = (inventory) => "## Local tool path rules\n" +
19
15
  "Every path argument is one checkout-relative file path inside the approved scope, without a leading slash: write " +
@@ -6,7 +6,7 @@ import { createJobSecurityReceipt } from '../../safety/job-security-receipt.js';
6
6
  import { requireJobSecurity } from '../../safety/job-security-policy.js';
7
7
  import { CODEX_UNSUPPORTED_DIAGNOSTIC } from "./compatibility.js";
8
8
  import { buildCodexInvocation } from "./config-builder.js";
9
- import { compileCodexPrompt, CODEX_REPORT_SCHEMA } from "./prompt-compiler.js";
9
+ import { codexPromptToolInventory, compileCodexPrompt, CODEX_REPORT_SCHEMA } from "./prompt-compiler.js";
10
10
  import { CodexStreamParser } from "./stream-parser.js";
11
11
  function object(value) {
12
12
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -62,7 +62,7 @@ export class CodexAdapter {
62
62
  this.usingOfficialExec = true;
63
63
  let stage = 'codex_prompt';
64
64
  try {
65
- const prompt = compileCodexPrompt(object(ctx.taskPackage), this.options.jobId);
65
+ const prompt = compileCodexPrompt(object(ctx.taskPackage), this.options.jobId, codexPromptToolInventory(approved.security_requirements));
66
66
  stage = 'codex_launch';
67
67
  this.handle = await this.ownedExec.start(ctx, this.options.jobId, prompt, this.startupAbort.signal);
68
68
  this.receipt = this.ownedExec.receipt();
@@ -144,7 +144,7 @@ export class CodexAdapter {
144
144
  }
145
145
  };
146
146
  try {
147
- const prompt = compileCodexPrompt(pkg, this.options.jobId);
147
+ const prompt = compileCodexPrompt(pkg, this.options.jobId, codexPromptToolInventory(approved.security_requirements));
148
148
  this.tools = this.options.createTools(pkg, approved);
149
149
  const endpoint = await this.tools.start();
150
150
  this.assertNotCancelled();
@@ -1,9 +1,26 @@
1
1
  import { renderBundleCorrectionBrief } from '../../verification/continuation-runner-contract.js';
2
2
  import { compilePrompt as compileBrief } from "../prompt-compiler.js";
3
- import { LOCAL_TOOL_GUIDANCE, repositoryScope } from "../local-tool-prompt.js";
4
- export function compileCodexPrompt(taskPackage, label) {
3
+ import { LOCAL_FIND_DISCOVERY, LOCAL_TOOL_GUIDANCE, repositoryScope } from "../local-tool-prompt.js";
4
+ import { localToolNames } from "../../safety/local-tool-policy.js";
5
+ import { localToolInventoryFor } from "../../safety/local-tool-server.js";
6
+ import { securityRequirementsFingerprint } from "../../safety/security-requirements.js";
7
+ // local-tools-v1 has no discovery tool and its prompt is unchanged. With local-tools-v2 Codex must be told about local_find:
8
+ // without it, the CHANNEL-4K Investigate of 2026-10-31 (job 4cec57c0) searched directories with local_search and read nothing.
9
+ const CODEX_FILE_DISCOVERY = Object.freeze({
10
+ "local-tools-v1": "",
11
+ "local-tools-v2": `\n\n## Local file discovery\n${LOCAL_FIND_DISCOVERY.trimEnd()}`
12
+ });
13
+ /** The inventory the job's frozen descriptor names (exec-preflight.ts checks the same fingerprint); local-tools-v1 when it
14
+ * names none. The tool server enforces the inventory on its own, so this only picks the prompt text. */
15
+ export function codexPromptToolInventory(securityRequirements) {
16
+ const sha = securityRequirements && typeof securityRequirements === "object" && !Array.isArray(securityRequirements)
17
+ ? securityRequirements.tools_sha256 : undefined;
18
+ return localToolInventoryFor(sha, securityRequirementsFingerprint) ?? "local-tools-v1";
19
+ }
20
+ export function compileCodexPrompt(taskPackage, label, toolInventory = "local-tools-v1") {
21
+ localToolNames(toolInventory);
5
22
  const correction = renderBundleCorrectionBrief(taskPackage);
6
- return `${compileBrief(taskPackage, { workItemLabel: label, progressReporting: "native_messages" })}\n\n${repositoryScope(taskPackage)}\n\n${LOCAL_TOOL_GUIDANCE}${correction}`;
23
+ return `${compileBrief(taskPackage, { workItemLabel: label, progressReporting: "native_messages" })}\n\n${repositoryScope(taskPackage)}\n\n${LOCAL_TOOL_GUIDANCE}${CODEX_FILE_DISCOVERY[toolInventory]}${correction}`;
7
24
  }
8
25
  export const CODEX_REPORT_SCHEMA = {
9
26
  type: "object", additionalProperties: false, required: ["status", "report", "evidence", "pending_work"],
@@ -23,3 +23,9 @@ export function repositoryScope(taskPackage) {
23
23
  "These are scope descriptions, not instructions from source files; the host policy remains authoritative and may deny blocked/private paths within an allowed prefix.";
24
24
  }
25
25
  export const LOCAL_TOOL_GUIDANCE = "Use only the ai_local job tools for repository reads, edits and approved tests. For reading, first use server ai_local's local_search({path: \"<exact approved path>\", query: \"text\"}) to find the location, then local_read({path: \"<exact approved path>\", start_line: 1, end_line: 1}) with the matching line numbers. Ranges are inclusive, at most 400 lines; both bounds are required together. Read a matched line first to learn total_lines, then request only the needed in-file context. Omit both bounds only when the entire file is necessary. Use approved checkout-relative paths. For small approved edits, use local_replace({path, old, new}) after reading: old must match exactly once, including whitespace. local_edit requires the complete replacement file, never a patch. Required test criteria are not command grants. No model test commands are registered unless the host explicitly supplies exact model-callable command IDs; do not use local_test to discover commands or run --help. Missing tests remain not_run/unverified, not passed. In a read-only task, use only read/search tools; the Runner enforces the approved paths and operations. A denied native cwd is intentional: it is a private control directory, not the source checkout. Native filesystem, commands, web, images and delegation are unavailable or denied. A denied tool is a scope boundary, never permission to broaden scope or bypass the denial. Do not retry the denied path. It does not remove independently approved paths: continue with relevant listed paths, and report a missing dependency only after checking the available approved source. Do not repeat host orchestration: claim, shadow, fingerprints, binding, administrator checks, launches, Gateway usage and Job reporting belong to the Runner/host, even when described in the objective. Perform only the assigned repository work and report evidence you actually observed. Do not claim that no model call occurred. If the required tool is unavailable, emit one final structured blocked report naming the missing tool and unfinished repository work; do not request or attempt a new launch.";
26
+ /** How to find a file with local-tools-v2 (Work Decision f43e7aa0). Shared by the Claude and Codex prompts. */
27
+ export const LOCAL_FIND_DISCOVERY = "local_search searches only inside that one file. To find a file, use local_find with name for a literal part of " +
28
+ "its path, such as MigrateChannelLedgerCommand, or text for a literal it contains, such as migrate:channel-ledger, then " +
29
+ "local_search and local_read on the file found. local_find's path is an optional directory inside the approved scope, not a " +
30
+ "file; it skips vendor/ and node_modules/ unless that directory is inside them, and truncated: true means it stopped at a " +
31
+ "bound, so narrow path, name or text. ";
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { constants } from "node:fs";
3
- import { access, cp, mkdir, readdir, rm, stat } from "node:fs/promises";
3
+ import { access, cp, mkdir, readdir, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { basename, delimiter, isAbsolute, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { packageVersion } from "../package-version.js";
@@ -13,8 +13,14 @@ import { packageVersion } from "../package-version.js";
13
13
  * Only a CLI that loads `modules` hooks can install it. On 2026-10-30 the terminal `claude` 2.1.212 could not, and the
14
14
  * desktop-bundled 2.1.299 and 2.1.308 could; the desktop app bundles one even when the terminal `claude` is older.
15
15
  * A mod the person disabled stays disabled: setup reinstalls only a missing or older one.
16
+ *
17
+ * Every setup installs the mod, with or without --integrations (owner decision 2026-10-31), unless the person removed
18
+ * it with `integrations remove claude`. That removal leaves <stateRoot>/claude-mod-removed, which
19
+ * `integrations install claude` and uninstall delete again.
16
20
  */
17
21
  export const CLAUDE_MOD_NAME = "ai-gateway-work-room";
22
+ /** <stateRoot>/claude-mod-removed: the person removed the mod, so setup leaves it out until they install it again. */
23
+ export const CLAUDE_MOD_REMOVED_FILE = "claude-mod-removed";
18
24
  export const CLAUDE_MOD_MARKETPLACE = "mtec-ai-gateway";
19
25
  const ID = `${CLAUDE_MOD_NAME}@${CLAUDE_MOD_MARKETPLACE}`;
20
26
  const MIN_VERSION = [2, 1, 299];
@@ -126,6 +132,20 @@ export async function removeClaudeMod(deps) {
126
132
  await rm(join(deps.stateRoot, "claude-mod"), { recursive: true, force: true }).catch(() => undefined);
127
133
  return { state: "removed", ...(cli ? { cli } : {}) };
128
134
  }
135
+ /** Whether the person removed the mod with `integrations remove claude` since it was last installed on request. */
136
+ export async function claudeModRemovedByPerson(stateRoot) {
137
+ return stat(join(stateRoot, CLAUDE_MOD_REMOVED_FILE)).then(entry => entry.isFile(), () => false);
138
+ }
139
+ /** Records or clears the person's removal. */
140
+ export async function setClaudeModRemovedByPerson(stateRoot, removed) {
141
+ const file = join(stateRoot, CLAUDE_MOD_REMOVED_FILE);
142
+ if (!removed) {
143
+ await rm(file, { force: true });
144
+ return;
145
+ }
146
+ await mkdir(stateRoot, { recursive: true });
147
+ await writeFile(file, "Removed with integrations remove claude. Run integrations install claude to add the Work Room pane again.\n", { mode: 0o600 });
148
+ }
129
149
  /** What `plugin list` says of the mod. Never throws. */
130
150
  export async function claudeModStatus(deps) {
131
151
  const cli = await findClaudeCli(deps);
@@ -1,7 +1,7 @@
1
1
  import { readdir, rm } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { ASKS_FILE } from "./store.js";
4
- import { claudeModDeps, claudeModStatus, installClaudeMod, removeClaudeMod } from "./claude-mod.js";
4
+ import { claudeModDeps, claudeModRemovedByPerson, claudeModStatus, installClaudeMod, removeClaudeMod, setClaudeModRemovedByPerson } from "./claude-mod.js";
5
5
  import { homeDirectory, installIntegration, integrationStatus, removeIntegration, removeShimIfUnused } from "./integrations.js";
6
6
  import { APPS, isApp } from "./types.js";
7
7
  import { deleteGuideState } from "../guide/files.js";
@@ -89,6 +89,16 @@ async function claudeMod(env, stateRoot, act) {
89
89
  return { state: "failed", reason: errorCode(error) };
90
90
  }
91
91
  }
92
+ /**
93
+ * setup's Work Room mod: installed on every setup, with or without --integrations (owner decision 2026-10-31), unless
94
+ * the person removed it with `integrations remove claude`. Never throws.
95
+ */
96
+ export async function setupClaudeMod(env, stateRoot) {
97
+ if (await claudeModRemovedByPerson(stateRoot)) {
98
+ return { state: "skipped", reason: "removed with integrations remove claude; run integrations install claude to add it again" };
99
+ }
100
+ return claudeMod(env, stateRoot, installClaudeMod);
101
+ }
92
102
  /** The Work Room mod's state, for integrations status and doctor. It runs the Claude CLI, so the daemon never calls it. */
93
103
  export const claudeModStatusOf = (env, stateRoot) => claudeMod(env, stateRoot, claudeModStatus);
94
104
  async function attempt(app, env, stateRoot, change) {
@@ -104,7 +114,10 @@ export async function installIntegrations(apps, env, stateRoot) {
104
114
  for (const app of apps) {
105
115
  const result = await attempt(app, env, stateRoot, () => installIntegration(app, env, stateRoot));
106
116
  const done = result.error === undefined ? { ...result, action: NEXT_STEP[app] } : result;
107
- // The hooks first: they work without the mod, and a Claude CLI too old for the mod leaves them in place.
117
+ // The hooks first: they work without the mod, and a Claude CLI too old for the mod leaves them in place. Installing
118
+ // on request undoes an earlier removal, so setup keeps the mod again.
119
+ if (app === "claude")
120
+ await setClaudeModRemovedByPerson(stateRoot, false).catch(() => undefined);
108
121
  results.push(app === "claude" ? { ...done, mod: await claudeMod(env, stateRoot, installClaudeMod) } : done);
109
122
  }
110
123
  return results;
@@ -163,6 +176,8 @@ export async function removeAsksInstall(env, stateRoot) {
163
176
  const results = [];
164
177
  for (const app of APPS)
165
178
  results.push(await removeAsks(app, env, stateRoot));
179
+ // Uninstall removes everything, so a later setup starts fresh and installs the Work Room mod again.
180
+ await setClaudeModRemovedByPerson(stateRoot, false).catch(() => undefined);
166
181
  // A hook left behind (an unreadable Codex hooks file) keeps its shim, which then prints nothing and exits 0.
167
182
  await removeShimIfUnused(env, stateRoot).catch(() => false);
168
183
  await rm(join(stateRoot, ASKS_FILE), { force: true });
@@ -187,6 +202,9 @@ export async function runIntegrationsCommand(argv, io) {
187
202
  else if (parsed.action === "remove") {
188
203
  for (const app of parsed.apps)
189
204
  results.push(await removeAsks(app, io.env, io.stateRoot));
205
+ // The person removed the Work Room mod: setup leaves it out until integrations install claude.
206
+ if (parsed.apps.includes("claude"))
207
+ await setClaudeModRemovedByPerson(io.stateRoot, true).catch(() => undefined);
190
208
  await removeShimIfUnused(io.env, io.stateRoot).catch(() => false);
191
209
  }
192
210
  else {
@@ -1,9 +1,8 @@
1
- import { createHash } from "node:crypto";
2
1
  import { join } from "node:path";
3
2
  import { McpGatewayClient } from "../gateway-client.js";
4
3
  import { Daemon } from "../daemon/daemon.js";
5
4
  import { ProcessLock } from "../daemon/process-lock.js";
6
- import { runnerDisplayName } from "../daemon/runtime.js";
5
+ import { runnerDeviceFingerprint, runnerDisplayName } from "../daemon/runtime.js";
7
6
  import { SessionOpener, sessionOpenAdvertisement } from "../daemon/session-opener.js";
8
7
  import { RunnerTransport } from "../transport/runner-transport.js";
9
8
  import { ReverbSocket } from "../transport/reverb-socket.js";
@@ -108,7 +107,7 @@ export async function buildMessengerDaemon(input) {
108
107
  const askInbox = await asks.advertisement();
109
108
  const guidance = await guide.advertisement();
110
109
  await gateway.heartbeatLocalRunner({
111
- deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
110
+ deviceFingerprint: runnerDeviceFingerprint(input.gatewayMcpUrl),
112
111
  displayName: runnerDisplayName(), packageVersion: packageVersion(), capabilities: [], mode: "messenger",
113
112
  // 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.
114
113
  ...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
package/dist/src/cli.js CHANGED
@@ -25,14 +25,16 @@ import { createBrowserOpener } from "./auth/browser-opener.js";
25
25
  import { FileLocalConfigStore, runnerMode } from "./registration/local-config.js";
26
26
  import { LaunchdService } from "./platform/macos/launchd.js";
27
27
  import { installStableRelease } from "./platform/macos/stable-release.js";
28
- import { buildDaemon, runnerDisplayName } from "./daemon/runtime.js";
29
- import { createHash } from "node:crypto";
28
+ import { packageVersion } from "./package-version.js";
29
+ import { reportRefusal } from "./registration/refusal-report.js";
30
+ import { selfChecksReport } from "./safety/self-checks.js";
31
+ import { buildDaemon, runnerDeviceFingerprint, runnerDisplayName } from "./daemon/runtime.js";
30
32
  import { discoverEmployeeAdapters, hasUsableAdapter, adapterDiagnosticsWithRemedies } from "./adapters/discovery.js";
31
33
  import { verifyPackagedHook } from "./safety/hook-command.js";
32
34
  import { access, mkdir, rm } from "node:fs/promises";
33
35
  import { buildMessengerDaemon } from "./asks/messenger.js";
34
36
  import { runHookClient } from "./asks/hook-client.js";
35
- import { installGuideIntegrations, installIntegrations, parseFeatures, parseSetupIntegrations, removeAsksInstall, removeGuideInstall, runIntegrationsCommand } from "./asks/commands.js";
37
+ import { installGuideIntegrations, installIntegrations, parseFeatures, parseSetupIntegrations, removeAsksInstall, removeGuideInstall, runIntegrationsCommand, setupClaudeMod } from "./asks/commands.js";
36
38
  import { asksDoctorReport } from "./asks/doctor.js";
37
39
  import { guideDoctorReport, guideReportShown } from "./guide/doctor.js";
38
40
  import { runClaudeReleaseCommand } from "./commands/claude-release.js";
@@ -259,6 +261,8 @@ export async function main(argv, env, io = {}) {
259
261
  const hook = await hookSelfTest(config.workspaceRoot);
260
262
  if (!hook.enforcing) {
261
263
  stdout(JSON.stringify({ ok: false, configured: false, hook: hookReport(hook), action: hookAction(hook) }));
264
+ await reportRefusal({ stateRoot: config.stateRoot, env, fetchFn, report: selfChecksReport({ source: "setup", packageVersion: packageVersion(),
265
+ checkedAt: Math.floor(Date.now() / 1000), tools: null, hook, refusalCode: "hook_not_enforcing" }) });
262
266
  throw new Error(`The PreToolUse hook did not deny the probe call (hook_not_enforcing: ${hook.reason})`);
263
267
  }
264
268
  // A full runner whose job file tools cannot read here would take jobs and stop executor_blocked on every one
@@ -268,6 +272,8 @@ export async function main(argv, env, io = {}) {
268
272
  if (tools.status !== "passed") {
269
273
  const report = localToolsReport(tools);
270
274
  stdout(JSON.stringify({ ok: false, configured: false, local_tools: report, action: report.action }));
275
+ await reportRefusal({ stateRoot: config.stateRoot, env, fetchFn, report: selfChecksReport({ source: "setup", packageVersion: packageVersion(),
276
+ checkedAt: Math.floor(Date.now() / 1000), tools, hook, refusalCode: "local_tools_failed" }) });
271
277
  throw new Error(`The job file tools failed on this Mac (local_tools_failed: ${tools.tool ?? "setup"}: ${tools.detail ?? "unknown"})`);
272
278
  }
273
279
  }
@@ -285,8 +291,8 @@ export async function main(argv, env, io = {}) {
285
291
  throw new Error("No usable authenticated runtime adapter is available. Run doctor for adapter diagnostics.");
286
292
  }
287
293
  const registered = await gateway.registerLocalRunner({
288
- deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${config.gatewayMcpUrl}`).digest("hex"),
289
- displayName: runnerDisplayName(), packageVersion: "0.1.11",
294
+ deviceFingerprint: runnerDeviceFingerprint(config.gatewayMcpUrl),
295
+ displayName: runnerDisplayName(), packageVersion: packageVersion(),
290
296
  capabilities: mode === "full" ? adapters.map(probe => ({ adapter: probe.id, version: probe.version, capabilities: probe.capabilities, ...adapterStatus(probe) })) : [],
291
297
  mode,
292
298
  });
@@ -296,12 +302,14 @@ export async function main(argv, env, io = {}) {
296
302
  const features = requestedFeatures ?? ["asks"];
297
303
  const integrations = features.includes("asks") ? await installIntegrations(requestedIntegrations ?? [], env, config.stateRoot) : [];
298
304
  const guide = features.includes("guide") ? await installGuideIntegrations(requestedIntegrations ?? [], env, config.stateRoot) : undefined;
305
+ // The Work Room pane goes with every setup (owner decision 2026-10-31); --integrations claude has just installed it.
306
+ const workRoomPane = integrations.some(report => report.app === "claude" && report.mod !== undefined) ? undefined : await setupClaudeMod(env, config.stateRoot);
299
307
  // The LaunchAgent runs a copy under releases/<version>, never the npx cache that may have run setup.
300
308
  const daemonExecutable = await (io.installRelease ?? installStableRelease)({ entry: process.argv[1], stateRoot: config.stateRoot });
301
309
  await launchd.install(daemonExecutable, config.stateRoot);
302
310
  stdout(JSON.stringify(mode === "full"
303
- ? { configured: true, mode, runnerId: registered.runner.id, adapters, integrations, ...(guide ? { guide } : {}), daemonExecutable }
304
- : { configured: true, mode, runnerId: registered.runner.id, adapters: adapterDiagnosticsWithRemedies(detected), integrations, ...(guide ? { guide } : {}), daemonExecutable,
311
+ ? { configured: true, mode, runnerId: registered.runner.id, adapters, integrations, ...(guide ? { guide } : {}), ...(workRoomPane ? { workRoomPane } : {}), daemonExecutable }
312
+ : { configured: true, mode, runnerId: registered.runner.id, adapters: adapterDiagnosticsWithRemedies(detected), integrations, ...(guide ? { guide } : {}), ...(workRoomPane ? { workRoomPane } : {}), daemonExecutable,
305
313
  action: "No executor is admitted on this Mac, so the runner is set up as a messenger: it delivers AI Gateway asks and opens sessions, and runs no jobs. Resolve the adapter diagnostics, then rerun setup to run jobs." }));
306
314
  return 0;
307
315
  }
@@ -416,6 +424,8 @@ export async function main(argv, env, io = {}) {
416
424
  if (!hook.enforcing) {
417
425
  stderr(JSON.stringify({ event: "hook_not_enforcing", reason: hook.reason,
418
426
  ...(hook.exitCode === null ? {} : { exit_code: hook.exitCode }), ...(hook.detail ? { detail: hook.detail } : {}) }));
427
+ await reportRefusal({ stateRoot: config.stateRoot, env, fetchFn, report: selfChecksReport({ source: "daemon_start", packageVersion: packageVersion(),
428
+ checkedAt: Math.floor(Date.now() / 1000), tools: null, hook, refusalCode: "hook_not_enforcing" }) });
419
429
  return 1;
420
430
  }
421
431
  }
@@ -30,7 +30,9 @@ import { PendingClosureAcks, createSyncDispatcher } from "../transport/sync-disp
30
30
  import { abortableDelay } from "../transport/abortable-delay.js";
31
31
  import { sideLoops } from "../transport/side-loops.js";
32
32
  import { buildTerminalEvidenceCollector } from "../safety/terminal-evidence.js";
33
- import { packagedHookCommandParts } from "../safety/hook-command.js";
33
+ import { packagedHookCommandParts, verifyPackagedHook } from "../safety/hook-command.js";
34
+ import { localToolSelfTest } from "../safety/local-tool-self-test.js";
35
+ import { SelfCheckMonitor } from "./self-check-monitor.js";
34
36
  import { composeReviewedP1Host } from '../verification/p1-host-composition.js';
35
37
  import { AskRuntime, withAsks } from '../asks/runtime.js';
36
38
  import { GuideRuntime, withGuide } from '../guide/runtime.js';
@@ -196,6 +198,15 @@ export async function buildDaemon(input) {
196
198
  // Until now the heartbeat said this runner takes no jobs; tell the Gateway at once, and ask whether one waits.
197
199
  onArmed: () => { void heartbeats.tick(); transport?.requestSync("start"); },
198
200
  });
201
+ // Work Item 2549b1b0: the file tools and hook checked with this daemon's node, sent in every heartbeat.
202
+ const selfChecks = new SelfCheckMonitor({
203
+ workspaceRoot: input.workspaceRoot,
204
+ localTools: input.selfChecks?.localTools ?? localToolSelfTest,
205
+ hook: input.selfChecks?.hook ?? verifyPackagedHook,
206
+ packageVersion: () => packageVersion(),
207
+ changed: () => { void heartbeats.tick(); },
208
+ ...(input.selfChecks?.intervalMs ? { intervalMs: input.selfChecks.intervalMs } : {}),
209
+ });
199
210
  const heartbeats = new HeartbeatScheduler({
200
211
  intervalMs: 30_000,
201
212
  snapshot: async () => {
@@ -259,13 +270,14 @@ export async function buildDaemon(input) {
259
270
  const askInbox = await asks.advertisement();
260
271
  const guidance = await guide.advertisement();
261
272
  return {
262
- deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
273
+ deviceFingerprint: runnerDeviceFingerprint(input.gatewayMcpUrl),
263
274
  displayName: runnerDisplayName(), packageVersion: packageVersion(),
264
275
  capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationModes ?? localFixHost?.verificationMode), poller.accepting),
265
276
  mode: "full",
266
277
  ...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
267
278
  ...(guidance ? { guidance } : {}),
268
279
  localFix: localFixReport(),
280
+ ...(selfChecks.report() ? { selfChecks: selfChecks.report() } : {}),
269
281
  };
270
282
  },
271
283
  send: async (payload) => { await gateway.heartbeatLocalRunner(payload); },
@@ -340,6 +352,7 @@ export async function buildDaemon(input) {
340
352
  });
341
353
  transport = pushTransport;
342
354
  const sessionOpens = sideLoops({ heartbeat: heartbeats, transport: pushTransport, onStop: () => stopping.abort() });
355
+ selfChecks.start();
343
356
  const daemon = new Daemon({
344
357
  lock: new ProcessLock(join(input.stateRoot, "daemon.lock")), state,
345
358
  reconcile: async (stored) => {
@@ -358,6 +371,7 @@ export async function buildDaemon(input) {
358
371
  // Model tools have no Gateway relay. Each adapter owns and closes its job endpoint.
359
372
  // The Claude host always closes; a failing Claude close never masks a Codex close failure.
360
373
  return { daemon, close: async () => {
374
+ selfChecks.stop();
361
375
  daemon.stop();
362
376
  try {
363
377
  await production?.close();
@@ -370,6 +384,10 @@ export async function buildDaemon(input) {
370
384
  } };
371
385
  }
372
386
  export function runnerDisplayName() { return `${hostname()} local AI`; }
387
+ /** The fingerprint setup registers and every heartbeat repeats; a refusal report must match it too (Work Item 2549b1b0). */
388
+ export function runnerDeviceFingerprint(gatewayMcpUrl) {
389
+ return createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${gatewayMcpUrl}`).digest("hex");
390
+ }
373
391
  /** The execution blocker a full runner reports for every adapter while it takes no jobs. */
374
392
  export const RUNNER_NOT_ACCEPTING_JOBS = "RUNNER_NOT_ACCEPTING_JOBS";
375
393
  /*
@@ -0,0 +1,43 @@
1
+ import { selfChecksReport } from "../safety/self-checks.js";
2
+ export const SELF_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
3
+ /**
4
+ * Runs the job file tool and hook self-checks with this daemon's own node (Work Item 2549b1b0): the node every job uses,
5
+ * which doctor's terminal node may not be. A check that throws counts as failed; nothing here stops job claims.
6
+ */
7
+ export class SelfCheckMonitor {
8
+ deps;
9
+ latest;
10
+ timer;
11
+ stopped = false;
12
+ constructor(deps) {
13
+ this.deps = deps;
14
+ }
15
+ report() { return this.latest; }
16
+ start() {
17
+ const loop = () => {
18
+ if (this.stopped)
19
+ return;
20
+ void this.check().finally(() => {
21
+ if (!this.stopped)
22
+ this.timer = setTimeout(loop, this.deps.intervalMs ?? SELF_CHECK_INTERVAL_MS);
23
+ });
24
+ };
25
+ loop();
26
+ }
27
+ stop() {
28
+ this.stopped = true;
29
+ if (this.timer)
30
+ clearTimeout(this.timer);
31
+ }
32
+ async check() {
33
+ const [tools, hook] = await Promise.all([
34
+ this.deps.localTools(this.deps.workspaceRoot).catch(() => ({ status: "failed", node: process.execPath })),
35
+ this.deps.hook(this.deps.workspaceRoot).catch(() => ({ enforcing: false, reason: "failed", exitCode: null })),
36
+ ]);
37
+ if (this.stopped)
38
+ return;
39
+ this.latest = selfChecksReport({ source: "daemon", packageVersion: this.deps.packageVersion(),
40
+ checkedAt: Math.floor((this.deps.now?.() ?? Date.now()) / 1000), tools, hook });
41
+ this.deps.changed();
42
+ }
43
+ }
@@ -568,9 +568,18 @@ export class McpGatewayClient {
568
568
  ...(input.sessionOpen ? { session_open: input.sessionOpen } : {}),
569
569
  ...(input.guidance ? { guidance: input.guidance } : {}),
570
570
  ...(input.localFix !== undefined ? { local_fix: input.localFix } : {}),
571
+ ...(input.selfChecks ? { self_checks: input.selfChecks } : {}),
571
572
  },
572
573
  }, signal);
573
574
  }
575
+ /** A refused setup or daemon start, reported once before the process exits (Work Item 2549b1b0). */
576
+ async reportRunnerChecks(input, signal) {
577
+ return this.callTool("local_agent_runner_check_report", {
578
+ runner_id: this.runnerId,
579
+ device_fingerprint: input.deviceFingerprint,
580
+ self_checks: input.selfChecks,
581
+ }, signal);
582
+ }
574
583
  async waitForSessionOpens(waitSeconds = 25, signal) {
575
584
  const result = await this.callTool("local_agent_session_opens_wait", {
576
585
  runner_id: this.runnerId,
@@ -0,0 +1,27 @@
1
+ import { FileOAuthCredentialStore } from "../auth/credential-store.js";
2
+ import { OAuthClient } from "../auth/oauth-client.js";
3
+ import { OAuthTokenProvider } from "../auth/token-provider.js";
4
+ import { McpGatewayClient } from "../gateway-client.js";
5
+ import { FileLocalConfigStore } from "./local-config.js";
6
+ import { runnerDeviceFingerprint } from "../daemon/runtime.js";
7
+ const REPORT_TIMEOUT_MS = 5000;
8
+ /**
9
+ * Tell the Gateway once that setup or the daemon start refused (Work Item 2549b1b0), with the identity and login an
10
+ * earlier setup saved. A first install has neither, so it reports nothing. Advisory: it never throws and never waits
11
+ * more than 5 seconds, so the refusal's message and exit code stay as they were.
12
+ */
13
+ export async function reportRefusal(input) {
14
+ try {
15
+ const local = await new FileLocalConfigStore(input.stateRoot).load();
16
+ if (!local)
17
+ return;
18
+ const tokenProvider = new OAuthTokenProvider({ store: new FileOAuthCredentialStore(input.stateRoot),
19
+ oauthClient: new OAuthClient({ resource: local.gatewayMcpUrl, fetchFn: input.fetchFn }), resource: local.gatewayMcpUrl,
20
+ tokenOverride: input.env.AI_GATEWAY_LOCAL_RUNNER_TOKEN, clock: () => Date.now() });
21
+ const gateway = new McpGatewayClient({ mcpUrl: local.gatewayMcpUrl, tokenProvider, runnerId: local.runnerId, fetchFn: input.fetchFn });
22
+ await gateway.reportRunnerChecks({ deviceFingerprint: runnerDeviceFingerprint(local.gatewayMcpUrl), selfChecks: input.report }, AbortSignal.timeout(REPORT_TIMEOUT_MS));
23
+ }
24
+ catch {
25
+ // The refusal itself is what the employee must act on; the report is only for the owner.
26
+ }
27
+ }
@@ -0,0 +1,31 @@
1
+ /** A hint for the reader; local_tools.status is the signal. */
2
+ export function nodeKind(execPath) {
3
+ if (execPath.includes("/opt/homebrew/") || execPath.includes("/Cellar/"))
4
+ return "homebrew";
5
+ if (execPath.includes("/.nvm/"))
6
+ return "nvm";
7
+ if (execPath === "/usr/local/bin/node")
8
+ return "official";
9
+ return "other";
10
+ }
11
+ export function selfChecksReport(input) {
12
+ const tools = input.tools;
13
+ return {
14
+ version: 1,
15
+ source: input.source,
16
+ package_version: input.packageVersion,
17
+ checked_at: input.checkedAt,
18
+ local_tools: tools === null ? null : {
19
+ status: tools.status,
20
+ ...(tools.status === "failed" && tools.tool ? { tool: tools.tool } : {}),
21
+ ...(tools.sandbox?.reason ? { sandbox_reason: tools.sandbox.reason } : {}),
22
+ node_kind: nodeKind(tools.node),
23
+ node_version: input.nodeVersion ?? process.version,
24
+ },
25
+ hook: input.hook === null ? null : {
26
+ status: input.hook.enforcing ? "enforcing" : "hook_not_enforcing",
27
+ ...(input.hook.enforcing ? {} : { reason: input.hook.reason }),
28
+ },
29
+ ...(input.refusalCode ? { refusal_code: input.refusalCode } : {}),
30
+ };
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.80",
3
+ "version": "0.1.82",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {