mtech-ai-local-runner 0.1.110 → 0.1.111
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 +18 -0
- package/dist/src/adapters/claude-code/adapter.js +108 -6
- package/dist/src/adapters/claude-code/runtime-options.js +1 -0
- package/dist/src/daemon/execution-coordinator.js +11 -3
- package/dist/src/daemon/runtime.js +11 -4
- 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/d4-execution-limits.js +57 -2
- 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
package/README.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
## 0.1.111: Work Rooms in the menu bar app
|
|
2
|
+
|
|
3
|
+
The menu bar window now has a **Rooms** tab (Gateway Work Item b35d65cb); the menu's **Show Rooms (n unread)…** opens
|
|
4
|
+
it. Pick a Room, then a thread: it shows the newest 20 messages and every unread one, with unread ones marked.
|
|
5
|
+
**Mark read (n)** marks them read. Write a reply and press **Review reply**; it is posted only after you confirm the
|
|
6
|
+
members it goes to ("Post to 3 members: …?"). If the members changed meanwhile, the window shows the new list and asks
|
|
7
|
+
again. A Room notice opens the Rooms tab at that Room while the runner is connected.
|
|
8
|
+
|
|
9
|
+
- For the Rooms tab the runner calls only `work_room_read`, `work_room_receipt_report` and `work_room_write` operation
|
|
10
|
+
`post`, one at a time, with your own runner sign-in. Room creation, members, new threads and outcome shares stay in
|
|
11
|
+
your AI session; archived Rooms are read-only.
|
|
12
|
+
- Room names, titles and messages stay in memory: neither the runner nor the app writes them to disk or a log.
|
|
13
|
+
Notifications stay generic.
|
|
14
|
+
- A reply is at most 8,000 bytes, the Gateway's own limit.
|
|
15
|
+
|
|
16
|
+
0.1.111 also carries the D4 bounded CLI review wiring merged after 0.1.110 (PR #938). Nothing supplies a D4 execution
|
|
17
|
+
host yet, so D4 execution stays closed.
|
|
18
|
+
|
|
1
19
|
## 0.1.110: Claude returns its report through `--json-schema`
|
|
2
20
|
|
|
3
21
|
Claude jobs now pass `--json-schema`, and the model returns its completion report by calling Claude Code's
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assertD4PackageLimitSupport, assertCurrentD4LimitSupport } from '../../safety/d4-execution-limits.js';
|
|
1
|
+
import { assertD4PackageLimitSupport, assertCurrentD4LimitSupport, D4ObservedLimitWatchdog, D4CliUsageTracker } from '../../safety/d4-execution-limits.js';
|
|
2
2
|
import { CLAUDE_TOKEN_FD, CLAUDE_TOKEN_FD_ENV, pathWithKeychainStub } from "../../safety/claude-runner-token.js";
|
|
3
3
|
import { candidateReviewCompletion } from '../../safety/candidate-review-report.js';
|
|
4
4
|
import path from "node:path";
|
|
@@ -104,6 +104,9 @@ export class ClaudeCodeAdapter {
|
|
|
104
104
|
started = false;
|
|
105
105
|
p1 = false;
|
|
106
106
|
candidateReview = false;
|
|
107
|
+
d4Limits;
|
|
108
|
+
d4Guard;
|
|
109
|
+
d4Usage = new D4CliUsageTracker();
|
|
107
110
|
startupAcquisition;
|
|
108
111
|
revokedTools;
|
|
109
112
|
revokeFailed = false;
|
|
@@ -135,7 +138,9 @@ export class ClaudeCodeAdapter {
|
|
|
135
138
|
let acquired;
|
|
136
139
|
this.startupAcquisition = new Promise(resolve => { acquired = resolve; });
|
|
137
140
|
try {
|
|
138
|
-
assertD4PackageLimitSupport(ctx.taskPackage, ctx.approvedTaskPackage, ctx.resumeSessionId);
|
|
141
|
+
this.d4Limits = assertD4PackageLimitSupport(ctx.taskPackage, ctx.approvedTaskPackage, ctx.resumeSessionId);
|
|
142
|
+
if (this.d4Limits && !this.options.d4ExecutionHost)
|
|
143
|
+
throw Error('D4_REVIEW_HOST_REQUIRED');
|
|
139
144
|
const generation = ctx.generation;
|
|
140
145
|
if (!Number.isSafeInteger(generation) || generation === undefined || generation < 1)
|
|
141
146
|
throw new Error("JOB_SECURITY_REJECTED");
|
|
@@ -160,8 +165,25 @@ export class ClaudeCodeAdapter {
|
|
|
160
165
|
assertAdmission();
|
|
161
166
|
const toolInventory = claudeJobToolInventory(this.options.securityPolicy);
|
|
162
167
|
this.candidateReview = toolInventory === 'local-tools-d4-review-v1';
|
|
163
|
-
if (this.candidateReview)
|
|
168
|
+
if (this.candidateReview && !this.d4Limits)
|
|
164
169
|
assertCurrentD4LimitSupport();
|
|
170
|
+
if (this.d4Limits && !this.candidateReview)
|
|
171
|
+
throw Error('D4_REVIEW_INVENTORY_REJECTED');
|
|
172
|
+
const assertD4Host = async () => {
|
|
173
|
+
if (!this.d4Limits)
|
|
174
|
+
return;
|
|
175
|
+
// Recheck both immutable packages around asynchronous preparation; equality alone is not host authority.
|
|
176
|
+
assertD4PackageLimitSupport(ctx.taskPackage, ctx.approvedTaskPackage, ctx.resumeSessionId);
|
|
177
|
+
const descriptor = ctx.approvedTaskPackage.candidate_review_execution;
|
|
178
|
+
const profile = descriptor.profile, host = this.options.d4ExecutionHost;
|
|
179
|
+
if (ctx.generation !== 1 || resolvedModelId !== profile.model_id || host.runnerReleaseSha256 !== profile.runner_release_sha256 || host.hostPolicySha256 !== profile.host_policy_sha256
|
|
180
|
+
|| this.options.securityPolicy?.pin.toolsSha256 !== profile.tools_sha256 || this.options.securityPolicy?.pin.requirementsSha256 !== profile.cli_security_fingerprint
|
|
181
|
+
|| this.options.securityPolicy?.pin.securityMode !== CLAUDE_RELEASE_MODE)
|
|
182
|
+
throw Error('D4_REVIEW_HOST_REQUIRED');
|
|
183
|
+
await host.assertCurrent(structuredClone(descriptor));
|
|
184
|
+
};
|
|
185
|
+
if (this.d4Limits)
|
|
186
|
+
await assertD4Host();
|
|
165
187
|
if (!ctx.securityBinding)
|
|
166
188
|
throw new Error('SECURITY_REQUIREMENTS_REJECTED');
|
|
167
189
|
this.receipt = createJobSecurityReceipt(this.options.securityPolicy, { jobId: this.options.jobId ?? ctx.bindingId, generation, modelId: resolvedModelId, sourceSha: this.options.securityPolicy.pin.sourceSha, ...ctx.securityBinding, requirements: ctx.approvedTaskPackage?.security_requirements });
|
|
@@ -196,7 +218,7 @@ export class ClaudeCodeAdapter {
|
|
|
196
218
|
const mcpConfigPath = this.options.writeLocalMcpConfig(endpoint);
|
|
197
219
|
const maxBudgetUsd = unbounded
|
|
198
220
|
? null
|
|
199
|
-
: Math.min(this.options.maxBudgetUsd, contractBudget(ctx.taskPackage));
|
|
221
|
+
: Math.min(this.options.maxBudgetUsd, this.d4Limits ? this.d4Limits.max_estimated_cost_microusd / 1_000_000 : contractBudget(ctx.taskPackage));
|
|
200
222
|
const hookPolicy = buildHookPolicy({
|
|
201
223
|
taskPackage: ctx.taskPackage,
|
|
202
224
|
worktreeRoot: this.options.worktreeRoot,
|
|
@@ -227,12 +249,43 @@ export class ClaudeCodeAdapter {
|
|
|
227
249
|
if (this.cancelled)
|
|
228
250
|
throw new Error('adapter startup cancelled');
|
|
229
251
|
assertAdmission();
|
|
252
|
+
if (this.d4Limits)
|
|
253
|
+
await assertD4Host();
|
|
254
|
+
if (this.d4Limits)
|
|
255
|
+
await this.options.assertCurrentInputs?.();
|
|
256
|
+
if (this.cancelled)
|
|
257
|
+
throw new Error('adapter startup cancelled');
|
|
258
|
+
if (this.d4Limits) {
|
|
259
|
+
assertD4PackageLimitSupport(ctx.taskPackage, ctx.approvedTaskPackage, ctx.resumeSessionId);
|
|
260
|
+
assertAdmission();
|
|
261
|
+
}
|
|
230
262
|
const handle = this.options.processHost.spawn(this.options.binaryPath ?? "claude", args, {
|
|
231
263
|
cwd,
|
|
232
264
|
env,
|
|
233
265
|
...(claudeToken === null ? {} : { secretFd3: claudeToken }),
|
|
234
266
|
});
|
|
235
267
|
this.handle = handle;
|
|
268
|
+
if (this.d4Limits) {
|
|
269
|
+
this.d4Guard = new D4ObservedLimitWatchdog(this.d4Limits, { revoke: () => {
|
|
270
|
+
this.localReadObservations();
|
|
271
|
+
this.revokeSecurity();
|
|
272
|
+
this.revokeTools(this.tools);
|
|
273
|
+
if (this.revokeFailed)
|
|
274
|
+
throw Error('D4_STOP_UNCONFIRMED');
|
|
275
|
+
} });
|
|
276
|
+
try {
|
|
277
|
+
this.d4Guard.attach(handle);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
// We own this spawn but cannot certify its process group. Stop the child best-effort;
|
|
281
|
+
// cleanup remains unconfirmed and must never turn into a passing review.
|
|
282
|
+
try {
|
|
283
|
+
handle.kill('SIGKILL');
|
|
284
|
+
}
|
|
285
|
+
catch { /* Preserve the failed ownership proof. */ }
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
236
289
|
if (this.cancelled)
|
|
237
290
|
throw new Error("adapter startup cancelled");
|
|
238
291
|
handle.stdin.write(claudeUserInput(prompt));
|
|
@@ -257,7 +310,8 @@ export class ClaudeCodeAdapter {
|
|
|
257
310
|
});
|
|
258
311
|
let sawTerminal = false;
|
|
259
312
|
try {
|
|
260
|
-
|
|
313
|
+
const lines = this.d4Guard ? this.d4Lines(handle, this.d4Guard) : handle.stdout;
|
|
314
|
+
for await (const line of lines) {
|
|
261
315
|
let message;
|
|
262
316
|
try {
|
|
263
317
|
const value = JSON.parse(line);
|
|
@@ -271,6 +325,29 @@ export class ClaudeCodeAdapter {
|
|
|
271
325
|
// A nested agent message is not evidence that the executor has finished.
|
|
272
326
|
if (message.parent_tool_use_id != null)
|
|
273
327
|
continue;
|
|
328
|
+
if (this.d4Guard && message.type === 'assistant') {
|
|
329
|
+
const observed = this.d4Usage.assistant(message);
|
|
330
|
+
if (observed) {
|
|
331
|
+
await this.d4Guard.observe(observed);
|
|
332
|
+
if (this.d4Guard.diagnostic().status !== 'active') {
|
|
333
|
+
yield { eventType: 'failed', summary: this.d4Guard.diagnostic().reason_code ?? 'D4_USAGE_UNAVAILABLE' };
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
let d4Usage;
|
|
339
|
+
if (this.d4Guard && message.type === 'result') {
|
|
340
|
+
try {
|
|
341
|
+
d4Usage = this.d4Usage.final(message);
|
|
342
|
+
await this.d4Guard.complete(d4Usage);
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
if (this.d4Guard.diagnostic().status === 'active')
|
|
346
|
+
await this.d4Guard.complete().catch(() => { });
|
|
347
|
+
yield { eventType: 'failed', summary: this.d4Guard.diagnostic().reason_code ?? 'D4_USAGE_UNAVAILABLE' };
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
274
351
|
let parsed = parseClaudeLine(line, this.options.worktreeRoot, { p1: this.p1 });
|
|
275
352
|
if (!parsed)
|
|
276
353
|
continue;
|
|
@@ -279,6 +356,8 @@ export class ClaudeCodeAdapter {
|
|
|
279
356
|
parsed = structuredCompletionEvent(message, parsed.payload?.cost_usd);
|
|
280
357
|
if (this.candidateReview)
|
|
281
358
|
parsed = candidateReviewCompletion(parsed);
|
|
359
|
+
if (d4Usage)
|
|
360
|
+
parsed = { ...parsed, payload: { ...parsed.payload, d4_usage: { ...d4Usage, policy: 'observed_stop_possible_overshoot' } } };
|
|
282
361
|
if (this.p1)
|
|
283
362
|
parsed = p1ModelEvent({ ...parsed, payload: { ...parsed.payload, usage: observedUsage } });
|
|
284
363
|
}
|
|
@@ -294,11 +373,20 @@ export class ClaudeCodeAdapter {
|
|
|
294
373
|
yield { eventType: "failed", summary: `claude process exited unexpectedly (code ${exitInfo.code ?? "unknown"})` };
|
|
295
374
|
}
|
|
296
375
|
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
if (!this.d4Guard)
|
|
378
|
+
throw error;
|
|
379
|
+
if (this.d4Guard.diagnostic().status === 'active')
|
|
380
|
+
await this.d4Guard.complete().catch(() => { });
|
|
381
|
+
yield { eventType: 'failed', summary: this.d4Guard.diagnostic().reason_code ?? 'D4_USAGE_UNAVAILABLE' };
|
|
382
|
+
}
|
|
297
383
|
finally {
|
|
298
384
|
await this.cleanup();
|
|
299
385
|
}
|
|
300
386
|
}
|
|
301
387
|
async command(command) {
|
|
388
|
+
if (this.candidateReview)
|
|
389
|
+
throw Error('D4_SUPERVISOR_CONTINUATION_REJECTED');
|
|
302
390
|
if (!ALLOWED_SUPERVISOR_COMMANDS.has(command.command)) {
|
|
303
391
|
throw new Error(`unsupported supervisor command: ${command.command} is not allowed for this adapter`);
|
|
304
392
|
}
|
|
@@ -394,7 +482,9 @@ export class ClaudeCodeAdapter {
|
|
|
394
482
|
failed = true;
|
|
395
483
|
}
|
|
396
484
|
try {
|
|
397
|
-
if (
|
|
485
|
+
if (this.d4Guard)
|
|
486
|
+
await this.d4Guard.cancel();
|
|
487
|
+
else if (handle?.terminateOwned)
|
|
398
488
|
await handle.terminateOwned();
|
|
399
489
|
else
|
|
400
490
|
handle?.kill('SIGKILL');
|
|
@@ -415,6 +505,18 @@ export class ClaudeCodeAdapter {
|
|
|
415
505
|
this.cleanupPromise = operation;
|
|
416
506
|
return operation;
|
|
417
507
|
}
|
|
508
|
+
/** Race a silent CLI against the owned watchdog; stop evidence never depends on another stdout line. */
|
|
509
|
+
async *d4Lines(handle, guard) {
|
|
510
|
+
const iterator = handle.stdout[Symbol.asyncIterator]();
|
|
511
|
+
for (;;) {
|
|
512
|
+
const next = await Promise.race([iterator.next(), guard.stopped().then(() => null)]);
|
|
513
|
+
if (next === null)
|
|
514
|
+
throw Error(guard.diagnostic().reason_code ?? 'D4_STOP_UNCONFIRMED');
|
|
515
|
+
if (next.done)
|
|
516
|
+
return;
|
|
517
|
+
yield next.value;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
418
520
|
/** Claude Code treats a hook it cannot run as no objection, so the hook runs through Node. */
|
|
419
521
|
requireTrustedHookCommand() {
|
|
420
522
|
return formatHookCommand({
|
|
@@ -73,6 +73,7 @@ export function createClaudeCodeAdapterOptions(input) {
|
|
|
73
73
|
throw new Error('Claude account configuration path must be absolute');
|
|
74
74
|
const tokenStore = new ClaudeRunnerTokenStore({ stateRoot: state });
|
|
75
75
|
return {
|
|
76
|
+
d4ExecutionHost: input.d4ExecutionHost,
|
|
76
77
|
processHost: new NodeProcessGroupHost(), worktreeRoot: checkout, jobId: input.jobId, claudeToken: () => tokenStore.load(), keychainStub: () => ensureClaudeKeychainStub(state), get securityPolicy() { return securityPolicy; },
|
|
77
78
|
...(input.securityHost ? { prepareSecurity: async (ctx) => {
|
|
78
79
|
try {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { assertClaimedExecutionAttempt, assertBoundExecutionAttempt } from '../verification/continuation-runner-contract.js';
|
|
2
2
|
import { completionRejectionDiagnostic } from '../adapters/completion-report.js';
|
|
3
3
|
import { assertCandidateReviewPackages, candidateReviewReport } from '../safety/candidate-review-report.js';
|
|
4
|
+
import { D4_OBSERVED_EXECUTION_SCHEMA } from '../safety/d4-execution-limits.js';
|
|
4
5
|
import { publicationError } from '../publication/failure.js';
|
|
5
6
|
import { PublicationOutcomeUnknownError } from '../publication/publisher.js';
|
|
6
7
|
import { assertP1Bindings, hasP1, p1ModelEvent } from '../verification/p1-execution-host.js';
|
|
@@ -178,6 +179,9 @@ export class ExecutionCoordinator {
|
|
|
178
179
|
assertBoundExecutionAttempt(job, binding.taskPackage, binding.iterationId);
|
|
179
180
|
if (executionAttempt && binding.bindingReplayed !== false)
|
|
180
181
|
throw Error('CORRECTION_BINDING_RECONCILIATION_REQUIRED');
|
|
182
|
+
if (pkg.candidate_review_execution?.schema_version === D4_OBSERVED_EXECUTION_SCHEMA
|
|
183
|
+
&& (job.generation !== 1 || binding.bindingReplayed !== false))
|
|
184
|
+
throw Error('D4_BINDING_RECONCILIATION_REQUIRED');
|
|
181
185
|
binding = immutable(structuredClone(binding));
|
|
182
186
|
assertCandidateReviewPackages(pkg, binding.taskPackage);
|
|
183
187
|
if (isP1)
|
|
@@ -445,8 +449,10 @@ export class ExecutionCoordinator {
|
|
|
445
449
|
if (isP1 && !hostProof)
|
|
446
450
|
event = p1ModelEvent(event);
|
|
447
451
|
const previousStage = executionStage;
|
|
452
|
+
const d4StopAck = job.taskPackage.candidate_review_execution?.schema_version === D4_OBSERVED_EXECUTION_SCHEMA
|
|
453
|
+
&& ['failed', 'cancelled', 'blocked'].includes(event.eventType);
|
|
448
454
|
let payload = event.payload;
|
|
449
|
-
if (isTerminalEventType(event.eventType)) {
|
|
455
|
+
if (isTerminalEventType(event.eventType) && !d4StopAck) {
|
|
450
456
|
await fenced(observeCleanup());
|
|
451
457
|
executionStage = 'terminal_evidence';
|
|
452
458
|
requireControlFence();
|
|
@@ -456,6 +462,8 @@ export class ExecutionCoordinator {
|
|
|
456
462
|
if (isP1 && event.eventType === 'completed' && p1Result)
|
|
457
463
|
payload = { ...payload, candidate_commit_sha: p1Result.candidate_commit_sha, snapshot_fingerprint: p1Result.snapshot_fingerprint, changed_paths: p1Result.changed_paths, verification_receipt_ids: [...p1ReceiptIds] };
|
|
458
464
|
}
|
|
465
|
+
if (d4StopAck)
|
|
466
|
+
await fenced(observeCleanup());
|
|
459
467
|
// A vendor report cannot supply host observations, even when its shape looks valid.
|
|
460
468
|
if (payload) {
|
|
461
469
|
payload = { ...payload };
|
|
@@ -463,10 +471,10 @@ export class ExecutionCoordinator {
|
|
|
463
471
|
delete payload.local_fix_publication;
|
|
464
472
|
}
|
|
465
473
|
// Memory use is bound to this binding's bundle receipt by the runner, never taken from the model (WI 35a84e52).
|
|
466
|
-
payload = withMemoryUsage(payload, binding.taskPackage, isTerminalEventType(event.eventType));
|
|
474
|
+
payload = d4StopAck ? undefined : withMemoryUsage(payload, binding.taskPackage, isTerminalEventType(event.eventType));
|
|
467
475
|
if (event.eventType === "completed" && localFixPublication)
|
|
468
476
|
payload = { ...payload, local_fix_publication: structuredClone(localFixPublication) };
|
|
469
|
-
if (isTerminalEventType(event.eventType) || event.eventType === 'blocked' || event.eventType === 'scope_conflict') {
|
|
477
|
+
if (!d4StopAck && (isTerminalEventType(event.eventType) || event.eventType === 'blocked' || event.eventType === 'scope_conflict')) {
|
|
470
478
|
const reads = safeLocalReadObservations(adapter.localReadObservations?.());
|
|
471
479
|
if (reads)
|
|
472
480
|
payload = { ...payload, local_read_observations: reads };
|
|
@@ -15,6 +15,7 @@ import { CodexPlugin } from "../adapters/codex/plugin.js";
|
|
|
15
15
|
import { buildCodexJobOptions } from "../adapters/codex/runtime-options.js";
|
|
16
16
|
import { AdapterRegistry } from "../adapters/registry.js";
|
|
17
17
|
import { ClaudeCodePlugin } from "../adapters/claude-code/adapter.js";
|
|
18
|
+
import { D4_OBSERVED_EXECUTION_CAPABILITY } from '../safety/d4-execution-limits.js';
|
|
18
19
|
import { createClaudeCodeAdapterOptions } from "../adapters/claude-code/runtime-options.js";
|
|
19
20
|
import { ClaudeRunnerTokenStore, ensureClaudeKeychainStub } from "../safety/claude-runner-token.js";
|
|
20
21
|
import { WorkspaceManager } from "../workspace/workspace-manager.js";
|
|
@@ -123,6 +124,7 @@ export async function buildDaemon(input) {
|
|
|
123
124
|
securityHost: claude.securityHost, binaryIdentity: await claude.binaryIdentity(),
|
|
124
125
|
pullRequestEvidence: (args, signal) => gateway.readPullRequestEvidence(args, signal),
|
|
125
126
|
readCandidateReviewContext: loopId => gateway.readCandidateReviewContext(loopId),
|
|
127
|
+
d4ExecutionHost: input.d4ExecutionHost,
|
|
126
128
|
}),
|
|
127
129
|
});
|
|
128
130
|
const codex = new CodexPlugin({
|
|
@@ -299,7 +301,7 @@ export async function buildDaemon(input) {
|
|
|
299
301
|
return {
|
|
300
302
|
deviceFingerprint: runnerDeviceFingerprint(input.gatewayMcpUrl),
|
|
301
303
|
displayName: runnerDisplayName(), packageVersion: packageVersion(),
|
|
302
|
-
capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationModes ?? localFixHost?.verificationMode, ['claude_code_cli', ...(production ? ['codex_cli'] : [])]), poller.accepting),
|
|
304
|
+
capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationModes ?? localFixHost?.verificationMode, ['claude_code_cli', ...(production ? ['codex_cli'] : [])], input.d4ExecutionHost), poller.accepting),
|
|
303
305
|
mode: "full",
|
|
304
306
|
...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
|
|
305
307
|
...(guidance ? { guidance } : {}),
|
|
@@ -462,12 +464,14 @@ export async function reconcilePreparationState(previous, deps) {
|
|
|
462
464
|
return true;
|
|
463
465
|
}
|
|
464
466
|
/** Vendor capability text cannot claim a host-owned publication facility. */
|
|
465
|
-
export function localRunnerCapabilities(probes, trustedLocalFixHost = false, verificationMode, pullRequestEvidenceAdapters = []) {
|
|
467
|
+
export function localRunnerCapabilities(probes, trustedLocalFixHost = false, verificationMode, pullRequestEvidenceAdapters = [], d4Host) {
|
|
468
|
+
if (d4Host && (!/^[a-f0-9]{64}$/.test(d4Host.runnerReleaseSha256) || !/^[a-f0-9]{64}$/.test(d4Host.hostPolicySha256) || typeof d4Host.assertCurrent !== 'function'))
|
|
469
|
+
throw Error('D4_REVIEW_HOST_REQUIRED');
|
|
466
470
|
const modes = verificationMode === undefined ? [] : typeof verificationMode === 'string' ? [verificationMode] : verificationMode;
|
|
467
471
|
return probes.map(probe => {
|
|
468
472
|
const status = adapterStatus(probe);
|
|
469
473
|
const authenticated = status.authenticated;
|
|
470
|
-
const capabilities = probe.capabilities.filter(value => value !== 'local_fix_publication_v1' && value !== 'local_fix_gateway_ci_v1' && value !== 'local_fix_gateway_publish_v1' && value !== 'pull_request_evidence_v1');
|
|
474
|
+
const capabilities = probe.capabilities.filter(value => value !== 'local_fix_publication_v1' && value !== 'local_fix_gateway_ci_v1' && value !== 'local_fix_gateway_publish_v1' && value !== 'pull_request_evidence_v1' && value !== D4_OBSERVED_EXECUTION_CAPABILITY);
|
|
471
475
|
if (trustedLocalFixHost && authenticated && isLocalFixAdapter(probe.id)) {
|
|
472
476
|
capabilities.push('local_fix_publication_v1');
|
|
473
477
|
if (modes.includes('gateway_ci_v1'))
|
|
@@ -478,6 +482,9 @@ export function localRunnerCapabilities(probes, trustedLocalFixHost = false, ver
|
|
|
478
482
|
// The host owns the Gateway call behind local_pull_request_read, so only the host can claim it (Work Decision 842179e6).
|
|
479
483
|
if (authenticated && pullRequestEvidenceAdapters.includes(probe.id))
|
|
480
484
|
capabilities.push('pull_request_evidence_v1');
|
|
481
|
-
|
|
485
|
+
const d4 = authenticated && probe.id === 'claude_code_cli' && d4Host;
|
|
486
|
+
if (d4)
|
|
487
|
+
capabilities.push(D4_OBSERVED_EXECUTION_CAPABILITY);
|
|
488
|
+
return { adapter: probe.id, version: probe.version, ...status, capabilities, ...(d4 ? { runner_release_sha256: d4.runnerReleaseSha256, host_policy_sha256: d4.hostPolicySha256 } : {}) };
|
|
482
489
|
});
|
|
483
490
|
}
|
|
@@ -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,12 @@ export function assertCurrentD4LimitSupport() { return reject('D4_STRICT_USAGE_E
|
|
|
23
23
|
const d4 = (p) => ['candidate_review', 'candidate_review_record', 'candidate_review_execution'].some(k => Object.hasOwn(p, k))
|
|
24
24
|
|| p.contract_purpose === 'candidate_review' || p.tool_inventory === D4_REVIEW_INVENTORY;
|
|
25
25
|
/** Before token/security/tool acquisition. The later inventory check also catches a D4 security pin without package markers. */
|
|
26
|
+
export const D4_OBSERVED_EXECUTION_SCHEMA = 'd4-review-execution-v2';
|
|
27
|
+
export const D4_OBSERVED_EXECUTION_STAGE = 'cli_observed_usage_v1';
|
|
28
|
+
export const D4_OBSERVED_EXECUTION_CAPABILITY = 'd4_review_observed_usage_v1';
|
|
29
|
+
const PROFILE_KEYS = ['schema_version', 'decision_revision_id', 'runtime_adapter', 'provider', 'model_id', 'tool_inventory', 'tools_sha256', 'cli_security_fingerprint',
|
|
30
|
+
'runner_release_sha256', 'host_policy_sha256', 'review_status', 'limits', 'usage_enforcement'];
|
|
31
|
+
/** Version 2 accepts observed-stop limits only. It does not make strict caps available or authorize a launch. */
|
|
26
32
|
export function assertD4PackageLimitSupport(bound, approved, resume) {
|
|
27
33
|
if (![bound, approved].some(p => object(p) && d4(p)))
|
|
28
34
|
return;
|
|
@@ -37,8 +43,19 @@ export function assertD4PackageLimitSupport(bound, approved, resume) {
|
|
|
37
43
|
return reject('D4_LIMIT_PROFILE_REQUIRED');
|
|
38
44
|
if (protocolHash(a) !== protocolHash(b))
|
|
39
45
|
return reject('D4_LIMIT_PROFILE_CHANGED');
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
if (a.schema_version !== D4_OBSERVED_EXECUTION_SCHEMA)
|
|
47
|
+
return assertCurrentD4LimitSupport();
|
|
48
|
+
const p = a.profile, u = p.usage_enforcement;
|
|
49
|
+
if (!keys(a, ['schema_version', 'stage', 'profile', 'profile_fingerprint', 'registration_fingerprint']) || a.stage !== D4_OBSERVED_EXECUTION_STAGE
|
|
50
|
+
|| !keys(p, PROFILE_KEYS) || p.schema_version !== 'd4-review-execution-profile-v2' || p.review_status !== 'reviewed'
|
|
51
|
+
|| p.runtime_adapter !== 'claude_code_cli' || p.provider !== 'anthropic' || p.model_id !== 'claude-sonnet-5-5' || p.tool_inventory !== D4_REVIEW_INVENTORY
|
|
52
|
+
|| typeof p.decision_revision_id !== 'string' || !(/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/).test(p.decision_revision_id)
|
|
53
|
+
|| !['tools_sha256', 'cli_security_fingerprint', 'runner_release_sha256', 'host_policy_sha256'].every(k => typeof p[k] === 'string' && /^[a-f0-9]{64}$/.test(p[k]))
|
|
54
|
+
|| typeof a.registration_fingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(a.registration_fingerprint)
|
|
55
|
+
|| a.profile_fingerprint !== protocolHash(p) || !object(u) || !keys(u, ['tokens', 'estimated_cost', 'hard_stop'])
|
|
56
|
+
|| u.tokens !== 'observed_stop' || u.estimated_cost !== 'observed_stop' || u.hard_stop !== false)
|
|
57
|
+
return reject('D4_LIMIT_PROFILE_REJECTED');
|
|
58
|
+
return parseD4Limits(p.limits);
|
|
42
59
|
}
|
|
43
60
|
/** Conservative diagnostic interpretation only. All token partitions and the CLI's estimated cost must be explicit.
|
|
44
61
|
* This does not establish that a CLI result is cumulative, timely, complete or an invoice/weekly-limit measurement. */
|
|
@@ -56,6 +73,38 @@ export function parseD4ResultUsage(value, sequence) {
|
|
|
56
73
|
return reject('D4_USAGE_UNAVAILABLE');
|
|
57
74
|
return { sequence, total_tokens: tokens, estimated_cost_microusd: micro };
|
|
58
75
|
}
|
|
76
|
+
/** CLI transport observations only. Message IDs deduplicate cumulative revisions of the same assistant message.
|
|
77
|
+
* Cost is available only when the CLI reports total_cost_usd; absent intermediate cost is not a zero-cost claim.
|
|
78
|
+
* Final root usage must cover every observation. Never consume usage embedded in model text/tool output. */
|
|
79
|
+
export class D4CliUsageTracker {
|
|
80
|
+
messages = new Map();
|
|
81
|
+
sequence = 0;
|
|
82
|
+
tokens = 0;
|
|
83
|
+
assistant(value) {
|
|
84
|
+
if (value.type !== 'assistant' || !object(value.message) || value.message.usage === undefined)
|
|
85
|
+
return;
|
|
86
|
+
const m = value.message, id = m.id;
|
|
87
|
+
if (typeof id !== 'string' || !id || id.length > 191 || this.messages.size >= 4096 && !this.messages.has(id))
|
|
88
|
+
return reject('D4_USAGE_UNAVAILABLE');
|
|
89
|
+
const parsed = parseD4ResultUsage({ type: 'result', usage: m.usage, total_cost_usd: 0 }, 1), previous = this.messages.get(id);
|
|
90
|
+
if (previous !== undefined && parsed.total_tokens < previous)
|
|
91
|
+
return reject('D4_USAGE_UNAVAILABLE');
|
|
92
|
+
if (previous !== parsed.total_tokens) {
|
|
93
|
+
this.tokens += parsed.total_tokens - (previous ?? 0);
|
|
94
|
+
this.messages.set(id, parsed.total_tokens);
|
|
95
|
+
this.sequence++;
|
|
96
|
+
}
|
|
97
|
+
if (!integer(this.tokens))
|
|
98
|
+
return reject('D4_USAGE_UNAVAILABLE');
|
|
99
|
+
return { sequence: Math.max(1, this.sequence), total_tokens: this.tokens, estimated_cost_microusd: 0 };
|
|
100
|
+
}
|
|
101
|
+
final(value) {
|
|
102
|
+
const final = parseD4ResultUsage(value, ++this.sequence);
|
|
103
|
+
if (final.total_tokens < this.tokens)
|
|
104
|
+
return reject('D4_USAGE_UNAVAILABLE');
|
|
105
|
+
return final;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
59
108
|
const STOP_TIMEOUT_MS = 6000;
|
|
60
109
|
/** Local owned-process primitive for synthetic feasibility tests and a future reviewed runtime.
|
|
61
110
|
* It grants no admission; the production adapter above remains blocked. Observed caps may already have overshot.
|
|
@@ -71,9 +120,12 @@ export class D4ObservedLimitWatchdog {
|
|
|
71
120
|
reason;
|
|
72
121
|
status = 'idle';
|
|
73
122
|
stopPromise;
|
|
123
|
+
stoppedSignal;
|
|
124
|
+
signalStopped;
|
|
74
125
|
constructor(limits, host) {
|
|
75
126
|
this.host = host;
|
|
76
127
|
this.limits = parseD4Limits(limits);
|
|
128
|
+
this.stoppedSignal = new Promise(resolve => { this.signalStopped = resolve; });
|
|
77
129
|
}
|
|
78
130
|
now() { const n = (this.host.now ?? (() => performance.now()))(); if (!Number.isFinite(n) || n < this.lastNow)
|
|
79
131
|
return reject('D4_USAGE_UNAVAILABLE'); this.lastNow = n; return n; }
|
|
@@ -90,6 +142,8 @@ export class D4ObservedLimitWatchdog {
|
|
|
90
142
|
}
|
|
91
143
|
diagnostic() { return { status: this.status, reason_code: this.status === 'unconfirmed' ? 'D4_STOP_UNCONFIRMED' : this.reason ?? null }; }
|
|
92
144
|
settled() { return this.stopPromise ?? Promise.resolve(); }
|
|
145
|
+
/** Wakes a silent stream consumer after a stop attempt, including unconfirmed cleanup. */
|
|
146
|
+
stopped() { return this.stoppedSignal; }
|
|
93
147
|
async observe(snapshot) {
|
|
94
148
|
if (this.stopPromise)
|
|
95
149
|
return this.stopPromise;
|
|
@@ -170,6 +224,7 @@ export class D4ObservedLimitWatchdog {
|
|
|
170
224
|
}
|
|
171
225
|
finally {
|
|
172
226
|
clearTimeout(timer);
|
|
227
|
+
this.signalStopped();
|
|
173
228
|
}
|
|
174
229
|
});
|
|
175
230
|
return this.stopPromise;
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
<key>CFBundleDisplayName</key><string>AI Gateway</string>
|
|
8
8
|
<key>CFBundleExecutable</key><string>AIGatewayMenubar</string>
|
|
9
9
|
<key>CFBundlePackageType</key><string>APPL</string>
|
|
10
|
-
<key>CFBundleShortVersionString</key><string>1.
|
|
11
|
-
<key>CFBundleVersion</key><string>
|
|
10
|
+
<key>CFBundleShortVersionString</key><string>1.2.0</string>
|
|
11
|
+
<key>CFBundleVersion</key><string>3</string>
|
|
12
12
|
<key>LSMinimumSystemVersion</key><string>13.0</string>
|
|
13
13
|
<key>LSUIElement</key><true/>
|
|
14
14
|
</dict>
|
|
Binary file
|