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 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
- for await (const line of handle.stdout) {
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 (handle?.terminateOwned)
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
- return { adapter: probe.id, version: probe.version, ...status, capabilities };
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 request panel's list, show and answer; without one each request is answered failed. */
38
+ /** The handler for the window's request panel and Rooms tab; without one each request is answered failed. */
39
39
  onPanel(handler) {
40
40
  this.panel = handler;
41
41
  }
@@ -158,7 +158,7 @@ export class MenubarChannel {
158
158
  handle(message, socket) {
159
159
  if (message === null)
160
160
  return;
161
- if (message.type === "list" || message.type === "show" || message.type === "answer") {
161
+ if (isPanelRequest(message)) {
162
162
  void this.answerPanel(message, socket);
163
163
  return;
164
164
  }
@@ -1,14 +1,16 @@
1
1
  import { MAX_ANSWER_BYTES } from "./protocol.js";
2
- /** Each Gateway call the panel makes is given up after this long. */
2
+ import { callRoom, checkRoomRequest } from "./room-panel.js";
3
+ /** Each app message the panel answers is given up after this long. */
3
4
  export const PANEL_CALL_MS = 30_000;
4
5
  const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
5
6
  const MAX_REASON = 300;
6
7
  const FAILED = "The runner could not reach the AI Gateway. Try again.";
8
+ const AUDIENCE_CHANGED = "The Room members changed. Check the new list and post again.";
7
9
  /**
8
- * The daemon's side of the menu bar request panel (Gateway Work Item d2b6d0da). It is the allowlist for what the app
9
- * may ask: list what waits for the person, show one Work Request, answer one. Ids and the answer are checked before
10
- * any call, one call runs at a time, and nothing is kept after the reply. Logs carry the request type and an error
11
- * code only, never a title, body or answer.
10
+ * The daemon's side of the menu bar window (Gateway Work Items d2b6d0da and b35d65cb). It is the allowlist for what
11
+ * the app may ask: the request panel lists, shows and answers Work Requests; the Rooms tab's messages are checked and
12
+ * called in room-panel.ts. Ids and text are checked before any call, one call runs at a time, and nothing is kept
13
+ * after the reply. Logs carry the message type, an error code and counts only, never a name, title or body.
12
14
  */
13
15
  export function createPanelHandler(input) {
14
16
  let running = false;
@@ -32,6 +34,8 @@ export function createPanelHandler(input) {
32
34
  case "answer":
33
35
  await input.gateway.answerWorkRequest(request.requestId, request.body.trim(), signal);
34
36
  return { answered: true };
37
+ default:
38
+ return callRoom(request, input.gateway, signal);
35
39
  }
36
40
  };
37
41
  return async (request) => {
@@ -50,6 +54,17 @@ export function createPanelHandler(input) {
50
54
  }
51
55
  catch { /* A refresh that fails never undoes a sent answer. */ }
52
56
  }
57
+ if (request.type === "room_presented")
58
+ log({ event: "menubar_panel_presented", count: data.presented });
59
+ if (request.type === "room_mark_read") {
60
+ log({ event: "menubar_panel_marked_read", count: data.acknowledged });
61
+ try {
62
+ input.onMarkedRead?.();
63
+ }
64
+ catch { /* A rescan that fails never undoes a receipt. */ }
65
+ }
66
+ if (request.type === "room_post")
67
+ log({ event: "menubar_panel_posted" });
53
68
  return { ok: true, data };
54
69
  }
55
70
  catch (error) {
@@ -57,6 +72,8 @@ export function createPanelHandler(input) {
57
72
  log({ event: "menubar_panel_failed", request: request.type, code });
58
73
  const reason = error?.reason;
59
74
  if (code === "GATEWAY_TOOL_REJECTED" && typeof reason === "string" && reason !== "") {
75
+ if (request.type === "room_post" && reason.includes("Room audience changed"))
76
+ return refusal("audience_changed", AUDIENCE_CHANGED);
60
77
  return refusal("refused", reason.slice(0, MAX_REASON));
61
78
  }
62
79
  return refusal("failed", FAILED);
@@ -69,6 +86,8 @@ export function createPanelHandler(input) {
69
86
  function check(request) {
70
87
  if (request.type === "list")
71
88
  return null;
89
+ if (request.type !== "show" && request.type !== "answer")
90
+ return checkRoomRequest(request);
72
91
  if (!ID.test(request.requestId))
73
92
  return "That request id is not valid.";
74
93
  if (request.type === "answer") {
@@ -19,14 +19,34 @@
19
19
  * {"v":1,"type":"answer","req":"<uuid>","request_id":"<uuid>","body":"…"}
20
20
  * {"v":1,"type":"reply","req":"<uuid>","ok":true,"data":{…}}
21
21
  * {"v":1,"type":"reply","req":"<uuid>","ok":false,"error":"busy|invalid|refused|failed","message":"…"}
22
+ *
23
+ * The Rooms tab (Work Item b35d65cb) adds, each answered by one reply:
24
+ * {"v":1,"type":"room_list","req":"<uuid>"}
25
+ * {"v":1,"type":"room_show","req":"<uuid>","room_id":"<uuid>"}
26
+ * {"v":1,"type":"room_thread","req":"<uuid>","room_id":"<uuid>","thread_id":"<uuid>"}
27
+ * {"v":1,"type":"room_presented","req":"<uuid>","room_id":"<uuid>","message_ids":["<uuid>",…]}
28
+ * {"v":1,"type":"room_mark_read","req":"<uuid>","room_id":"<uuid>","message_ids":["<uuid>",…]} the person pressed Mark read
29
+ * {"v":1,"type":"room_post","req":"<uuid>","room_id":"<uuid>","thread_id":"<uuid>","body":"…","membership_revision":n,
30
+ * "idempotency_key":"<uuid>"} sent only after the person confirmed the members
31
+ * a refused post can reply with "error":"audience_changed"
22
32
  */
23
33
  export const MENUBAR_SOCKET = "menubar.sock";
24
- /** A request body is at most 8,000 characters and a list at most 50 items, so every legitimate line fits. */
25
- export const MAX_MENUBAR_LINE_BYTES = 256 * 1024;
34
+ /** A Room thread reply holds at most 120 messages of at most 8,000 bytes, so every legitimate line fits. */
35
+ export const MAX_MENUBAR_LINE_BYTES = 4 * 1024 * 1024;
26
36
  /** The Gateway's own limit for one answer, in UTF-8 bytes after trimming. */
27
37
  export const MAX_ANSWER_BYTES = 8_000;
38
+ /** The Gateway's own limit for one Room message, in UTF-8 bytes after trimming. */
39
+ export const MAX_ROOM_POST_BYTES = 8_000;
28
40
  const NOTICE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
29
41
  const APP_VERSION = /^\d+\.\d+\.\d+$/;
42
+ /** No legitimate message carries more ids than this; the handler checks the real limits. */
43
+ const MAX_LIST_ITEMS = 1_000;
44
+ const PANEL_TYPES = new Set(["list", "show", "answer", "room_list", "room_show", "room_thread", "room_presented",
45
+ "room_mark_read", "room_post"]);
46
+ /** True for every message the panel handler answers with a reply. */
47
+ export function isPanelRequest(message) {
48
+ return PANEL_TYPES.has(message.type);
49
+ }
30
50
  export function stateLine(state) {
31
51
  return `${JSON.stringify({ v: 1, type: "state", room_unread: state.roomUnread, requests: state.requests,
32
52
  runner_version: state.runnerVersion, inbox_url: state.inboxUrl })}\n`;
@@ -72,6 +92,24 @@ export function parseAppLine(line) {
72
92
  case "answer":
73
93
  return keys === 5 && isId(record.req) && typeof record.request_id === "string" && typeof record.body === "string"
74
94
  ? { type: "answer", req: record.req, requestId: record.request_id, body: record.body } : null;
95
+ case "room_list":
96
+ return keys === 3 && isId(record.req) ? { type: "room_list", req: record.req } : null;
97
+ case "room_show":
98
+ return keys === 4 && isId(record.req) && typeof record.room_id === "string"
99
+ ? { type: "room_show", req: record.req, roomId: record.room_id } : null;
100
+ case "room_thread":
101
+ return keys === 5 && isId(record.req) && typeof record.room_id === "string" && typeof record.thread_id === "string"
102
+ ? { type: "room_thread", req: record.req, roomId: record.room_id, threadId: record.thread_id } : null;
103
+ case "room_presented":
104
+ case "room_mark_read":
105
+ return keys === 5 && isId(record.req) && typeof record.room_id === "string" && isStringList(record.message_ids)
106
+ ? { type: record.type, req: record.req, roomId: record.room_id, messageIds: [...record.message_ids] } : null;
107
+ case "room_post":
108
+ return keys === 8 && isId(record.req) && typeof record.room_id === "string" && typeof record.thread_id === "string"
109
+ && typeof record.body === "string" && typeof record.membership_revision === "number"
110
+ && typeof record.idempotency_key === "string"
111
+ ? { type: "room_post", req: record.req, roomId: record.room_id, threadId: record.thread_id, body: record.body,
112
+ membershipRevision: record.membership_revision, idempotencyKey: record.idempotency_key } : null;
75
113
  default:
76
114
  return null;
77
115
  }
@@ -79,3 +117,6 @@ export function parseAppLine(line) {
79
117
  function isId(value) {
80
118
  return typeof value === "string" && NOTICE_ID.test(value);
81
119
  }
120
+ function isStringList(value) {
121
+ return Array.isArray(value) && value.length <= MAX_LIST_ITEMS && value.every(item => typeof item === "string");
122
+ }
@@ -0,0 +1,115 @@
1
+ import { MAX_ROOM_POST_BYTES } from "./protocol.js";
2
+ /** The Room list reads at most this many activity pages of 50 (500 Rooms, as the web Inbox). */
3
+ export const ROOM_LIST_PAGES = 10;
4
+ /** A Room shows at most this many thread pages of 100. */
5
+ export const ROOM_THREAD_PAGES = 5;
6
+ /** A thread shows the newest 20 and at most 100 unread messages, so at most 120 are reported presented. */
7
+ export const MAX_PRESENTED_IDS = 120;
8
+ /** The Gateway takes at most 100 ids per receipt call. */
9
+ export const MAX_RECEIPT_IDS = 100;
10
+ const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
11
+ /** Why a Room request is refused before any Gateway call, or null. */
12
+ export function checkRoomRequest(request) {
13
+ switch (request.type) {
14
+ case "room_list":
15
+ return null;
16
+ case "room_show":
17
+ return checkRoomId(request.roomId);
18
+ case "room_thread":
19
+ return checkRoomId(request.roomId) ?? checkThreadId(request.threadId);
20
+ case "room_presented":
21
+ return checkRoomId(request.roomId) ?? checkIds(request.messageIds, MAX_PRESENTED_IDS);
22
+ case "room_mark_read":
23
+ return checkRoomId(request.roomId) ?? checkIds(request.messageIds, MAX_RECEIPT_IDS);
24
+ case "room_post": {
25
+ const bad = checkRoomId(request.roomId) ?? checkThreadId(request.threadId);
26
+ if (bad !== null)
27
+ return bad;
28
+ if (!ID.test(request.idempotencyKey))
29
+ return "That reply key is not valid.";
30
+ if (!Number.isSafeInteger(request.membershipRevision) || request.membershipRevision < 1)
31
+ return "That membership revision is not valid.";
32
+ const bytes = Buffer.byteLength(request.body.trim());
33
+ if (bytes === 0)
34
+ return "Write a reply first.";
35
+ if (bytes > MAX_ROOM_POST_BYTES)
36
+ return `A reply can be at most ${MAX_ROOM_POST_BYTES} bytes.`;
37
+ return null;
38
+ }
39
+ }
40
+ }
41
+ /** The Gateway calls behind one checked Room request, and the reply data the app reads. */
42
+ export async function callRoom(request, gateway, signal) {
43
+ switch (request.type) {
44
+ case "room_list": {
45
+ const rooms = [];
46
+ let cursor = "";
47
+ for (let page = 0; page < ROOM_LIST_PAGES; page += 1) {
48
+ const read = await gateway.readRoomListPage(cursor, signal);
49
+ rooms.push(...read.rooms);
50
+ if (read.nextCursor === null)
51
+ return { rooms: rooms.map(roomRow), complete: true };
52
+ cursor = read.nextCursor;
53
+ }
54
+ return { rooms: rooms.map(roomRow), complete: false };
55
+ }
56
+ case "room_show": {
57
+ const room = await gateway.showRoomForPanel(request.roomId, signal);
58
+ const threads = [];
59
+ let after = 0;
60
+ for (let page = 0; page < ROOM_THREAD_PAGES; page += 1) {
61
+ const read = await gateway.readRoomThreadsPage(request.roomId, after, signal);
62
+ threads.push(...read.threads);
63
+ if (read.nextCursor === null)
64
+ break;
65
+ after = read.nextCursor;
66
+ }
67
+ return {
68
+ room: { id: room.id, name: room.name, status: room.status, membership_revision: room.membershipRevision,
69
+ members: room.members.map(member => ({ actor_id: member.actorId, name: member.name })) },
70
+ threads: threads.map(thread => ({ id: thread.id, title: thread.title, unread_count: thread.unreadCount,
71
+ latest_message_sequence: thread.latestMessageSequence })),
72
+ };
73
+ }
74
+ case "room_thread": {
75
+ const latest = await gateway.readRoomMessages(request.roomId, request.threadId, "latest", signal);
76
+ const unread = await gateway.readRoomMessages(request.roomId, request.threadId, "unread", signal);
77
+ const unreadIds = new Set(unread.map(message => message.id));
78
+ const byId = new Map();
79
+ for (const message of [...latest, ...unread])
80
+ byId.set(message.id, message);
81
+ return { messages: [...byId.values()].sort((a, b) => a.sequence - b.sequence).map(message => ({ id: message.id,
82
+ author_actor_id: message.authorActorId, kind: message.kind, body: message.body, created_at: message.createdAt,
83
+ unread: unreadIds.has(message.id) })) };
84
+ }
85
+ case "room_presented": {
86
+ let presented = 0;
87
+ for (let start = 0; start < request.messageIds.length; start += MAX_RECEIPT_IDS) {
88
+ presented += await gateway.reportRoomPresented(request.roomId, request.messageIds.slice(start, start + MAX_RECEIPT_IDS), signal);
89
+ }
90
+ return { presented };
91
+ }
92
+ case "room_mark_read":
93
+ return { acknowledged: await gateway.acknowledgeRoomMessages(request.roomId, request.messageIds, signal) };
94
+ case "room_post":
95
+ await gateway.postRoomReply({ roomId: request.roomId, threadId: request.threadId, body: request.body.trim(),
96
+ membershipRevision: request.membershipRevision, idempotencyKey: request.idempotencyKey }, signal);
97
+ return { posted: true };
98
+ }
99
+ }
100
+ function roomRow(room) {
101
+ return { room_id: room.roomId, name: room.name, unread_count: room.unreadCount };
102
+ }
103
+ function checkRoomId(value) {
104
+ return ID.test(value) ? null : "That Room id is not valid.";
105
+ }
106
+ function checkThreadId(value) {
107
+ return ID.test(value) ? null : "That thread id is not valid.";
108
+ }
109
+ function checkIds(ids, max) {
110
+ if (ids.length < 1 || ids.length > max)
111
+ return `Send 1 to ${max} message ids.`;
112
+ if (new Set(ids).size !== ids.length || !ids.every(value => ID.test(value)))
113
+ return "The message ids are not valid.";
114
+ return null;
115
+ }
@@ -18,7 +18,8 @@ export function buildMenubar(input) {
18
18
  channel.on("quit", () => input.log({ event: "menubar_app_quit" }));
19
19
  if (input.panel !== undefined) {
20
20
  channel.onPanel(createPanelHandler({ gateway: input.panel, log: input.log,
21
- onAnswered: () => { void runtime.refreshRequests(true); } }));
21
+ onAnswered: () => { void runtime.refreshRequests(true); },
22
+ onMarkedRead: () => { void input.rescanRooms?.().catch(() => input.log({ event: "menubar_room_rescan_failed" })); } }));
22
23
  }
23
24
  const launch = input.launch ?? (() => launchMenubarApp({ home: input.home, stateRoot: input.stateRoot, socketPath }));
24
25
  const quit = input.quit ?? (() => quitMenubarApp());
@@ -66,7 +66,10 @@ export function assertCandidateReviewPackages(approved, bound) {
66
66
  approved.local_fix_scope !== undefined || approved.verification !== undefined ||
67
67
  !approved.candidate_review_record || !bound.candidate_review_record ||
68
68
  protocolHash(approved.candidate_review) !== protocolHash(bound.candidate_review) ||
69
- protocolHash(approved.candidate_review_record) !== protocolHash(bound.candidate_review_record))
69
+ protocolHash(approved.candidate_review_record) !== protocolHash(bound.candidate_review_record) ||
70
+ ((approved.candidate_review_execution !== undefined || bound.candidate_review_execution !== undefined)
71
+ && (approved.candidate_review_execution === undefined || bound.candidate_review_execution === undefined
72
+ || protocolHash(approved.candidate_review_execution) !== protocolHash(bound.candidate_review_execution))))
70
73
  throw Error();
71
74
  }
72
75
  catch {
@@ -23,6 +23,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
- assessD4LimitEnforcement(a.profile.limits);
41
- return assertCurrentD4LimitSupport();
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.1.0</string>
11
- <key>CFBundleVersion</key><string>2</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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.110",
3
+ "version": "0.1.111",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {