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.
- package/README.md +30 -0
- package/dist/src/adapters/claude-code/adapter.js +121 -10
- package/dist/src/adapters/claude-code/runtime-options.js +1 -0
- package/dist/src/adapters/claude-code/stream-parser.js +65 -4
- package/dist/src/adapters/runtime-failure.js +34 -0
- package/dist/src/daemon/execution-coordinator.js +15 -4
- package/dist/src/daemon/runtime.js +65 -9
- package/dist/src/gateway-client.js +101 -0
- package/dist/src/menubar/channel.js +3 -3
- package/dist/src/menubar/panel.js +24 -5
- package/dist/src/menubar/protocol.js +43 -2
- package/dist/src/menubar/room-panel.js +115 -0
- package/dist/src/menubar/wiring.js +2 -1
- package/dist/src/safety/candidate-review-report.js +4 -1
- package/dist/src/safety/claude-review-provider.js +9 -1
- package/dist/src/safety/d4-execution-limits.js +74 -7
- package/dist/src/safety/d4-host-composition.js +100 -0
- package/dist/src/safety/d4-reviewed-hosts.js +2 -0
- package/dist/src/safety/d4-runtime-release.js +9 -0
- package/dist/src/verification/p1-execution-host.js +4 -0
- package/native/app/AI Gateway.app/Contents/Info.plist +2 -2
- package/native/app/AI Gateway.app/Contents/MacOS/AIGatewayMenubar +0 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
9
|
-
* may ask:
|
|
10
|
-
* any call, one call runs at a time, and nothing is kept
|
|
11
|
-
* code only, never a
|
|
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
|
|
25
|
-
export const MAX_MENUBAR_LINE_BYTES =
|
|
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:
|
|
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
|
-
|
|
41
|
-
|
|
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
|
|
61
|
-
*
|
|
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;
|