mtech-ai-local-runner 0.1.82 → 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.
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
await
|
|
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
|
-
|
|
101
|
+
const current = await readOptionsReach($)
|
|
102
|
+
await update($, status, () => (current === false ? { state: 'stale', detail: STALE } : { state: 'ok', detail: '' }))
|
|
58
103
|
}
|
|
59
104
|
|
|
60
|
-
/**
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
71
|
-
const
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
{
|
|
202
|
-
{
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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 || !
|
|
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 || !
|
|
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
|
-
|
|
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;
|