mtech-ai-local-runner 0.1.79 → 0.1.81

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,6 +298,17 @@ 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 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.
311
+
301
312
  The runner copies the mod to `claude-mod/<version>/` in the state root, without its tests, and installs it with the
302
313
  Claude CLI as a local marketplace (`plugin marketplace add` and `plugin install --scope user`). This needs a Claude CLI
303
314
  2.1.299 or newer: the first `claude` on `PATH` when it is new enough, else the newest one the Claude desktop app
@@ -305,6 +316,12 @@ bundles. Without one, the hooks are installed as before and the result reports t
305
316
  only a missing or older copy, and it never turns on a mod you turned off. `integrations status` and `doctor` report the
306
317
  mod's state under `mod`; `integrations remove claude` and `uninstall` remove it. The daemon never runs the Claude CLI.
307
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
+
308
325
  `CLAUDE_CONFIG_DIR` and `CODEX_HOME` are honoured. The LaunchAgent that runs the daemon gets only `PATH`, so every
309
326
  install adds the directory it used to a list per app in `integrations.json` in the state root, and the daemon,
310
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.0.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" }
@@ -33,15 +33,15 @@ export async function callGateway($: EngineInterface, name: GatewayTool, input:
33
33
  text?: string
34
34
  isError?: boolean
35
35
  }
36
- if (answer.deny !== undefined) return { ok: false, state: 'refused', detail: clean(answer.deny, 160) }
36
+ if (answer.deny !== undefined) return { ok: false, state: 'refused', detail: clean(answer.deny, 400) }
37
37
  if (answer.isError) {
38
- const detail = clean(typeof answer.result === 'string' ? answer.result : answer.text, 160)
38
+ const detail = clean(typeof answer.result === 'string' ? answer.result : answer.text, 400)
39
39
 
40
40
  return { ok: false, state: 'error', detail: detail || 'Gateway error' }
41
41
  }
42
42
 
43
43
  return { ok: true, payload: { result: answer.result, text: answer.text } }
44
44
  } catch (error) {
45
- return { ok: false, state: 'error', detail: clean(error instanceof Error ? error.message : 'Gateway call failed', 160) }
45
+ return { ok: false, state: 'error', detail: clean(error instanceof Error ? error.message : 'Gateway call failed', 400) }
46
46
  }
47
47
  }
@@ -1 +1,34 @@
1
- { "modules": ["./register.tsx"] }
1
+ {
2
+ "modules": ["./register.tsx"],
3
+ "hooks": {
4
+ "PreToolUse": [
5
+ {
6
+ "matcher": "^mcp__.+__(work_room_read|work_inbox)$",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Work Room pane: reading your own AI Gateway Rooms and requests is allowed.\"}}'"
11
+ }
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
+ ]
31
+ }
32
+ ]
33
+ }
34
+ }
@@ -9,12 +9,18 @@ import { bandText, chatPrompt, findPayload, parseActivity, parseInbox, parseMess
9
9
  * The Work Room pane (Work Item 98da2635, spec 2026-10-30): Room shares and requests from colleagues beside the
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
+ *
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.
12
18
  */
13
19
 
14
20
  export const PANE = 'work-room'
15
21
  const TITLE = 'Work Room'
16
22
  const REFRESH_MS = 60_000
17
- const NO_CONNECTOR = 'Connect AI Gateway in Claude to use Work Room.'
23
+ const NO_CONNECTOR = 'This session has no AI Gateway Work Room tools. Connect AI Gateway in Claude, reconnect it with /mcp, or start a new session.'
18
24
 
19
25
  const counts = atom({ plugin: 'ai-gateway-work-room', key: 'counts' } as const, { shares: 0, requests: 0 } as Counts)
20
26
  const activity = atom({ plugin: 'ai-gateway-work-room', key: 'activity' } as const, [] as RoomActivity[])
@@ -35,19 +41,19 @@ async function fail($: EngineInterface, answer: { state: Status['state']; detail
35
41
  await update($, status, () => ({ state: answer.state, detail: answer.state === 'no-connector' ? NO_CONNECTOR : answer.detail }))
36
42
  }
37
43
 
38
- /** Counts only: Room activity and open inbox items, never message bodies. */
44
+ /** Counts only: Room activity and open inbox items, never message bodies. Each half shows even when the other fails. */
39
45
  export async function refreshCounts($: EngineInterface): Promise<void> {
40
46
  const [roomAnswer, inboxAnswer] = await Promise.all([
41
47
  callGateway($, 'work_room_read', { operation: 'activity', limit: 100 }),
42
48
  callGateway($, 'work_inbox', { direction: 'to_me' }),
43
49
  ])
50
+ if (roomAnswer.ok) await update($, activity, () => parseActivity(findPayload(roomAnswer.payload, v => Array.isArray(v.rooms))) ?? [])
51
+ if (inboxAnswer.ok) await update($, inbox, () => parseInbox(findPayload(inboxAnswer.payload, v => Array.isArray(v.items))) ?? [])
52
+ const rows = await read($, activity)
53
+ const items = await read($, inbox)
54
+ await update($, counts, () => ({ shares: rows.reduce((sum, row) => sum + row.unread, 0), requests: items.length }))
44
55
  if (!roomAnswer.ok) return fail($, roomAnswer)
45
56
  if (!inboxAnswer.ok) return fail($, inboxAnswer)
46
- const rows = parseActivity(findPayload(roomAnswer.payload, v => Array.isArray(v.rooms))) ?? []
47
- const items = parseInbox(findPayload(inboxAnswer.payload, v => Array.isArray(v.items))) ?? []
48
- await update($, activity, () => rows)
49
- await update($, inbox, () => items)
50
- await update($, counts, () => ({ shares: rows.reduce((sum, row) => sum + row.unread, 0), requests: items.length }))
51
57
  await update($, status, () => ({ state: 'ok', detail: '' }))
52
58
  }
53
59
 
@@ -76,7 +82,10 @@ export async function reportPresented($: EngineInterface, roomId: string, ids: s
76
82
  const fresh = ids.filter(id => !presentedSent.has(id)).slice(0, 100)
77
83
  if (fresh.length === 0) return
78
84
  for (const id of fresh) presentedSent.add(id)
79
- const sent = await callGateway($, 'work_room_receipt_report', { room_id: roomId, message_ids: fresh, state: 'presented' })
85
+ const name = (await read($, rooms))[roomId]?.info.name ?? 'a Room'
86
+ // The person's press on Open is what showed these messages, so the receipt carries it as consent.
87
+ const consent = `The user opened Room ${name} in the Work Room pane, which showed them ${fresh.length} message${fresh.length === 1 ? '' : 's'}.`
88
+ const sent = await callGateway($, 'work_room_receipt_report', { room_id: roomId, message_ids: fresh, state: 'presented' }, consent)
80
89
  if (!sent.ok) {
81
90
  for (const id of fresh) presentedSent.delete(id)
82
91
  return fail($, sent)
@@ -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();
@@ -57,7 +57,8 @@ export function assessCodexPreModel(actual, expected, supported) {
57
57
  require(server.status === 'ready', 'MCP_NOT_READY');
58
58
  require(server.toolSchemaFingerprint === expected.toolSchemaFingerprint, 'MCP_SCHEMA_MISMATCH');
59
59
  // Exactly the tools of the inventory the expected fingerprint names (Work Decision f43e7aa0), never the other one.
60
- const inventory = localToolInventoryFor(expected.toolSchemaFingerprint, fingerprint);
60
+ // The native session fingerprints the bare tool array (validateNativeTools), not the descriptor's {tools} form.
61
+ const inventory = localToolInventoryFor(expected.toolSchemaFingerprint, value => fingerprint(value.tools));
61
62
  require(inventory !== undefined && Array.isArray(server.tools) && server.tools.every(tool => typeof tool === 'string')
62
63
  && JSON.stringify([...server.tools].sort()) === JSON.stringify([...localToolNames(inventory)].sort()), 'MCP_TOOLS_MISMATCH');
63
64
  }
@@ -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 {
package/dist/src/cli.js CHANGED
@@ -32,7 +32,7 @@ import { verifyPackagedHook } from "./safety/hook-command.js";
32
32
  import { access, mkdir, rm } from "node:fs/promises";
33
33
  import { buildMessengerDaemon } from "./asks/messenger.js";
34
34
  import { runHookClient } from "./asks/hook-client.js";
35
- import { installGuideIntegrations, installIntegrations, parseFeatures, parseSetupIntegrations, removeAsksInstall, removeGuideInstall, runIntegrationsCommand } from "./asks/commands.js";
35
+ import { installGuideIntegrations, installIntegrations, parseFeatures, parseSetupIntegrations, removeAsksInstall, removeGuideInstall, runIntegrationsCommand, setupClaudeMod } from "./asks/commands.js";
36
36
  import { asksDoctorReport } from "./asks/doctor.js";
37
37
  import { guideDoctorReport, guideReportShown } from "./guide/doctor.js";
38
38
  import { runClaudeReleaseCommand } from "./commands/claude-release.js";
@@ -42,7 +42,16 @@ import { localToolSelfTest } from "./safety/local-tool-self-test.js";
42
42
  function localToolsReport(result) {
43
43
  if (result.status === "passed")
44
44
  return { ...result };
45
- return { ...result, action: `Local file tools failed on this Mac (${result.tool ?? "setup"}: ${result.detail ?? "unknown"}), so a job here cannot read source. Send this doctor output to the AI Gateway owner.` };
45
+ return { ...result, action: localToolsAction(result) };
46
+ }
47
+ function localToolsAction(result) {
48
+ const failed = `Local file tools failed on this Mac (${result.tool ?? "setup"}: ${result.detail ?? "unknown"}), so a job here cannot read source.`;
49
+ // A bare restricted node that also fails cannot start in the sandbox at all. On 2026-10-31 Homebrew's node did this on
50
+ // an employee Mac: it maps libraries from /opt/homebrew, and the sandbox maps only /usr/lib and /System/Library.
51
+ if (result.sandbox?.status === "failed") {
52
+ return `${failed} The Node at ${result.node} cannot start inside the runner's restricted sandbox, which loads libraries only from /usr/lib and /System/Library; a Node built against libraries elsewhere, such as Homebrew's, stops there. Install an official Node.js build from https://nodejs.org, then rerun setup and doctor with its bin directory first on PATH, for example: PATH="<official Node>/bin:$PATH" npx mtech-ai-local-runner@latest setup`;
53
+ }
54
+ return `${failed} Send this doctor output to the AI Gateway owner.`;
46
55
  }
47
56
  function hookAction(result) {
48
57
  return result.reason === "invalid_command"
@@ -252,6 +261,15 @@ export async function main(argv, env, io = {}) {
252
261
  stdout(JSON.stringify({ ok: false, configured: false, hook: hookReport(hook), action: hookAction(hook) }));
253
262
  throw new Error(`The PreToolUse hook did not deny the probe call (hook_not_enforcing: ${hook.reason})`);
254
263
  }
264
+ // A full runner whose job file tools cannot read here would take jobs and stop executor_blocked on every one
265
+ // (Work Item 6f9e64ae), so setup refuses before login, as for the hook.
266
+ const tools = await (io.localToolSelfTest ?? localToolSelfTest)(config.workspaceRoot)
267
+ .catch(error => ({ status: "failed", detail: error instanceof Error ? error.message : String(error), node: process.execPath }));
268
+ if (tools.status !== "passed") {
269
+ const report = localToolsReport(tools);
270
+ stdout(JSON.stringify({ ok: false, configured: false, local_tools: report, action: report.action }));
271
+ throw new Error(`The job file tools failed on this Mac (local_tools_failed: ${tools.tool ?? "setup"}: ${tools.detail ?? "unknown"})`);
272
+ }
255
273
  }
256
274
  const loginCode = await handleLogin(config, stdout, stderr, fetchFn, io.browserOpener);
257
275
  if (loginCode !== 0)
@@ -278,12 +296,14 @@ export async function main(argv, env, io = {}) {
278
296
  const features = requestedFeatures ?? ["asks"];
279
297
  const integrations = features.includes("asks") ? await installIntegrations(requestedIntegrations ?? [], env, config.stateRoot) : [];
280
298
  const guide = features.includes("guide") ? await installGuideIntegrations(requestedIntegrations ?? [], env, config.stateRoot) : undefined;
299
+ // The Work Room pane goes with every setup (owner decision 2026-10-31); --integrations claude has just installed it.
300
+ const workRoomPane = integrations.some(report => report.app === "claude" && report.mod !== undefined) ? undefined : await setupClaudeMod(env, config.stateRoot);
281
301
  // The LaunchAgent runs a copy under releases/<version>, never the npx cache that may have run setup.
282
302
  const daemonExecutable = await (io.installRelease ?? installStableRelease)({ entry: process.argv[1], stateRoot: config.stateRoot });
283
303
  await launchd.install(daemonExecutable, config.stateRoot);
284
304
  stdout(JSON.stringify(mode === "full"
285
- ? { configured: true, mode, runnerId: registered.runner.id, adapters, integrations, ...(guide ? { guide } : {}), daemonExecutable }
286
- : { configured: true, mode, runnerId: registered.runner.id, adapters: adapterDiagnosticsWithRemedies(detected), integrations, ...(guide ? { guide } : {}), daemonExecutable,
305
+ ? { configured: true, mode, runnerId: registered.runner.id, adapters, integrations, ...(guide ? { guide } : {}), ...(workRoomPane ? { workRoomPane } : {}), daemonExecutable }
306
+ : { configured: true, mode, runnerId: registered.runner.id, adapters: adapterDiagnosticsWithRemedies(detected), integrations, ...(guide ? { guide } : {}), ...(workRoomPane ? { workRoomPane } : {}), daemonExecutable,
287
307
  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." }));
288
308
  return 0;
289
309
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.79",
3
+ "version": "0.1.81",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {