mtech-ai-local-runner 0.1.79 → 0.1.80

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
@@ -298,6 +298,11 @@ 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.
305
+
301
306
  The runner copies the mod to `claude-mod/<version>/` in the state root, without its tests, and installs it with the
302
307
  Claude CLI as a local marketplace (`plugin marketplace add` and `plugin install --scope user`). This needs a Claude CLI
303
308
  2.1.299 or newer: the first `claude` on `PATH` when it is new enough, else the newest one the Claude desktop app
@@ -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.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" }
@@ -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,16 @@
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
+ }
16
+ }
@@ -9,12 +9,16 @@ 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
+ * 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.
12
16
  */
13
17
 
14
18
  export const PANE = 'work-room'
15
19
  const TITLE = 'Work Room'
16
20
  const REFRESH_MS = 60_000
17
- const NO_CONNECTOR = 'Connect AI Gateway in Claude to use Work Room.'
21
+ 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
22
 
19
23
  const counts = atom({ plugin: 'ai-gateway-work-room', key: 'counts' } as const, { shares: 0, requests: 0 } as Counts)
20
24
  const activity = atom({ plugin: 'ai-gateway-work-room', key: 'activity' } as const, [] as RoomActivity[])
@@ -35,19 +39,19 @@ async function fail($: EngineInterface, answer: { state: Status['state']; detail
35
39
  await update($, status, () => ({ state: answer.state, detail: answer.state === 'no-connector' ? NO_CONNECTOR : answer.detail }))
36
40
  }
37
41
 
38
- /** Counts only: Room activity and open inbox items, never message bodies. */
42
+ /** Counts only: Room activity and open inbox items, never message bodies. Each half shows even when the other fails. */
39
43
  export async function refreshCounts($: EngineInterface): Promise<void> {
40
44
  const [roomAnswer, inboxAnswer] = await Promise.all([
41
45
  callGateway($, 'work_room_read', { operation: 'activity', limit: 100 }),
42
46
  callGateway($, 'work_inbox', { direction: 'to_me' }),
43
47
  ])
48
+ if (roomAnswer.ok) await update($, activity, () => parseActivity(findPayload(roomAnswer.payload, v => Array.isArray(v.rooms))) ?? [])
49
+ if (inboxAnswer.ok) await update($, inbox, () => parseInbox(findPayload(inboxAnswer.payload, v => Array.isArray(v.items))) ?? [])
50
+ const rows = await read($, activity)
51
+ const items = await read($, inbox)
52
+ await update($, counts, () => ({ shares: rows.reduce((sum, row) => sum + row.unread, 0), requests: items.length }))
44
53
  if (!roomAnswer.ok) return fail($, roomAnswer)
45
54
  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
55
  await update($, status, () => ({ state: 'ok', detail: '' }))
52
56
  }
53
57
 
@@ -76,7 +80,10 @@ export async function reportPresented($: EngineInterface, roomId: string, ids: s
76
80
  const fresh = ids.filter(id => !presentedSent.has(id)).slice(0, 100)
77
81
  if (fresh.length === 0) return
78
82
  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' })
83
+ const name = (await read($, rooms))[roomId]?.info.name ?? 'a Room'
84
+ // The person's press on Open is what showed these messages, so the receipt carries it as consent.
85
+ const consent = `The user opened Room ${name} in the Work Room pane, which showed them ${fresh.length} message${fresh.length === 1 ? '' : 's'}.`
86
+ const sent = await callGateway($, 'work_room_receipt_report', { room_id: roomId, message_ids: fresh, state: 'presented' }, consent)
80
87
  if (!sent.ok) {
81
88
  for (const id of fresh) presentedSent.delete(id)
82
89
  return fail($, sent)
@@ -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
  }
package/dist/src/cli.js CHANGED
@@ -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)
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.80",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {