mtech-ai-local-runner 0.1.110 → 0.1.112

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.
@@ -28,6 +28,8 @@ export class GatewayToolError extends Error {
28
28
  const ROOM_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
29
29
  const PANEL_ITEMS = 50;
30
30
  const PANEL_TITLE = 300;
31
+ /** The menu bar Rooms tab (Work Item b35d65cb). Ids are lowercase UUIDs, as the Gateway makes them. */
32
+ const PANEL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
31
33
  const SUSPENDED_JOB_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
32
34
  /** A malformed answer is dropped, never a reason to refuse the sync: the runner then stays suspended. */
33
35
  function parseSuspendedJob(value) {
@@ -789,6 +791,102 @@ export class McpGatewayClient {
789
791
  async answerWorkRequest(requestId, body, signal) {
790
792
  await this.callTool('work_request_answer', { request_id: requestId, body }, signal);
791
793
  }
794
+ /** work_room_read activity, share_only (the projection the runner counts), for the menu bar Rooms tab: one page of 50. */
795
+ async readRoomListPage(afterRoomId, signal) {
796
+ const invalid = () => new GatewayToolError('Invalid Room list page', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
797
+ if (afterRoomId !== '' && !PANEL_ID.test(afterRoomId))
798
+ throw invalid();
799
+ const body = record(await this.callTool('work_room_read', { operation: 'activity', share_only: true, after_room_id: afterRoomId, limit: 50 }, signal));
800
+ if (!body || !Array.isArray(body.rooms) || body.rooms.length > 50)
801
+ throw invalid();
802
+ const rooms = body.rooms.map(row => {
803
+ const item = record(row) ?? {};
804
+ if (typeof item.room_id !== 'string' || !PANEL_ID.test(item.room_id) || !isCount(item.unread_count))
805
+ throw invalid();
806
+ return { roomId: item.room_id, name: text(item.name).slice(0, PANEL_TITLE), unreadCount: item.unread_count };
807
+ });
808
+ const next = body.next_cursor;
809
+ if (next !== null && (typeof next !== 'string' || rooms.length === 0 || next !== rooms[rooms.length - 1].roomId))
810
+ throw invalid();
811
+ return { rooms, nextCursor: next };
812
+ }
813
+ /** work_room_read show for the Rooms tab: the Room's current members and membership revision. */
814
+ async showRoomForPanel(roomId, signal) {
815
+ const invalid = () => new GatewayToolError('Invalid Work Room', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
816
+ const room = record(record(await this.callTool('work_room_read', { operation: 'show', room_id: roomId }, signal))?.room);
817
+ if (!room || room.id !== roomId || !isCount(room.membership_revision) || room.membership_revision < 1 || !Array.isArray(room.members)) {
818
+ throw invalid();
819
+ }
820
+ const members = room.members.map(row => {
821
+ const member = record(row) ?? {};
822
+ if (typeof member.actor_id !== 'string' || member.actor_id === '')
823
+ throw invalid();
824
+ return { actorId: member.actor_id, name: (text(member.display_name) || member.actor_id).slice(0, PANEL_TITLE) };
825
+ });
826
+ return { id: roomId, name: text(room.name).slice(0, PANEL_TITLE), status: text(room.status),
827
+ membershipRevision: room.membership_revision, members };
828
+ }
829
+ /** work_room_read threads for the Rooms tab: one page of up to 100 threads after a creation sequence. */
830
+ async readRoomThreadsPage(roomId, afterSequence, signal) {
831
+ const invalid = () => new GatewayToolError('Invalid Room threads', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
832
+ const body = record(await this.callTool('work_room_read', { operation: 'threads', room_id: roomId, after_sequence: afterSequence, limit: 100 }, signal));
833
+ if (!body || !Array.isArray(body.threads) || body.threads.length > 100
834
+ || !(body.next_cursor === null || (isCount(body.next_cursor) && body.next_cursor > afterSequence))) {
835
+ throw invalid();
836
+ }
837
+ const threads = body.threads.map(row => {
838
+ const item = record(row) ?? {};
839
+ if (typeof item.id !== 'string' || !PANEL_ID.test(item.id) || !isCount(item.unread_count) || !isCount(item.latest_message_sequence)) {
840
+ throw invalid();
841
+ }
842
+ return { id: item.id, title: text(item.title).slice(0, PANEL_TITLE), unreadCount: item.unread_count,
843
+ latestMessageSequence: item.latest_message_sequence };
844
+ });
845
+ return { threads, nextCursor: body.next_cursor };
846
+ }
847
+ /**
848
+ * work_room_read messages for the Rooms tab: the newest 20, or the unread ones up to 100, oldest first. The Gateway
849
+ * records fetched for exactly these rows; fetched is not presented.
850
+ */
851
+ async readRoomMessages(roomId, threadId, mode, signal) {
852
+ const invalid = () => new GatewayToolError('Invalid Room messages', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
853
+ const limit = mode === 'latest' ? 20 : 100;
854
+ const body = record(await this.callTool('work_room_read', { operation: 'messages', room_id: roomId, thread_id: threadId,
855
+ limit, ...(mode === 'latest' ? { latest: true } : { unread_only: true }) }, signal));
856
+ if (!body || !Array.isArray(body.messages) || body.messages.length > limit)
857
+ throw invalid();
858
+ return body.messages.map(row => {
859
+ const item = record(row) ?? {};
860
+ if (typeof item.id !== 'string' || !PANEL_ID.test(item.id) || item.thread_id !== threadId || !isCount(item.room_sequence)
861
+ || typeof item.author_actor_id !== 'string' || typeof item.kind !== 'string' || typeof item.body !== 'string') {
862
+ throw invalid();
863
+ }
864
+ return { id: item.id, sequence: item.room_sequence, authorActorId: item.author_actor_id, kind: item.kind, body: item.body,
865
+ createdAt: isCount(item.created_at) ? item.created_at : 0 };
866
+ });
867
+ }
868
+ /** work_room_receipt_report presented, for up to 100 messages the window has drawn on screen. */
869
+ async reportRoomPresented(roomId, messageIds, signal) {
870
+ return this.roomReceipt(roomId, { state: 'presented' }, messageIds, signal);
871
+ }
872
+ /** work_room_receipt_report user_acknowledged, only from the person's explicit Mark read in the window. */
873
+ async acknowledgeRoomMessages(roomId, messageIds, signal) {
874
+ return this.roomReceipt(roomId, { state: 'user_acknowledged', human_confirmed: true }, messageIds, signal);
875
+ }
876
+ /** work_room_write post for the Rooms tab, sent only after the person confirmed the members in the window. */
877
+ async postRoomReply(input, signal) {
878
+ await this.callTool('work_room_write', { operation: 'post', room_id: input.roomId, thread_id: input.threadId,
879
+ body: input.body, membership_revision: input.membershipRevision, idempotency_key: input.idempotencyKey }, signal);
880
+ }
881
+ async roomReceipt(roomId, state, messageIds, signal) {
882
+ const invalid = () => new GatewayToolError('Invalid Room receipt', 'work_room_receipt_report', 'GATEWAY_INVALID_TOOL_RESULT');
883
+ if (messageIds.length < 1 || messageIds.length > 100)
884
+ throw invalid();
885
+ const receipt = record(record(await this.callTool('work_room_receipt_report', { room_id: roomId, ...state, message_ids: [...messageIds] }, signal))?.receipt);
886
+ if (!receipt || !Array.isArray(receipt.message_ids))
887
+ throw invalid();
888
+ return receipt.message_ids.length;
889
+ }
792
890
  async authorizeRunnerChannel(socketId, signal) {
793
891
  const result = await this.callTool("local_agent_runner_channel_auth", {
794
892
  runner_id: this.runnerId,
@@ -1066,3 +1164,6 @@ function record(value) {
1066
1164
  function text(value) {
1067
1165
  return typeof value === 'string' ? value : '';
1068
1166
  }
1167
+ function isCount(value) {
1168
+ return Number.isSafeInteger(value) && value >= 0;
1169
+ }
@@ -4,7 +4,7 @@ import { createServer } from "node:net";
4
4
  import { dirname, join } from "node:path";
5
5
  import { MAX_SOCKET_PATH_BYTES } from "../asks/hub-protocol.js";
6
6
  import { removeStaleSocket } from "../asks/hub.js";
7
- import { MAX_MENUBAR_LINE_BYTES, MENUBAR_SOCKET, noticeLine, parseAppLine, replyLine, stateLine } from "./protocol.js";
7
+ import { MAX_MENUBAR_LINE_BYTES, MENUBAR_SOCKET, isPanelRequest, noticeLine, parseAppLine, replyLine, stateLine } from "./protocol.js";
8
8
  /** A notice the app has not confirmed by then goes to the osascript notice instead. */
9
9
  export const NOTICE_ACK_MS = 2_000;
10
10
  const PANEL_FAILED = { ok: false, error: "failed", message: "The runner could not answer. Try again." };
@@ -35,7 +35,7 @@ export class MenubarChannel {
35
35
  on(event, listener) {
36
36
  this.listeners[event].push(listener);
37
37
  }
38
- /** The handler for the request panel's list, show and answer; without one each request is answered failed. */
38
+ /** The handler for the window's request panel and Rooms tab; without one each request is answered failed. */
39
39
  onPanel(handler) {
40
40
  this.panel = handler;
41
41
  }
@@ -158,7 +158,7 @@ export class MenubarChannel {
158
158
  handle(message, socket) {
159
159
  if (message === null)
160
160
  return;
161
- if (message.type === "list" || message.type === "show" || message.type === "answer") {
161
+ if (isPanelRequest(message)) {
162
162
  void this.answerPanel(message, socket);
163
163
  return;
164
164
  }
@@ -1,14 +1,16 @@
1
1
  import { MAX_ANSWER_BYTES } from "./protocol.js";
2
- /** Each Gateway call the panel makes is given up after this long. */
2
+ import { callRoom, checkRoomRequest } from "./room-panel.js";
3
+ /** Each app message the panel answers is given up after this long. */
3
4
  export const PANEL_CALL_MS = 30_000;
4
5
  const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
5
6
  const MAX_REASON = 300;
6
7
  const FAILED = "The runner could not reach the AI Gateway. Try again.";
8
+ const AUDIENCE_CHANGED = "The Room members changed. Check the new list and post again.";
7
9
  /**
8
- * The daemon's side of the menu bar request panel (Gateway Work Item d2b6d0da). It is the allowlist for what the app
9
- * may ask: list what waits for the person, show one Work Request, answer one. Ids and the answer are checked before
10
- * any call, one call runs at a time, and nothing is kept after the reply. Logs carry the request type and an error
11
- * code only, never a title, body or answer.
10
+ * The daemon's side of the menu bar window (Gateway Work Items d2b6d0da and b35d65cb). It is the allowlist for what
11
+ * the app may ask: the request panel lists, shows and answers Work Requests; the Rooms tab's messages are checked and
12
+ * called in room-panel.ts. Ids and text are checked before any call, one call runs at a time, and nothing is kept
13
+ * after the reply. Logs carry the message type, an error code and counts only, never a name, title or body.
12
14
  */
13
15
  export function createPanelHandler(input) {
14
16
  let running = false;
@@ -32,6 +34,8 @@ export function createPanelHandler(input) {
32
34
  case "answer":
33
35
  await input.gateway.answerWorkRequest(request.requestId, request.body.trim(), signal);
34
36
  return { answered: true };
37
+ default:
38
+ return callRoom(request, input.gateway, signal);
35
39
  }
36
40
  };
37
41
  return async (request) => {
@@ -50,6 +54,17 @@ export function createPanelHandler(input) {
50
54
  }
51
55
  catch { /* A refresh that fails never undoes a sent answer. */ }
52
56
  }
57
+ if (request.type === "room_presented")
58
+ log({ event: "menubar_panel_presented", count: data.presented });
59
+ if (request.type === "room_mark_read") {
60
+ log({ event: "menubar_panel_marked_read", count: data.acknowledged });
61
+ try {
62
+ input.onMarkedRead?.();
63
+ }
64
+ catch { /* A rescan that fails never undoes a receipt. */ }
65
+ }
66
+ if (request.type === "room_post")
67
+ log({ event: "menubar_panel_posted" });
53
68
  return { ok: true, data };
54
69
  }
55
70
  catch (error) {
@@ -57,6 +72,8 @@ export function createPanelHandler(input) {
57
72
  log({ event: "menubar_panel_failed", request: request.type, code });
58
73
  const reason = error?.reason;
59
74
  if (code === "GATEWAY_TOOL_REJECTED" && typeof reason === "string" && reason !== "") {
75
+ if (request.type === "room_post" && reason.includes("Room audience changed"))
76
+ return refusal("audience_changed", AUDIENCE_CHANGED);
60
77
  return refusal("refused", reason.slice(0, MAX_REASON));
61
78
  }
62
79
  return refusal("failed", FAILED);
@@ -69,6 +86,8 @@ export function createPanelHandler(input) {
69
86
  function check(request) {
70
87
  if (request.type === "list")
71
88
  return null;
89
+ if (request.type !== "show" && request.type !== "answer")
90
+ return checkRoomRequest(request);
72
91
  if (!ID.test(request.requestId))
73
92
  return "That request id is not valid.";
74
93
  if (request.type === "answer") {
@@ -19,14 +19,34 @@
19
19
  * {"v":1,"type":"answer","req":"<uuid>","request_id":"<uuid>","body":"…"}
20
20
  * {"v":1,"type":"reply","req":"<uuid>","ok":true,"data":{…}}
21
21
  * {"v":1,"type":"reply","req":"<uuid>","ok":false,"error":"busy|invalid|refused|failed","message":"…"}
22
+ *
23
+ * The Rooms tab (Work Item b35d65cb) adds, each answered by one reply:
24
+ * {"v":1,"type":"room_list","req":"<uuid>"}
25
+ * {"v":1,"type":"room_show","req":"<uuid>","room_id":"<uuid>"}
26
+ * {"v":1,"type":"room_thread","req":"<uuid>","room_id":"<uuid>","thread_id":"<uuid>"}
27
+ * {"v":1,"type":"room_presented","req":"<uuid>","room_id":"<uuid>","message_ids":["<uuid>",…]}
28
+ * {"v":1,"type":"room_mark_read","req":"<uuid>","room_id":"<uuid>","message_ids":["<uuid>",…]} the person pressed Mark read
29
+ * {"v":1,"type":"room_post","req":"<uuid>","room_id":"<uuid>","thread_id":"<uuid>","body":"…","membership_revision":n,
30
+ * "idempotency_key":"<uuid>"} sent only after the person confirmed the members
31
+ * a refused post can reply with "error":"audience_changed"
22
32
  */
23
33
  export const MENUBAR_SOCKET = "menubar.sock";
24
- /** A request body is at most 8,000 characters and a list at most 50 items, so every legitimate line fits. */
25
- export const MAX_MENUBAR_LINE_BYTES = 256 * 1024;
34
+ /** A Room thread reply holds at most 120 messages of at most 8,000 bytes, so every legitimate line fits. */
35
+ export const MAX_MENUBAR_LINE_BYTES = 4 * 1024 * 1024;
26
36
  /** The Gateway's own limit for one answer, in UTF-8 bytes after trimming. */
27
37
  export const MAX_ANSWER_BYTES = 8_000;
38
+ /** The Gateway's own limit for one Room message, in UTF-8 bytes after trimming. */
39
+ export const MAX_ROOM_POST_BYTES = 8_000;
28
40
  const NOTICE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
29
41
  const APP_VERSION = /^\d+\.\d+\.\d+$/;
42
+ /** No legitimate message carries more ids than this; the handler checks the real limits. */
43
+ const MAX_LIST_ITEMS = 1_000;
44
+ const PANEL_TYPES = new Set(["list", "show", "answer", "room_list", "room_show", "room_thread", "room_presented",
45
+ "room_mark_read", "room_post"]);
46
+ /** True for every message the panel handler answers with a reply. */
47
+ export function isPanelRequest(message) {
48
+ return PANEL_TYPES.has(message.type);
49
+ }
30
50
  export function stateLine(state) {
31
51
  return `${JSON.stringify({ v: 1, type: "state", room_unread: state.roomUnread, requests: state.requests,
32
52
  runner_version: state.runnerVersion, inbox_url: state.inboxUrl })}\n`;
@@ -72,6 +92,24 @@ export function parseAppLine(line) {
72
92
  case "answer":
73
93
  return keys === 5 && isId(record.req) && typeof record.request_id === "string" && typeof record.body === "string"
74
94
  ? { type: "answer", req: record.req, requestId: record.request_id, body: record.body } : null;
95
+ case "room_list":
96
+ return keys === 3 && isId(record.req) ? { type: "room_list", req: record.req } : null;
97
+ case "room_show":
98
+ return keys === 4 && isId(record.req) && typeof record.room_id === "string"
99
+ ? { type: "room_show", req: record.req, roomId: record.room_id } : null;
100
+ case "room_thread":
101
+ return keys === 5 && isId(record.req) && typeof record.room_id === "string" && typeof record.thread_id === "string"
102
+ ? { type: "room_thread", req: record.req, roomId: record.room_id, threadId: record.thread_id } : null;
103
+ case "room_presented":
104
+ case "room_mark_read":
105
+ return keys === 5 && isId(record.req) && typeof record.room_id === "string" && isStringList(record.message_ids)
106
+ ? { type: record.type, req: record.req, roomId: record.room_id, messageIds: [...record.message_ids] } : null;
107
+ case "room_post":
108
+ return keys === 8 && isId(record.req) && typeof record.room_id === "string" && typeof record.thread_id === "string"
109
+ && typeof record.body === "string" && typeof record.membership_revision === "number"
110
+ && typeof record.idempotency_key === "string"
111
+ ? { type: "room_post", req: record.req, roomId: record.room_id, threadId: record.thread_id, body: record.body,
112
+ membershipRevision: record.membership_revision, idempotencyKey: record.idempotency_key } : null;
75
113
  default:
76
114
  return null;
77
115
  }
@@ -79,3 +117,6 @@ export function parseAppLine(line) {
79
117
  function isId(value) {
80
118
  return typeof value === "string" && NOTICE_ID.test(value);
81
119
  }
120
+ function isStringList(value) {
121
+ return Array.isArray(value) && value.length <= MAX_LIST_ITEMS && value.every(item => typeof item === "string");
122
+ }
@@ -0,0 +1,115 @@
1
+ import { MAX_ROOM_POST_BYTES } from "./protocol.js";
2
+ /** The Room list reads at most this many activity pages of 50 (500 Rooms, as the web Inbox). */
3
+ export const ROOM_LIST_PAGES = 10;
4
+ /** A Room shows at most this many thread pages of 100. */
5
+ export const ROOM_THREAD_PAGES = 5;
6
+ /** A thread shows the newest 20 and at most 100 unread messages, so at most 120 are reported presented. */
7
+ export const MAX_PRESENTED_IDS = 120;
8
+ /** The Gateway takes at most 100 ids per receipt call. */
9
+ export const MAX_RECEIPT_IDS = 100;
10
+ const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
11
+ /** Why a Room request is refused before any Gateway call, or null. */
12
+ export function checkRoomRequest(request) {
13
+ switch (request.type) {
14
+ case "room_list":
15
+ return null;
16
+ case "room_show":
17
+ return checkRoomId(request.roomId);
18
+ case "room_thread":
19
+ return checkRoomId(request.roomId) ?? checkThreadId(request.threadId);
20
+ case "room_presented":
21
+ return checkRoomId(request.roomId) ?? checkIds(request.messageIds, MAX_PRESENTED_IDS);
22
+ case "room_mark_read":
23
+ return checkRoomId(request.roomId) ?? checkIds(request.messageIds, MAX_RECEIPT_IDS);
24
+ case "room_post": {
25
+ const bad = checkRoomId(request.roomId) ?? checkThreadId(request.threadId);
26
+ if (bad !== null)
27
+ return bad;
28
+ if (!ID.test(request.idempotencyKey))
29
+ return "That reply key is not valid.";
30
+ if (!Number.isSafeInteger(request.membershipRevision) || request.membershipRevision < 1)
31
+ return "That membership revision is not valid.";
32
+ const bytes = Buffer.byteLength(request.body.trim());
33
+ if (bytes === 0)
34
+ return "Write a reply first.";
35
+ if (bytes > MAX_ROOM_POST_BYTES)
36
+ return `A reply can be at most ${MAX_ROOM_POST_BYTES} bytes.`;
37
+ return null;
38
+ }
39
+ }
40
+ }
41
+ /** The Gateway calls behind one checked Room request, and the reply data the app reads. */
42
+ export async function callRoom(request, gateway, signal) {
43
+ switch (request.type) {
44
+ case "room_list": {
45
+ const rooms = [];
46
+ let cursor = "";
47
+ for (let page = 0; page < ROOM_LIST_PAGES; page += 1) {
48
+ const read = await gateway.readRoomListPage(cursor, signal);
49
+ rooms.push(...read.rooms);
50
+ if (read.nextCursor === null)
51
+ return { rooms: rooms.map(roomRow), complete: true };
52
+ cursor = read.nextCursor;
53
+ }
54
+ return { rooms: rooms.map(roomRow), complete: false };
55
+ }
56
+ case "room_show": {
57
+ const room = await gateway.showRoomForPanel(request.roomId, signal);
58
+ const threads = [];
59
+ let after = 0;
60
+ for (let page = 0; page < ROOM_THREAD_PAGES; page += 1) {
61
+ const read = await gateway.readRoomThreadsPage(request.roomId, after, signal);
62
+ threads.push(...read.threads);
63
+ if (read.nextCursor === null)
64
+ break;
65
+ after = read.nextCursor;
66
+ }
67
+ return {
68
+ room: { id: room.id, name: room.name, status: room.status, membership_revision: room.membershipRevision,
69
+ members: room.members.map(member => ({ actor_id: member.actorId, name: member.name })) },
70
+ threads: threads.map(thread => ({ id: thread.id, title: thread.title, unread_count: thread.unreadCount,
71
+ latest_message_sequence: thread.latestMessageSequence })),
72
+ };
73
+ }
74
+ case "room_thread": {
75
+ const latest = await gateway.readRoomMessages(request.roomId, request.threadId, "latest", signal);
76
+ const unread = await gateway.readRoomMessages(request.roomId, request.threadId, "unread", signal);
77
+ const unreadIds = new Set(unread.map(message => message.id));
78
+ const byId = new Map();
79
+ for (const message of [...latest, ...unread])
80
+ byId.set(message.id, message);
81
+ return { messages: [...byId.values()].sort((a, b) => a.sequence - b.sequence).map(message => ({ id: message.id,
82
+ author_actor_id: message.authorActorId, kind: message.kind, body: message.body, created_at: message.createdAt,
83
+ unread: unreadIds.has(message.id) })) };
84
+ }
85
+ case "room_presented": {
86
+ let presented = 0;
87
+ for (let start = 0; start < request.messageIds.length; start += MAX_RECEIPT_IDS) {
88
+ presented += await gateway.reportRoomPresented(request.roomId, request.messageIds.slice(start, start + MAX_RECEIPT_IDS), signal);
89
+ }
90
+ return { presented };
91
+ }
92
+ case "room_mark_read":
93
+ return { acknowledged: await gateway.acknowledgeRoomMessages(request.roomId, request.messageIds, signal) };
94
+ case "room_post":
95
+ await gateway.postRoomReply({ roomId: request.roomId, threadId: request.threadId, body: request.body.trim(),
96
+ membershipRevision: request.membershipRevision, idempotencyKey: request.idempotencyKey }, signal);
97
+ return { posted: true };
98
+ }
99
+ }
100
+ function roomRow(room) {
101
+ return { room_id: room.roomId, name: room.name, unread_count: room.unreadCount };
102
+ }
103
+ function checkRoomId(value) {
104
+ return ID.test(value) ? null : "That Room id is not valid.";
105
+ }
106
+ function checkThreadId(value) {
107
+ return ID.test(value) ? null : "That thread id is not valid.";
108
+ }
109
+ function checkIds(ids, max) {
110
+ if (ids.length < 1 || ids.length > max)
111
+ return `Send 1 to ${max} message ids.`;
112
+ if (new Set(ids).size !== ids.length || !ids.every(value => ID.test(value)))
113
+ return "The message ids are not valid.";
114
+ return null;
115
+ }
@@ -18,7 +18,8 @@ export function buildMenubar(input) {
18
18
  channel.on("quit", () => input.log({ event: "menubar_app_quit" }));
19
19
  if (input.panel !== undefined) {
20
20
  channel.onPanel(createPanelHandler({ gateway: input.panel, log: input.log,
21
- onAnswered: () => { void runtime.refreshRequests(true); } }));
21
+ onAnswered: () => { void runtime.refreshRequests(true); },
22
+ onMarkedRead: () => { void input.rescanRooms?.().catch(() => input.log({ event: "menubar_room_rescan_failed" })); } }));
22
23
  }
23
24
  const launch = input.launch ?? (() => launchMenubarApp({ home: input.home, stateRoot: input.stateRoot, socketPath }));
24
25
  const quit = input.quit ?? (() => quitMenubarApp());
@@ -66,7 +66,10 @@ export function assertCandidateReviewPackages(approved, bound) {
66
66
  approved.local_fix_scope !== undefined || approved.verification !== undefined ||
67
67
  !approved.candidate_review_record || !bound.candidate_review_record ||
68
68
  protocolHash(approved.candidate_review) !== protocolHash(bound.candidate_review) ||
69
- protocolHash(approved.candidate_review_record) !== protocolHash(bound.candidate_review_record))
69
+ protocolHash(approved.candidate_review_record) !== protocolHash(bound.candidate_review_record) ||
70
+ ((approved.candidate_review_execution !== undefined || bound.candidate_review_execution !== undefined)
71
+ && (approved.candidate_review_execution === undefined || bound.candidate_review_execution === undefined
72
+ || protocolHash(approved.candidate_review_execution) !== protocolHash(bound.candidate_review_execution))))
70
73
  throw Error();
71
74
  }
72
75
  catch {
@@ -23,6 +23,14 @@ function textOf(content) {
23
23
  const parts = content.filter((block) => record(block) && block.type === 'text' && typeof block.text === 'string').map(block => block.text);
24
24
  return parts.length ? parts.join('') : null;
25
25
  }
26
+ /** Tool evidence accepts text only. Never discard an unrecognized block and salvage a passing JSON object. */
27
+ function toolResultText(content) {
28
+ if (typeof content === 'string')
29
+ return content;
30
+ if (!Array.isArray(content) || content.length === 0 || !content.every((block) => record(block) && Object.keys(block).length === 2 && block.type === 'text' && typeof block.text === 'string'))
31
+ return 'CLAUDE_REVIEW_TOOL_RESULT_CONTENT_REJECTED';
32
+ return content.map(block => block.text).join('');
33
+ }
26
34
  const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
27
35
  /** The user's own text. The real CLI prepends context in system-reminder tags, in its own block or the same one. */
28
36
  function userText(content) {
@@ -131,7 +139,7 @@ export async function startClaudeReviewProvider(script) {
131
139
  continue;
132
140
  toolResultIds.push(block.tool_use_id);
133
141
  if (!toolResults.has(block.tool_use_id))
134
- toolResults.set(block.tool_use_id, { isError: block.is_error === true, text: typeof block.content === 'string' ? block.content : JSON.stringify(block.content ?? '') });
142
+ toolResults.set(block.tool_use_id, { isError: block.is_error === true, text: toolResultText(block.content) });
135
143
  }
136
144
  const firstUser = messages.find(message => message.role === 'user'), { text: firstUserText, reminders } = firstUser ? userText(firstUser.content) : { text: null, reminders: 0 };
137
145
  // The scripted turn: the launched model, the exact review prompt as the first user text and an offered tool list.
@@ -1,6 +1,14 @@
1
1
  import { protocolHash } from '../publication/candidate.js';
2
2
  import { D4_REVIEW_INVENTORY } from './local-tool-policy.js';
3
3
  const LIMIT_KEYS = ['max_candidates', 'max_process_starts', 'max_runtime_seconds', 'automatic_retries', 'max_total_tokens', 'max_estimated_cost_microusd'];
4
+ export function parseD4RecordLimits(value) {
5
+ const names = LIMIT_KEYS.slice(0, 4);
6
+ if (!object(value) || !keys(value, names) || !names.every(k => integer(value[k]))
7
+ || value.max_candidates !== 1 || value.max_process_starts !== 1 || value.automatic_retries !== 0
8
+ || !integer(value.max_runtime_seconds, 1) || value.max_runtime_seconds > 900)
9
+ return reject('D4_LIMIT_PROFILE_REJECTED');
10
+ return Object.freeze({ ...value });
11
+ }
4
12
  const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
5
13
  const keys = (v, expected) => Object.keys(v).length === expected.length && expected.every(k => Object.hasOwn(v, k));
6
14
  const integer = (v, min = 0) => typeof v === 'number' && Number.isSafeInteger(v) && v >= min;
@@ -23,6 +31,15 @@ export function assertCurrentD4LimitSupport() { return reject('D4_STRICT_USAGE_E
23
31
  const d4 = (p) => ['candidate_review', 'candidate_review_record', 'candidate_review_execution'].some(k => Object.hasOwn(p, k))
24
32
  || p.contract_purpose === 'candidate_review' || p.tool_inventory === D4_REVIEW_INVENTORY;
25
33
  /** Before token/security/tool acquisition. The later inventory check also catches a D4 security pin without package markers. */
34
+ export const D4_OBSERVED_EXECUTION_SCHEMA = 'd4-review-execution-v2';
35
+ export const D4_OBSERVED_EXECUTION_STAGE = 'cli_observed_usage_v1';
36
+ export const D4_OBSERVED_EXECUTION_CAPABILITY = 'd4_review_observed_usage_v1';
37
+ export const D4_RECORDED_EXECUTION_SCHEMA = 'd4-review-execution-v3';
38
+ export const D4_RECORDED_EXECUTION_CAPABILITY = 'd4_review_recorded_usage_v1';
39
+ export const isD4ExecutionSchema = (s) => s === D4_OBSERVED_EXECUTION_SCHEMA || s === D4_RECORDED_EXECUTION_SCHEMA;
40
+ const PROFILE_KEYS = ['schema_version', 'decision_revision_id', 'runtime_adapter', 'provider', 'model_id', 'tool_inventory', 'tools_sha256', 'cli_security_fingerprint',
41
+ 'runner_release_sha256', 'host_policy_sha256', 'review_status', 'limits', 'usage_enforcement'];
42
+ /** Immutable v2 observed-stop and v3 recording-only packages; neither grants launch authority. */
26
43
  export function assertD4PackageLimitSupport(bound, approved, resume) {
27
44
  if (![bound, approved].some(p => object(p) && d4(p)))
28
45
  return;
@@ -37,8 +54,20 @@ export function assertD4PackageLimitSupport(bound, approved, resume) {
37
54
  return reject('D4_LIMIT_PROFILE_REQUIRED');
38
55
  if (protocolHash(a) !== protocolHash(b))
39
56
  return reject('D4_LIMIT_PROFILE_CHANGED');
40
- assessD4LimitEnforcement(a.profile.limits);
41
- return assertCurrentD4LimitSupport();
57
+ if (!isD4ExecutionSchema(a.schema_version))
58
+ return assertCurrentD4LimitSupport();
59
+ const recordOnly = a.schema_version === D4_RECORDED_EXECUTION_SCHEMA;
60
+ const p = a.profile, u = p.usage_enforcement;
61
+ if (!keys(a, ['schema_version', 'stage', 'profile', 'profile_fingerprint', 'registration_fingerprint']) || a.stage !== (recordOnly ? 'cli_recorded_usage_v1' : D4_OBSERVED_EXECUTION_STAGE)
62
+ || !keys(p, PROFILE_KEYS) || p.schema_version !== (recordOnly ? 'd4-review-execution-profile-v3' : 'd4-review-execution-profile-v2') || p.review_status !== 'reviewed'
63
+ || p.runtime_adapter !== 'claude_code_cli' || p.provider !== 'anthropic' || p.model_id !== 'claude-sonnet-5-5' || p.tool_inventory !== D4_REVIEW_INVENTORY
64
+ || typeof p.decision_revision_id !== 'string' || !(/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/).test(p.decision_revision_id)
65
+ || !['tools_sha256', 'cli_security_fingerprint', 'runner_release_sha256', 'host_policy_sha256'].every(k => typeof p[k] === 'string' && /^[a-f0-9]{64}$/.test(p[k]))
66
+ || typeof a.registration_fingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(a.registration_fingerprint)
67
+ || a.profile_fingerprint !== protocolHash(p) || !object(u) || !keys(u, ['tokens', 'estimated_cost', 'hard_stop'])
68
+ || u.tokens !== (recordOnly ? 'record_only' : 'observed_stop') || u.estimated_cost !== (recordOnly ? 'record_only' : 'observed_stop') || u.hard_stop !== false)
69
+ return reject('D4_LIMIT_PROFILE_REJECTED');
70
+ return recordOnly ? parseD4RecordLimits(p.limits) : parseD4Limits(p.limits);
42
71
  }
43
72
  /** Conservative diagnostic interpretation only. All token partitions and the CLI's estimated cost must be explicit.
44
73
  * This does not establish that a CLI result is cumulative, timely, complete or an invoice/weekly-limit measurement. */
@@ -56,9 +85,41 @@ export function parseD4ResultUsage(value, sequence) {
56
85
  return reject('D4_USAGE_UNAVAILABLE');
57
86
  return { sequence, total_tokens: tokens, estimated_cost_microusd: micro };
58
87
  }
88
+ /** CLI transport observations only. Message IDs deduplicate cumulative revisions of the same assistant message.
89
+ * Cost is available only when the CLI reports total_cost_usd; absent intermediate cost is not a zero-cost claim.
90
+ * Final root usage must cover every observation. Never consume usage embedded in model text/tool output. */
91
+ export class D4CliUsageTracker {
92
+ messages = new Map();
93
+ sequence = 0;
94
+ tokens = 0;
95
+ assistant(value) {
96
+ if (value.type !== 'assistant' || !object(value.message) || value.message.usage === undefined)
97
+ return;
98
+ const m = value.message, id = m.id;
99
+ if (typeof id !== 'string' || !id || id.length > 191 || this.messages.size >= 4096 && !this.messages.has(id))
100
+ return reject('D4_USAGE_UNAVAILABLE');
101
+ const parsed = parseD4ResultUsage({ type: 'result', usage: m.usage, total_cost_usd: 0 }, 1), previous = this.messages.get(id);
102
+ if (previous !== undefined && parsed.total_tokens < previous)
103
+ return reject('D4_USAGE_UNAVAILABLE');
104
+ if (previous !== parsed.total_tokens) {
105
+ this.tokens += parsed.total_tokens - (previous ?? 0);
106
+ this.messages.set(id, parsed.total_tokens);
107
+ this.sequence++;
108
+ }
109
+ if (!integer(this.tokens))
110
+ return reject('D4_USAGE_UNAVAILABLE');
111
+ return { sequence: Math.max(1, this.sequence), total_tokens: this.tokens, estimated_cost_microusd: 0 };
112
+ }
113
+ final(value) {
114
+ const final = parseD4ResultUsage(value, ++this.sequence);
115
+ if (final.total_tokens < this.tokens)
116
+ return reject('D4_USAGE_UNAVAILABLE');
117
+ return final;
118
+ }
119
+ }
59
120
  const STOP_TIMEOUT_MS = 6000;
60
- /** Local owned-process primitive for synthetic feasibility tests and a future reviewed runtime.
61
- * It grants no admission; the production adapter above remains blocked. Observed caps may already have overshot.
121
+ /** Local owned-process watchdog. It grants no admission; the adapter also requires a trusted reviewed host.
122
+ * V2 stops on observed thresholds (possible overshoot); V3 records usage without numeric stopping.
62
123
  * In-memory start accounting is not a durable restart fence; existing Job/lease owners must provide that later. */
63
124
  export class D4ObservedLimitWatchdog {
64
125
  host;
@@ -71,9 +132,12 @@ export class D4ObservedLimitWatchdog {
71
132
  reason;
72
133
  status = 'idle';
73
134
  stopPromise;
135
+ stoppedSignal;
136
+ signalStopped;
74
137
  constructor(limits, host) {
75
138
  this.host = host;
76
- this.limits = parseD4Limits(limits);
139
+ this.limits = object(limits) && !Object.hasOwn(limits, 'max_total_tokens') ? parseD4RecordLimits(limits) : parseD4Limits(limits);
140
+ this.stoppedSignal = new Promise(resolve => { this.signalStopped = resolve; });
77
141
  }
78
142
  now() { const n = (this.host.now ?? (() => performance.now()))(); if (!Number.isFinite(n) || n < this.lastNow)
79
143
  return reject('D4_USAGE_UNAVAILABLE'); this.lastNow = n; return n; }
@@ -90,6 +154,8 @@ export class D4ObservedLimitWatchdog {
90
154
  }
91
155
  diagnostic() { return { status: this.status, reason_code: this.status === 'unconfirmed' ? 'D4_STOP_UNCONFIRMED' : this.reason ?? null }; }
92
156
  settled() { return this.stopPromise ?? Promise.resolve(); }
157
+ /** Wakes a silent stream consumer after a stop attempt, including unconfirmed cleanup. */
158
+ stopped() { return this.stoppedSignal; }
93
159
  async observe(snapshot) {
94
160
  if (this.stopPromise)
95
161
  return this.stopPromise;
@@ -113,9 +179,9 @@ export class D4ObservedLimitWatchdog {
113
179
  return this.stop('D4_USAGE_UNAVAILABLE');
114
180
  }
115
181
  this.last = { ...s };
116
- if (s.total_tokens >= this.limits.max_total_tokens)
182
+ if ('max_total_tokens' in this.limits && s.total_tokens >= this.limits.max_total_tokens)
117
183
  return this.stop('D4_OBSERVED_TOKEN_LIMIT');
118
- if (s.estimated_cost_microusd >= this.limits.max_estimated_cost_microusd)
184
+ if ('max_estimated_cost_microusd' in this.limits && s.estimated_cost_microusd >= this.limits.max_estimated_cost_microusd)
119
185
  return this.stop('D4_OBSERVED_COST_LIMIT');
120
186
  }
121
187
  cancel() { return this.stop('D4_CANCELLED'); }
@@ -170,6 +236,7 @@ export class D4ObservedLimitWatchdog {
170
236
  }
171
237
  finally {
172
238
  clearTimeout(timer);
239
+ this.signalStopped();
173
240
  }
174
241
  });
175
242
  return this.stopPromise;