mtech-ai-local-runner 0.1.82 → 0.1.85

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
@@ -291,12 +291,23 @@ reports `CODEX_HOOKS_JSON_INVALID`. `uninstall` removes both integrations, the s
291
291
 
292
292
  The Claude integration also installs the Work Room pane, the mod `ai-gateway-work-room@mtec-ai-gateway` (Work Item
293
293
  98da2635). A band above the prompt says how many Room shares and requests are waiting. Its button, or `/work-room`,
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
- threads. From the pane you can answer a request, reply in a thread, share an answer to a Room or mark messages read.
296
- 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
- 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
- conversation. Without the connector the pane says so. The Claude mobile app draws no input field, so there the pane
299
- reads, shares and marks read only.
294
+ opens a pane beside the conversation. "Requests for me" shows each request, decision and review in its own box, with
295
+ who asked first. "Rooms" shows each Room in its own box, with its threads and messages indented under it. From the
296
+ pane you can answer a request, reply in a thread, share an answer to a Room or mark messages read. Each of these
297
+ starts with Send (or the button that names it), then a confirm row that names who will see it, then Claude's
298
+ permission dialog. Opening a Room shows its threads with unread messages first and every unread message in them, and
299
+ Mark all read marks up to 100 of them at once.
300
+
301
+ Ask Claude puts only ids and tool names in the conversation and asks Claude to read the full request and sum it up
302
+ in one line; nothing is sent, and it is the only button that starts a model turn. Hide hides a request, decision or
303
+ review on this Mac only, with no Gateway call: it stays open for the person who asked and on your other devices.
304
+ "Hidden (N)" with Show lists them again, each with Unhide. The pane calls the AI Gateway only through your own AI
305
+ Gateway connector in Claude; without the connector the pane says so. The Claude mobile app draws no input field, so
306
+ there the pane reads, shares, marks read and offers Ask Claude.
307
+
308
+ After a Gateway change to a tool's arguments, refresh the AI Gateway tools list in Claude (Settings, Connectors, AI
309
+ Gateway) and start a new session: a session drops arguments its tools list does not declare. The pane says so when
310
+ its Room reads lose their arguments.
300
311
 
301
312
  The pane calls the connector with no model turn, so a Claude session in auto mode would refuse every one of those
302
313
  calls. The mod therefore ships PreToolUse hooks that match any AI Gateway connector and apply to every caller in the
@@ -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.4.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: '' }))
103
+ }
104
+
105
+ /** Hide (stored as cleared): 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: 'Hide 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: 'Unhide could not be saved on this Mac.' })
121
+ }
122
+ await update($, cleared, ids => ids.filter(other => other !== id))
123
+ await recount($)
58
124
  }
59
125
 
60
- /** Loads a Room the person opened: its info, up to 10 threads and the latest 20 messages of each. */
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
  }
@@ -102,7 +180,9 @@ export async function reportPresented($: EngineInterface, roomId: string, ids: s
102
180
  /** Writes already confirmed in this run, by write key, so a second press of the same text sends once. */
103
181
  const sent = new Set<string>()
104
182
 
105
- const CONFIRM_LABEL: Record<NonNullable<Pending>['kind'], string> = { answer: 'Send', post: 'Post', answer_share: 'Share', acknowledge: 'Mark read' }
183
+ const KIND_LABEL: Record<InboxItem['kind'], string> = { request: 'Request', decision: 'Decision', review: 'Review request' }
184
+ const ASK_CLAUDE_HINT = 'Ask Claude: Claude reads the full request here and sums it up. Nothing is sent.'
185
+ const HIDE_HINT = 'Hide: only on this Mac; it stays open for the person who asked.'
106
186
 
107
187
  async function stage($: EngineInterface, next: Pending): Promise<void> {
108
188
  await update($, pending, () => next)
@@ -178,11 +258,15 @@ export const register: Register = on => {
178
258
  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
179
259
  const table = $.ui.resolve(e)
180
260
  const { Box, Button, Text } = table
181
- // The mobile app draws no field yet: there the pane reads, shares and marks read, and answers go through Open in chat.
261
+ // The mobile app draws no field yet: there the pane reads, shares and marks read, and answers go through Ask Claude.
182
262
  const Input = 'Input' in table ? table.Input : undefined
183
263
  const state = await read($, status)
184
264
  const job = await read($, pending)
185
265
  const asks = await read($, inbox)
266
+ const hidden = await read($, cleared)
267
+ const showHidden = await read($, showCleared)
268
+ const visible = visibleInbox(asks, hidden)
269
+ const clearedItems = asks.filter(item => hidden.includes(item.id))
186
270
  const rows = await read($, activity)
187
271
  const open = await read($, rooms)
188
272
  const toReport: Array<[string, string[]]> = []
@@ -193,82 +277,120 @@ export const register: Register = on => {
193
277
  {job && (
194
278
  <Box>
195
279
  <Text color="yellow">{job.summary} </Text>
196
- <Button key="confirm" label={CONFIRM_LABEL[job.kind]} onPress={() => quietly(confirm($))} />
280
+ <Button key="confirm" label="Confirm" onPress={() => quietly(confirm($))} />
197
281
  <Button key="cancel" label="Cancel" onPress={() => quietly(cancel($))} />
198
282
  </Box>
199
283
  )}
200
- <Text bold>Requests for me</Text>
201
- {asks.length === 0 && <Text dimColor>None.</Text>}
202
- {asks.map(item => (
203
- <Box flexDirection="column">
204
- <Text>[{item.kind}] {item.title} — {item.askedBy}</Text>
205
- {item.kind === 'request' && Input && (
206
- <Input
207
- key={`answer-${item.id}`}
208
- placeholder="Answer this request"
209
- rows={3}
210
- submitLabel="review"
211
- onSubmit={(text: string) => quietly(stage($, { kind: 'answer', target: item.id, text, summary: `Answer goes to ${item.askedBy} only.` }))}
212
- />
213
- )}
214
- <Button key={`chat-${item.id}`} label="Open in chat" onPress={() => quietly($.prompt.submit({ text: chatPrompt(item) }))} />
215
- </Box>
216
- ))}
217
- <Text bold>Room shares</Text>
218
- {rows.length === 0 && <Text dimColor>No Rooms.</Text>}
219
- {rows.map(row => {
220
- const room = open[row.roomId]
221
- if (room) toReport.push([row.roomId, room.threads.flatMap(thread => thread.messages.filter(m => !m.presented).map(m => m.id))])
222
-
223
- return (
224
- <Box flexDirection="column">
225
- <Box>
226
- <Text>{row.name} ({row.unread} unread) </Text>
227
- <Button
228
- key={`open-${row.roomId}`}
229
- label={room ? 'Refresh' : 'Open'}
230
- onPress={() => quietly(openRoom($, row.roomId, `The user pressed Open on Room ${row.name} in the Work Room pane.`))}
284
+ <Box flexDirection="column" gap="xs">
285
+ <Text bold>Requests for me ({visible.length})</Text>
286
+ {visible.length === 0 && <Text dimColor>None.</Text>}
287
+ {visible.map(item => (
288
+ <Box flexDirection="column" gap="xs" borderStyle="round" borderDimColor paddingX={1}>
289
+ <Text dimColor>{KIND_LABEL[item.kind]} from {item.askedBy}</Text>
290
+ <Text>{item.title}</Text>
291
+ {item.kind === 'request' && Input && (
292
+ <Input
293
+ key={`answer-${item.id}`}
294
+ placeholder="Answer this request"
295
+ rows={3}
296
+ submitLabel="Send"
297
+ onSubmit={(text: string) => quietly(stage($, { kind: 'answer', target: item.id, text, summary: `Answer goes to ${item.askedBy} only.` }))}
231
298
  />
299
+ )}
300
+ <Box flexDirection="row" gap="xs">
301
+ <Button key={`chat-${item.id}`} label="Ask Claude" onPress={() => quietly($.prompt.submit({ text: chatPrompt(item) }))} />
302
+ <Button key={`hide-${item.id}`} label="Hide" onPress={() => quietly(clearItem($, item))} />
232
303
  </Box>
233
- {room?.threads.map(thread => (
234
- <Box flexDirection="column">
235
- <Text dimColor># {thread.title}</Text>
236
- {thread.messages.map(m => (
237
- <Box flexDirection="column">
238
- <Text>{m.author}: {m.body}</Text>
239
- {m.requestId !== undefined && (
304
+ </Box>
305
+ ))}
306
+ {visible.length > 0 && <Text dimColor>{ASK_CLAUDE_HINT}</Text>}
307
+ {visible.length > 0 && <Text dimColor>{HIDE_HINT}</Text>}
308
+ {clearedItems.length > 0 && (
309
+ <Box flexDirection="row" gap="xs" alignItems="center">
310
+ <Text dimColor>Hidden ({clearedItems.length})</Text>
311
+ <Button key="show-hidden" label={showHidden ? 'Close' : 'Show'} onPress={() => quietly(update($, showCleared, shown => !shown))} />
312
+ </Box>
313
+ )}
314
+ {showHidden && clearedItems.map(item => (
315
+ <Box flexDirection="row" gap="xs" alignItems="center">
316
+ <Text dimColor>{KIND_LABEL[item.kind]} from {item.askedBy}: {item.title}</Text>
317
+ <Button key={`unhide-${item.id}`} label="Unhide" onPress={() => quietly(restoreItem($, item.id))} />
318
+ </Box>
319
+ ))}
320
+ </Box>
321
+ <Box flexDirection="column" gap="xs" marginTop={1}>
322
+ <Text bold>Rooms</Text>
323
+ {rows.length === 0 && <Text dimColor>No Rooms.</Text>}
324
+ {rows.map(row => {
325
+ const room = open[row.roomId]
326
+ if (room) toReport.push([row.roomId, room.threads.flatMap(thread => thread.messages.filter(m => !m.presented).map(m => m.id))])
327
+
328
+ return (
329
+ <Box flexDirection="column" gap="xs" borderStyle="round" borderDimColor paddingX={1}>
330
+ <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap="xs">
331
+ <Text>{row.name} · {row.unread} unread</Text>
332
+ <Box flexDirection="row" gap="xs">
333
+ <Button
334
+ key={`open-${row.roomId}`}
335
+ label={room ? 'Refresh' : 'Open'}
336
+ onPress={() => quietly(openRoom($, row.roomId, `The user pressed Open on Room ${row.name} in the Work Room pane.`))}
337
+ />
338
+ {room && markable(room.threads).length > 0 && (
339
+ <Button
340
+ key={`ack-room-${row.roomId}`}
341
+ label="Mark all read"
342
+ onPress={() => {
343
+ const ids = markable(room.threads)
344
+ quietly(stage($, { kind: 'acknowledge', target: `${row.roomId}|`, text: ids.join(','), summary: `Mark ${ids.length} message${ids.length === 1 ? '' : 's'} read?` }))
345
+ }}
346
+ />
347
+ )}
348
+ </Box>
349
+ </Box>
350
+ {room?.threads.map(thread => (
351
+ <Box flexDirection="column" gap="xs" paddingLeft={2}>
352
+ <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap="xs">
353
+ <Text bold dimColor># {thread.title}</Text>
354
+ {markable([thread]).length > 0 && (
240
355
  <Button
241
- key={`share-${m.id}`}
242
- label="Share answer to Room"
243
- onPress={() => quietly(stage($, { kind: 'answer_share', target: `${row.roomId}|${thread.id}|${room.info.membershipRevision}|${m.requestId}`, text: m.requestId ?? '', summary: `Shares your answer to ${room.info.name} for ${room.info.memberCount} members.` }))}
356
+ key={`ack-${thread.id}`}
357
+ label="Mark read"
358
+ onPress={() => {
359
+ const ids = markable([thread])
360
+ quietly(stage($, { kind: 'acknowledge', target: `${row.roomId}|${thread.id}`, text: ids.join(','), summary: `Mark ${ids.length} message${ids.length === 1 ? '' : 's'} read?` }))
361
+ }}
244
362
  />
245
363
  )}
246
364
  </Box>
247
- ))}
248
- {thread.messages.some(m => !m.acknowledged) && (
249
- <Button
250
- key={`ack-${thread.id}`}
251
- label="Mark read"
252
- onPress={() => {
253
- const ids = thread.messages.filter(m => !m.acknowledged).map(m => m.id)
254
- quietly(stage($, { kind: 'acknowledge', target: `${row.roomId}|${thread.id}`, text: ids.join(','), summary: `Mark ${ids.length} message${ids.length === 1 ? '' : 's'} read?` }))
255
- }}
256
- />
257
- )}
258
- {Input && (
259
- <Input
260
- key={`post-${thread.id}`}
261
- placeholder="Reply in this thread"
262
- rows={2}
263
- submitLabel="review"
264
- onSubmit={(text: string) => quietly(stage($, { kind: 'post', target: `${row.roomId}|${thread.id}|${room.info.membershipRevision}`, text, summary: `Posts to ${room.info.name} for ${room.info.memberCount} members.` }))}
265
- />
266
- )}
267
- </Box>
268
- ))}
269
- </Box>
270
- )
271
- })}
365
+ <Box flexDirection="column" gap="xs" paddingLeft={2}>
366
+ {thread.messages.map(m => (
367
+ <Box flexDirection="column">
368
+ <Text>{m.author}: {m.body}</Text>
369
+ {m.requestId !== undefined && (
370
+ <Button
371
+ key={`share-${m.id}`}
372
+ label="Share answer to Room"
373
+ onPress={() => quietly(stage($, { kind: 'answer_share', target: `${row.roomId}|${thread.id}|${room.info.membershipRevision}|${m.requestId}`, text: m.requestId ?? '', summary: `Shares your answer to ${room.info.name} for ${room.info.memberCount} members.` }))}
374
+ />
375
+ )}
376
+ </Box>
377
+ ))}
378
+ {Input && (
379
+ <Input
380
+ key={`post-${thread.id}`}
381
+ placeholder="Reply in this thread"
382
+ rows={2}
383
+ submitLabel="Send"
384
+ onSubmit={(text: string) => quietly(stage($, { kind: 'post', target: `${row.roomId}|${thread.id}|${room.info.membershipRevision}`, text, summary: `Posts to ${room.info.name} for ${room.info.memberCount} members.` }))}
385
+ />
386
+ )}
387
+ </Box>
388
+ </Box>
389
+ ))}
390
+ </Box>
391
+ )
392
+ })}
393
+ </Box>
272
394
  </Box>
273
395
  )
274
396
  for (const [roomId, ids] of toReport) quietly(reportPresented($, roomId, ids))
@@ -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
  }
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.82",
3
+ "version": "0.1.85",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {