mtech-ai-local-runner 0.1.81 → 0.1.84

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
@@ -293,6 +293,12 @@ The Claude integration also installs the Work Room pane, the mod `ai-gateway-wor
293
293
  98da2635). A band above the prompt says how many Room shares and requests are waiting. Its button, or `/work-room`,
294
294
  opens a pane beside the conversation. The pane lists what waits for you and the Rooms you are in, and it opens Room
295
295
  threads. From the pane you can answer a request, reply in a thread, share an answer to a Room or mark messages read.
296
+ Opening a Room shows its threads with unread messages first and every unread message in them, and Mark all read marks
297
+ up to 100 of them at once. Clear hides a request, decision or review on this Mac only, with no Gateway call; Show
298
+ under "Cleared" lists them again with Restore.
299
+ After a Gateway change to a tool's arguments, refresh the AI Gateway tools list in Claude (Settings, Connectors, AI
300
+ Gateway) and start a new session: a session drops arguments its tools list does not declare. The pane says so when
301
+ its Room reads lose their arguments.
296
302
  Each of these is sent only after a confirm row that names who will see it. The pane calls the AI Gateway only through
297
303
  your own AI Gateway connector in Claude, and it never starts a model turn: its Open in chat button puts only ids in the
298
304
  conversation. Without the connector the pane says so. The Claude mobile app draws no input field, so there the pane
@@ -1 +1 @@
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" }
1
+ { "name": "ai-gateway-work-room", "version": "1.3.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,4 +1,4 @@
1
- import type { Counts, InboxItem, RoomActivity, RoomInfo, RoomMessage } from '../types'
1
+ import type { Counts, InboxItem, RoomActivity, RoomInfo, RoomMessage, RoomThread, ThreadRow } from '../types'
2
2
 
3
3
  /*
4
4
  * Pure helpers for the Work Room pane (Work Item 98da2635): they read Gateway tool results and build the text the
@@ -82,6 +82,20 @@ export function parseInbox(payload: Record<string, unknown> | undefined): InboxI
82
82
  .slice(0, 50)
83
83
  }
84
84
 
85
+ /** `$.store` key prefix for a request, decision or review the person cleared on this Mac. */
86
+ export const CLEARED = 'cleared:'
87
+
88
+ /** The inbox's own page size: a shorter list is the whole inbox. */
89
+ export const INBOX_LIMIT = 50
90
+
91
+ export const clearedIds = (keys: string[]): string[] => keys.filter(key => key.startsWith(CLEARED)).map(key => key.slice(CLEARED.length))
92
+
93
+ export const visibleInbox = (items: InboxItem[], cleared: string[]): InboxItem[] => {
94
+ const hidden = new Set(cleared)
95
+
96
+ return items.filter(item => !hidden.has(item.id))
97
+ }
98
+
85
99
  export function parseMessages(payload: Record<string, unknown> | undefined): RoomMessage[] | undefined {
86
100
  if (!payload || !Array.isArray(payload.messages)) return undefined
87
101
 
@@ -107,6 +121,45 @@ export function parseMessages(payload: Record<string, unknown> | undefined): Roo
107
121
  })
108
122
  }
109
123
 
124
+ /** One page of `threads`; `offset` numbers the rows so later pages sort after earlier ones. */
125
+ export function parseThreads(payload: Record<string, unknown> | undefined, offset: number): { rows: ThreadRow[]; next: number | null } | undefined {
126
+ if (!payload || !Array.isArray(payload.threads)) return undefined
127
+ const rows = payload.threads
128
+ .map(record)
129
+ .filter((row): row is Record<string, unknown> => row !== undefined && typeof row.id === 'string')
130
+ .map((row, index) => ({ id: clean(row.id, 64), title: clean(row.title, 120), unread: num(row.unread_count), latest: num(row.latest_message_sequence), order: offset + index }))
131
+
132
+ return { rows, next: typeof payload.next_cursor === 'number' ? payload.next_cursor : null }
133
+ }
134
+
135
+ const newestFirst = (a: ThreadRow, b: ThreadRow): number => b.latest - a.latest || b.order - a.order
136
+
137
+ /** Every thread with unread (at most 20), newest first, then the newest others until 10 threads are shown. */
138
+ export function pickThreads(rows: ThreadRow[]): ThreadRow[] {
139
+ const unread = rows.filter(row => row.unread > 0).sort(newestFirst).slice(0, 20)
140
+ const rest = rows.filter(row => row.unread === 0).sort(newestFirst).slice(0, Math.max(0, 10 - unread.length))
141
+
142
+ return [...unread, ...rest]
143
+ }
144
+
145
+ export function mergeMessages(a: RoomMessage[], b: RoomMessage[]): RoomMessage[] {
146
+ const byId = new Map<string, RoomMessage>()
147
+ for (const message of [...a, ...b]) byId.set(message.id, message)
148
+
149
+ return [...byId.values()].sort((x, y) => x.sequence - y.sequence)
150
+ }
151
+
152
+ /** Unread ids the Gateway already records as presented, in display order, at most 100 (one receipt call). */
153
+ export function markable(threads: RoomThread[]): string[] {
154
+ return threads
155
+ .flatMap(thread => {
156
+ const unread = new Set(thread.unreadIds)
157
+
158
+ return thread.messages.filter(m => unread.has(m.id) && m.presented && !m.acknowledged).map(m => m.id)
159
+ })
160
+ .slice(0, 100)
161
+ }
162
+
110
163
  export function parseRoom(payload: Record<string, unknown> | undefined): RoomInfo | undefined {
111
164
  const room = record(payload?.room)
112
165
  if (!room || typeof room.id !== 'string') return undefined
@@ -1,9 +1,9 @@
1
1
  import { atom, read, update } from 'claude-code'
2
2
  import type { EngineInterface, Register } from 'claude-code'
3
3
 
4
- import type { Counts, InboxItem, OpenRoom, Pending, RoomActivity, Status } from '../types'
4
+ import type { Counts, InboxItem, OpenRoom, Pending, RoomActivity, RoomMessage, Status, ThreadRow } from '../types'
5
5
  import { callGateway } from './gateway'
6
- import { bandText, chatPrompt, findPayload, parseActivity, parseInbox, parseMessages, parseRoom, writeKey } from './model'
6
+ import { CLEARED, INBOX_LIMIT, bandText, chatPrompt, clearedIds, findPayload, markable, mergeMessages, parseActivity, parseInbox, parseMessages, parseRoom, parseThreads, pickThreads, visibleInbox, writeKey } from './model'
7
7
 
8
8
  /*
9
9
  * The Work Room pane (Work Item 98da2635, spec 2026-10-30): Room shares and requests from colleagues beside the
@@ -21,6 +21,10 @@ export const PANE = 'work-room'
21
21
  const TITLE = 'Work Room'
22
22
  const REFRESH_MS = 60_000
23
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.'
24
+ // A session keeps the tools list its connector had when it started, and a call drops arguments that list does not
25
+ // declare. With a list from before unread_only and latest (Gateway a8df571a), a Room opens with its oldest messages.
26
+ const STALE = 'Refresh the AI Gateway tools list in Claude (Settings, Connectors, AI Gateway), then start a new session, so Rooms show their newest and unread messages.'
27
+ const NIL = '00000000-0000-4000-8000-000000000000'
24
28
 
25
29
  const counts = atom({ plugin: 'ai-gateway-work-room', key: 'counts' } as const, { shares: 0, requests: 0 } as Counts)
26
30
  const activity = atom({ plugin: 'ai-gateway-work-room', key: 'activity' } as const, [] as RoomActivity[])
@@ -28,6 +32,24 @@ const inbox = atom({ plugin: 'ai-gateway-work-room', key: 'inbox' } as const, []
28
32
  const rooms = atom({ plugin: 'ai-gateway-work-room', key: 'rooms' } as const, {} as Record<string, OpenRoom>)
29
33
  const pending = atom({ plugin: 'ai-gateway-work-room', key: 'pending' } as const, null as Pending)
30
34
  const status = atom({ plugin: 'ai-gateway-work-room', key: 'status' } as const, { state: 'ok', detail: '' } as Status)
35
+ const cleared = atom({ plugin: 'ai-gateway-work-room', key: 'cleared' } as const, [] as string[])
36
+ const showCleared = atom({ plugin: 'ai-gateway-work-room', key: 'showCleared' } as const, false as boolean)
37
+
38
+ /** Whether this session's calls carry unread_only and latest to the Gateway; asked once per run. */
39
+ let readOptions: boolean | undefined
40
+
41
+ /**
42
+ * The Gateway refuses unread_only with latest before it looks up the Room. A call that loses both arguments on the
43
+ * way gets "Work Room is unavailable." instead. Undefined when the answer says neither (a refusal, no connector).
44
+ */
45
+ async function readOptionsReach($: EngineInterface): Promise<boolean | undefined> {
46
+ if (readOptions !== undefined) return readOptions
47
+ const probe = await callGateway($, 'work_room_read', { operation: 'messages', room_id: NIL, thread_id: NIL, unread_only: true, latest: true, limit: 1 })
48
+ if (probe.ok || probe.state !== 'error') return undefined
49
+ readOptions = probe.detail.includes('not both')
50
+
51
+ return readOptions
52
+ }
31
53
 
32
54
  /** Messages already sent for presented in this run, so a redraw before the update lands does not report them twice. */
33
55
  const presentedSent = new Set<string>()
@@ -41,6 +63,28 @@ async function fail($: EngineInterface, answer: { state: Status['state']; detail
41
63
  await update($, status, () => ({ state: answer.state, detail: answer.state === 'no-connector' ? NO_CONNECTOR : answer.detail }))
42
64
  }
43
65
 
66
+ /** The band's counts from what this session holds: Room unread, and requests not cleared on this Mac. */
67
+ async function recount($: EngineInterface): Promise<void> {
68
+ const rows = await read($, activity)
69
+ const items = visibleInbox(await read($, inbox), await read($, cleared))
70
+ await update($, counts, () => ({ shares: rows.reduce((sum, row) => sum + row.unread, 0), requests: items.length }))
71
+ }
72
+
73
+ /** Reads the cleared ids from the store. A complete inbox read drops ids it no longer lists (answered, decided or expired). */
74
+ async function syncCleared($: EngineInterface, items: InboxItem[] | undefined): Promise<void> {
75
+ try {
76
+ let ids = clearedIds(await $.store.keys())
77
+ if (items !== undefined && items.length < INBOX_LIMIT) {
78
+ const open = new Set(items.map(item => item.id))
79
+ for (const id of ids.filter(id => !open.has(id))) await $.store.delete(`${CLEARED}${id}`)
80
+ ids = ids.filter(id => open.has(id))
81
+ }
82
+ await update($, cleared, () => ids)
83
+ } catch {
84
+ // An unreadable store keeps the ids this session already holds; the counts still show.
85
+ }
86
+ }
87
+
44
88
  /** Counts only: Room activity and open inbox items, never message bodies. Each half shows even when the other fails. */
45
89
  export async function refreshCounts($: EngineInterface): Promise<void> {
46
90
  const [roomAnswer, inboxAnswer] = await Promise.all([
@@ -48,31 +92,65 @@ export async function refreshCounts($: EngineInterface): Promise<void> {
48
92
  callGateway($, 'work_inbox', { direction: 'to_me' }),
49
93
  ])
50
94
  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 }))
95
+ const listed = inboxAnswer.ok ? parseInbox(findPayload(inboxAnswer.payload, v => Array.isArray(v.items))) : undefined
96
+ if (inboxAnswer.ok) await update($, inbox, () => listed ?? [])
97
+ await syncCleared($, listed)
98
+ await recount($)
55
99
  if (!roomAnswer.ok) return fail($, roomAnswer)
56
100
  if (!inboxAnswer.ok) return fail($, inboxAnswer)
57
- await update($, status, () => ({ state: 'ok', detail: '' }))
101
+ const current = await readOptionsReach($)
102
+ await update($, status, () => (current === false ? { state: 'stale', detail: STALE } : { state: 'ok', detail: '' }))
58
103
  }
59
104
 
60
- /** Loads a Room the person opened: its info, up to 10 threads and the latest 20 messages of each. */
105
+ /** Clear: hides the item on this Mac only. No Gateway call; the item stays open there and on other Macs. */
106
+ export async function clearItem($: EngineInterface, item: InboxItem): Promise<void> {
107
+ try {
108
+ await $.store.set(`${CLEARED}${item.id}`, item.kind)
109
+ } catch {
110
+ return fail($, { state: 'error', detail: 'Clear could not be saved on this Mac.' })
111
+ }
112
+ await update($, cleared, ids => (ids.includes(item.id) ? ids : [...ids, item.id]))
113
+ await recount($)
114
+ }
115
+
116
+ export async function restoreItem($: EngineInterface, id: string): Promise<void> {
117
+ try {
118
+ await $.store.delete(`${CLEARED}${id}`)
119
+ } catch {
120
+ return fail($, { state: 'error', detail: 'Restore could not be saved on this Mac.' })
121
+ }
122
+ await update($, cleared, ids => ids.filter(other => other !== id))
123
+ await recount($)
124
+ }
125
+
126
+ /** Loads a Room the person opened: its threads with unread first, then the newest; per thread the newest 20 messages and every unread one (up to 100). */
61
127
  export async function openRoom($: EngineInterface, roomId: string, consent: string): Promise<void> {
62
128
  const shown = await callGateway($, 'work_room_read', { operation: 'show', room_id: roomId }, consent)
63
129
  if (!shown.ok) return fail($, shown)
64
130
  const info = parseRoom(findPayload(shown.payload, v => v.room !== undefined))
65
131
  if (!info) return fail($, { state: 'error', detail: 'The Room is unavailable.' })
66
- const listed = await callGateway($, 'work_room_read', { operation: 'threads', room_id: roomId, limit: 20 }, consent)
67
- if (!listed.ok) return fail($, listed)
68
- const threadRows = (findPayload(listed.payload, v => Array.isArray(v.threads))?.threads ?? []) as Array<Record<string, unknown>>
132
+ const rows: ThreadRow[] = []
133
+ let cursor: number | null = 0
134
+ for (let page = 0; page < 5 && cursor !== null; page++) {
135
+ const listed = await callGateway($, 'work_room_read', { operation: 'threads', room_id: roomId, after_sequence: cursor, limit: 100 }, consent)
136
+ if (!listed.ok) return fail($, listed)
137
+ const parsed = parseThreads(findPayload(listed.payload, v => Array.isArray(v.threads)), rows.length)
138
+ if (!parsed) break
139
+ rows.push(...parsed.rows)
140
+ cursor = parsed.next
141
+ }
69
142
  const threads: OpenRoom['threads'] = []
70
- for (const row of threadRows.slice(0, 10)) {
71
- const id = String(row.id ?? '')
72
- const latest = await callGateway($, 'work_room_read', { operation: 'messages', room_id: roomId, thread_id: id, limit: 20 }, consent)
143
+ for (const row of pickThreads(rows)) {
144
+ const latest = await callGateway($, 'work_room_read', { operation: 'messages', room_id: roomId, thread_id: row.id, latest: true, limit: 20 }, consent)
73
145
  if (!latest.ok) return fail($, latest)
74
- const messages = parseMessages(findPayload(latest.payload, v => Array.isArray(v.messages))) ?? []
75
- threads.push({ id, title: String(row.title ?? '').slice(0, 120), messages })
146
+ let unread: RoomMessage[] = []
147
+ if (row.unread > 0) {
148
+ const page = await callGateway($, 'work_room_read', { operation: 'messages', room_id: roomId, thread_id: row.id, unread_only: true, limit: 100 }, consent)
149
+ if (!page.ok) return fail($, page)
150
+ unread = parseMessages(findPayload(page.payload, v => Array.isArray(v.messages))) ?? []
151
+ }
152
+ const newest = parseMessages(findPayload(latest.payload, v => Array.isArray(v.messages))) ?? []
153
+ threads.push({ id: row.id, title: row.title, messages: mergeMessages(newest, unread), unreadIds: unread.map(m => m.id) })
76
154
  }
77
155
  await update($, rooms, all => ({ ...all, [roomId]: { info, threads } }))
78
156
  }
@@ -183,6 +261,10 @@ export const register: Register = on => {
183
261
  const state = await read($, status)
184
262
  const job = await read($, pending)
185
263
  const asks = await read($, inbox)
264
+ const hidden = await read($, cleared)
265
+ const showHidden = await read($, showCleared)
266
+ const visible = visibleInbox(asks, hidden)
267
+ const clearedItems = asks.filter(item => hidden.includes(item.id))
186
268
  const rows = await read($, activity)
187
269
  const open = await read($, rooms)
188
270
  const toReport: Array<[string, string[]]> = []
@@ -198,8 +280,8 @@ export const register: Register = on => {
198
280
  </Box>
199
281
  )}
200
282
  <Text bold>Requests for me</Text>
201
- {asks.length === 0 && <Text dimColor>None.</Text>}
202
- {asks.map(item => (
283
+ {visible.length === 0 && <Text dimColor>None.</Text>}
284
+ {visible.map(item => (
203
285
  <Box flexDirection="column">
204
286
  <Text>[{item.kind}] {item.title} — {item.askedBy}</Text>
205
287
  {item.kind === 'request' && Input && (
@@ -212,6 +294,19 @@ export const register: Register = on => {
212
294
  />
213
295
  )}
214
296
  <Button key={`chat-${item.id}`} label="Open in chat" onPress={() => quietly($.prompt.submit({ text: chatPrompt(item) }))} />
297
+ <Button key={`clear-${item.id}`} label="Clear" onPress={() => quietly(clearItem($, item))} />
298
+ </Box>
299
+ ))}
300
+ {clearedItems.length > 0 && (
301
+ <Box>
302
+ <Text dimColor>Cleared ({clearedItems.length}) </Text>
303
+ <Button key="show-cleared" label={showHidden ? 'Hide' : 'Show'} onPress={() => quietly(update($, showCleared, shown => !shown))} />
304
+ </Box>
305
+ )}
306
+ {showHidden && clearedItems.map(item => (
307
+ <Box>
308
+ <Text dimColor>[{item.kind}] {item.title} — {item.askedBy} </Text>
309
+ <Button key={`restore-${item.id}`} label="Restore" onPress={() => quietly(restoreItem($, item.id))} />
215
310
  </Box>
216
311
  ))}
217
312
  <Text bold>Room shares</Text>
@@ -230,6 +325,16 @@ export const register: Register = on => {
230
325
  onPress={() => quietly(openRoom($, row.roomId, `The user pressed Open on Room ${row.name} in the Work Room pane.`))}
231
326
  />
232
327
  </Box>
328
+ {room && markable(room.threads).length > 0 && (
329
+ <Button
330
+ key={`ack-room-${row.roomId}`}
331
+ label="Mark all read"
332
+ onPress={() => {
333
+ const ids = markable(room.threads)
334
+ quietly(stage($, { kind: 'acknowledge', target: `${row.roomId}|`, text: ids.join(','), summary: `Mark ${ids.length} message${ids.length === 1 ? '' : 's'} read?` }))
335
+ }}
336
+ />
337
+ )}
233
338
  {room?.threads.map(thread => (
234
339
  <Box flexDirection="column">
235
340
  <Text dimColor># {thread.title}</Text>
@@ -245,12 +350,12 @@ export const register: Register = on => {
245
350
  )}
246
351
  </Box>
247
352
  ))}
248
- {thread.messages.some(m => !m.acknowledged) && (
353
+ {markable([thread]).length > 0 && (
249
354
  <Button
250
355
  key={`ack-${thread.id}`}
251
356
  label="Mark read"
252
357
  onPress={() => {
253
- const ids = thread.messages.filter(m => !m.acknowledged).map(m => m.id)
358
+ const ids = markable([thread])
254
359
  quietly(stage($, { kind: 'acknowledge', target: `${row.roomId}|${thread.id}`, text: ids.join(','), summary: `Mark ${ids.length} message${ids.length === 1 ? '' : 's'} read?` }))
255
360
  }}
256
361
  />
@@ -2,10 +2,11 @@ export type RoomActivity = { roomId: string; name: string; unread: number; lates
2
2
  export type InboxItem = { kind: 'request' | 'decision' | 'review'; id: string; workItemId: string; askedBy: string; title: string; status: string; changedAt: number }
3
3
  export type RoomMessage = { id: string; threadId: string; sequence: number; author: string; kind: string; body: string; createdAt: number; presented: boolean; acknowledged: boolean; requestId?: string }
4
4
  export type RoomInfo = { id: string; name: string; membershipRevision: number; memberCount: number }
5
- export type RoomThread = { id: string; title: string; messages: RoomMessage[] }
5
+ export type ThreadRow = { id: string; title: string; unread: number; latest: number; order: number }
6
+ export type RoomThread = { id: string; title: string; messages: RoomMessage[]; unreadIds: string[] }
6
7
  export type OpenRoom = { info: RoomInfo; threads: RoomThread[] }
7
8
  export type Pending = { kind: 'answer' | 'post' | 'answer_share' | 'acknowledge'; target: string; text: string; summary: string } | null
8
- export type Status = { state: 'ok' | 'no-connector' | 'refused' | 'error' | 'loading'; detail: string }
9
+ export type Status = { state: 'ok' | 'no-connector' | 'refused' | 'error' | 'loading' | 'stale'; detail: string }
9
10
  export type Counts = { shares: number; requests: number }
10
11
 
11
12
  declare module 'claude-code' {
@@ -17,6 +18,8 @@ declare module 'claude-code' {
17
18
  rooms: Record<string, OpenRoom>
18
19
  pending: Pending
19
20
  status: Status
21
+ cleared: string[]
22
+ showCleared: boolean
20
23
  }
21
24
  }
22
25
  }
@@ -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,8 +25,10 @@ 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";
@@ -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
  });
@@ -418,6 +424,8 @@ export async function main(argv, env, io = {}) {
418
424
  if (!hook.enforcing) {
419
425
  stderr(JSON.stringify({ event: "hook_not_enforcing", reason: hook.reason,
420
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" }) });
421
429
  return 1;
422
430
  }
423
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,
@@ -9,7 +9,8 @@ import { protocolHash } from './candidate.js';
9
9
  import { deflateObject, RawGitObjects } from './raw-git-objects.js';
10
10
  const execute = promisify(execFile), denied = () => Error('SNAPSHOT_REJECTED');
11
11
  const digest = (data) => createHash('sha256').update(data).digest('hex');
12
- const safe = (p) => p.length > 0 && !path.isAbsolute(p) && !p.includes('\\') && !p.includes('\0') && p.split('/').every(s => s !== '' && s !== '.' && s !== '..' && !/^(?:\.git|\.ssh|\.aws|\.codex|\.claude|\.env|\.netrc|\.npmrc|credentials(?:\..*)?)$/i.test(s));
12
+ const structural = (p) => p.length > 0 && !path.isAbsolute(p) && !p.includes('\\') && !p.includes('\0') && p.split('/').every(s => s !== '' && s !== '.' && s !== '..' && !/^\.git$/i.test(s));
13
+ const safe = (p) => structural(p) && p.split('/').every(s => !/^(?:\.ssh|\.aws|\.codex|\.claude|\.env|\.netrc|\.npmrc|credentials(?:\..*)?)$/i.test(s));
13
14
  /** P1 branch shape only. Authority still requires exact frozen-fence equality. */
14
15
  /** Whole-checkout memory bound for a snapshot, per file and in total. A snapshot reads every
15
16
  * file of the checkout, so callers bound candidate size on the changed files instead. */
@@ -89,8 +90,8 @@ export class GitSnapshotHost {
89
90
  throw denied();
90
91
  return result;
91
92
  }
92
- async currentFile(relative) {
93
- if (!safe(relative))
93
+ async currentFile(relative, protectedBase = false) {
94
+ if (!(protectedBase ? structural(relative) : safe(relative)))
94
95
  throw denied();
95
96
  const absolute = path.join(this.options.root, relative);
96
97
  let component = this.options.root;
@@ -148,7 +149,7 @@ export class GitSnapshotHost {
148
149
  const o = this.options;
149
150
  await this.head();
150
151
  const head = o.baseCommitSha, branch = o.workingBranch;
151
- const base = new Map(), gitlinks = new Map(), baseListing = await this.git(['ls-tree', '-r', '-z', '--full-tree', o.baseCommitSha]);
152
+ const base = new Map(), gitlinks = new Map(), protectedNames = new Set(), baseListing = await this.git(['ls-tree', '-r', '-z', '--full-tree', o.baseCommitSha]);
152
153
  for (const line of baseListing.toString().split('\0').filter(Boolean)) {
153
154
  const link = /^160000 commit ([a-f0-9]{40})\t(.+)$/s.exec(line);
154
155
  if (link) {
@@ -158,16 +159,19 @@ export class GitSnapshotHost {
158
159
  continue;
159
160
  }
160
161
  const match = /^(100644|100755) blob ([a-f0-9]{40})\t(.+)$/s.exec(line);
161
- if (!match || !safe(match[3]))
162
+ if (!match || !structural(match[3]))
162
163
  throw denied();
163
164
  base.set(match[3], { mode: parseInt(match[1], 8), blob: match[2] });
165
+ if (!safe(match[3]))
166
+ protectedNames.add(match[3]);
164
167
  }
168
+ // Protected names the base commit already tracks stay as exact base content; only a change to one is refused.
165
169
  // Gitlinks remain exact base-tree references. Never traverse or copy their source.
166
170
  // Staged replacement/deletion is refused rather than publishing hidden changes.
167
171
  const staged = new Map(), stagedListing = await this.git(['ls-files', '--stage', '-z']);
168
172
  for (const line of stagedListing.toString().split('\0').filter(Boolean)) {
169
173
  const entry = /^(100644|100755|160000) ([a-f0-9]{40}) 0\t(.+)$/s.exec(line);
170
- if (!entry || !safe(entry[3]))
174
+ if (!entry || !structural(entry[3]))
171
175
  throw denied();
172
176
  staged.set(entry[3], entry[1] + ' ' + entry[2]);
173
177
  }
@@ -184,7 +188,7 @@ export class GitSnapshotHost {
184
188
  for (const name of names) {
185
189
  if ([...gitlinks.keys()].some(p => name === p || name.startsWith(p + '/') || p.startsWith(name + '/')))
186
190
  throw denied();
187
- const current = await this.currentFile(name), prior = base.get(name);
191
+ const current = await this.currentFile(name, protectedNames.has(name)), prior = base.get(name);
188
192
  let before = null;
189
193
  if (prior) {
190
194
  const blob = blobs.get(prior.blob);
@@ -199,11 +203,14 @@ export class GitSnapshotHost {
199
203
  throw denied();
200
204
  if (current)
201
205
  files.push(current);
202
- if (!prior || !current || !before.equals(current.bytes) || prior.mode !== current.mode)
206
+ const changed = !prior || !current || !before.equals(current.bytes) || prior.mode !== current.mode;
207
+ if (changed && protectedNames.has(name))
208
+ throw denied();
209
+ if (changed)
203
210
  changes.push({ path: name, status: !prior ? 'added' : !current ? 'deleted' : 'modified', before: before?.toString('base64') ?? null, after: current?.bytes.toString('base64') ?? null, before_mode: prior?.mode ?? null, after_mode: current?.mode ?? null });
204
211
  }
205
212
  const observation = { base_commit_sha: o.baseCommitSha, head_commit_sha: head, working_branch: branch, diff: JSON.stringify({ format: 'local-candidate-binary-diff-v1', changes }), changed_files: changes.map(({ path, status }) => ({ path, status })) };
206
- const listing = { base: digest(baseListing), staged: digest(stagedListing), untracked: digest(untrackedListing), names };
213
+ const listing = { base: digest(baseListing), staged: digest(stagedListing), untracked: digest(untrackedListing), names, protectedNames };
207
214
  return { observation, files, listing, fingerprint: protocolHash({ observation, files: files.map(f => ({ path: f.path, mode: f.mode, sha256: digest(f.bytes) })) }) };
208
215
  }
209
216
  /** The checkout still equals a capture when HEAD, the branch, the three Git listings and every
@@ -217,7 +224,7 @@ export class GitSnapshotHost {
217
224
  const files = [];
218
225
  let total = 0;
219
226
  for (const name of listing.names) {
220
- const current = await this.currentFile(name);
227
+ const current = await this.currentFile(name, listing.protectedNames.has(name));
221
228
  if (!current)
222
229
  continue;
223
230
  total += current.bytes.length;
@@ -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.81",
3
+ "version": "0.1.84",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {