zigmini 1.0.0-beta.3 → 1.1.0-beta.2

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.
Files changed (138) hide show
  1. package/README.ko.md +29 -2
  2. package/README.md +29 -2
  3. package/assets/agent/gateway-request-rules.md +9 -2
  4. package/assets/agent/gateway-rules.md +13 -6
  5. package/assets/android/zigmini.apk +0 -0
  6. package/assets/android/zigmini.apk.json +3 -3
  7. package/assets/build.json +1 -1
  8. package/assets/document-reader/document-reader +0 -0
  9. package/assets/document-reader/host-selector +0 -0
  10. package/assets/document-reader/manifest.json +27 -0
  11. package/docs/manual.ko.md +114 -10
  12. package/docs/manual.md +117 -11
  13. package/host/document-reader/DocumentRead.swift +370 -0
  14. package/host/document-reader/HostSelector.swift +192 -0
  15. package/host/document-reader/Protocol.swift +647 -0
  16. package/host/document-reader/main.swift +22 -0
  17. package/host/omo-bridge/agent-aside-tool.ts +119 -0
  18. package/host/omo-bridge/agent-aside.ts +522 -0
  19. package/host/omo-bridge/agent-collaboration-files.ts +282 -0
  20. package/host/omo-bridge/agent-collaboration.ts +385 -0
  21. package/host/omo-bridge/agent-control.ts +400 -28
  22. package/host/omo-bridge/agent-execution.sb +42 -0
  23. package/host/omo-bridge/agent-execution.ts +158 -0
  24. package/host/omo-bridge/agent-tool-adapters.ts +388 -0
  25. package/host/omo-bridge/agent-tool-permissions.ts +96 -0
  26. package/host/omo-bridge/aside-tabs.ts +41 -0
  27. package/host/omo-bridge/attachment-read.ts +100 -0
  28. package/host/omo-bridge/bridge.ts +10 -0
  29. package/host/omo-bridge/managed-call.ts +46 -0
  30. package/host/omo-bridge/task-control.ts +39 -5
  31. package/host/omo-check/patches/attachments-293/README.md +138 -0
  32. package/host/omo-check/patches/attachments-293/document-content.js +234 -0
  33. package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
  34. package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
  35. package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
  36. package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
  37. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +28 -6
  38. package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
  39. package/package.json +5 -3
  40. package/src/agent/api.ts +345 -20
  41. package/src/agent/artifacts.ts +196 -0
  42. package/src/agent/aside-target-routes.ts +369 -0
  43. package/src/agent/aside-targets.ts +215 -0
  44. package/src/agent/collaboration-admission.ts +47 -0
  45. package/src/agent/collaboration-read.ts +117 -0
  46. package/src/agent/collaboration-session.ts +196 -0
  47. package/src/agent/collaboration-types.ts +258 -0
  48. package/src/agent/collaboration.ts +399 -0
  49. package/src/agent/definition.ts +65 -3
  50. package/src/agent/fields.ts +9 -3
  51. package/src/agent/file-core.ts +325 -0
  52. package/src/agent/files.ts +87 -8
  53. package/src/agent/list.ts +3 -3
  54. package/src/agent/managed-call.ts +144 -0
  55. package/src/agent/tool-catalog.ts +184 -0
  56. package/src/agent/tool-delivery.ts +359 -0
  57. package/src/agent/tool-permission-types.ts +81 -0
  58. package/src/agent/tool-permissions.ts +1366 -0
  59. package/src/attachments/api.ts +161 -0
  60. package/src/attachments/document-reader.ts +325 -0
  61. package/src/attachments/host-selector.ts +410 -0
  62. package/src/attachments/managed-binding.ts +83 -0
  63. package/src/attachments/prepare.ts +214 -0
  64. package/src/attachments/selector-port.ts +28 -0
  65. package/src/attachments/service.ts +729 -0
  66. package/src/attachments/store.ts +698 -0
  67. package/src/attachments/types.ts +123 -0
  68. package/src/cli/doctor.ts +30 -10
  69. package/src/decisions.ts +109 -10
  70. package/src/engine/apply-patch.ts +21 -0
  71. package/src/engine/compat.json +2 -1
  72. package/src/engine/install.ts +45 -4
  73. package/src/engine/sandbox-cell.ts +2 -0
  74. package/src/gc.ts +33 -225
  75. package/src/housekeeping/api.ts +163 -0
  76. package/src/housekeeping/audit.ts +61 -0
  77. package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
  78. package/src/housekeeping/collectors/agent-drafts.ts +43 -0
  79. package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
  80. package/src/housekeeping/collectors/agent-trash.ts +28 -0
  81. package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
  82. package/src/housekeeping/collectors/db-backup.ts +25 -0
  83. package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
  84. package/src/housekeeping/collectors/docker-image.ts +135 -0
  85. package/src/housekeeping/collectors/omo-version.ts +32 -0
  86. package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
  87. package/src/housekeeping/collectors/reconcile-work.ts +55 -0
  88. package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
  89. package/src/housekeeping/collectors/shared.ts +149 -0
  90. package/src/housekeeping/collectors/temp-dir.ts +28 -0
  91. package/src/housekeeping/collectors/update-file.ts +25 -0
  92. package/src/housekeeping/collectors/worktree.ts +193 -0
  93. package/src/housekeeping/engine.ts +125 -0
  94. package/src/housekeeping/reconcile/work.ts +130 -0
  95. package/src/housekeeping/registry.ts +63 -0
  96. package/src/housekeeping/trash.ts +59 -0
  97. package/src/housekeeping/types.ts +31 -0
  98. package/src/hub.ts +1095 -41
  99. package/src/main.ts +228 -30
  100. package/src/message-references.ts +14 -5
  101. package/src/profile-prompt.ts +13 -1
  102. package/src/protocol.ts +41 -6
  103. package/src/security-allow.ts +79 -2
  104. package/src/security-check.ts +1 -1
  105. package/src/store.ts +1946 -23
  106. package/src/test-crash.ts +20 -0
  107. package/src/work-watch.ts +55 -7
  108. package/src/work.ts +192 -3
  109. package/web/DESIGN.md +33 -0
  110. package/web/agents.d.ts +189 -12
  111. package/web/agents.js +898 -39
  112. package/web/app.js +2634 -210
  113. package/web/attachments.d.ts +91 -0
  114. package/web/attachments.js +422 -0
  115. package/web/collaboration.d.ts +87 -0
  116. package/web/collaboration.js +290 -0
  117. package/web/decisions.d.ts +39 -2
  118. package/web/decisions.js +354 -2
  119. package/web/gc.d.ts +26 -5
  120. package/web/gc.js +113 -13
  121. package/web/i18n/agents.d.ts +4 -0
  122. package/web/i18n/agents.js +200 -8
  123. package/web/i18n/attachments.js +78 -0
  124. package/web/i18n/decisions.d.ts +4 -0
  125. package/web/i18n/decisions.js +68 -0
  126. package/web/i18n/gc.d.ts +4 -0
  127. package/web/i18n/gc.js +184 -2
  128. package/web/i18n/main.js +80 -8
  129. package/web/i18n/tour.d.ts +4 -0
  130. package/web/i18n/tour.js +37 -0
  131. package/web/index.html +20 -3
  132. package/web/profile.d.ts +2 -2
  133. package/web/profile.js +11 -6
  134. package/web/style.css +342 -21
  135. package/web/tooltip.d.ts +2 -0
  136. package/web/tooltip.js +12 -0
  137. package/web/tour.d.ts +57 -0
  138. package/web/tour.js +450 -0
package/src/hub.ts CHANGED
@@ -4,25 +4,40 @@ import type { SttProvider } from "./adapters/voice/stt.ts";
4
4
  import { blockingCompaction, compactionOutcome, OmoCommandError, type CompactionFailureKind, type OmoEvent, type OmoStatus } from "./omo/client.ts";
5
5
  import type { OmoRpc } from "./omo/generations.ts";
6
6
  import { type ActionAnswerData, type ConversationActivity, type ConversationInfo, type Envelope, type MessageReference, PROTOCOL_VERSION, type Source, type Trust } from "./protocol.ts";
7
- import { readFileSync, statSync } from "node:fs";
7
+ import { existsSync, readFileSync, statSync } from "node:fs";
8
+ import { isDeepStrictEqual } from "node:util";
8
9
  import { mediaFile } from "./media.ts";
9
- import type { Decision, DecisionSubmission, LedgerEntry, LedgerEntryInput, LedgerState, MainInput, MainInputSource, Store, StoredEvent } from "./store.ts";
10
+ import { releasedWorkQuestion, type Decision, type DecisionSubmission, type LedgerEntry, type LedgerEntryInput, type LedgerState, type MainInput, type MainInputSource, type Store, type StoredEvent } from "./store.ts";
10
11
  import { type TranscriptLog, toRecord } from "./transcript.ts";
11
12
  import { MaintenanceError, nativeMaintenanceBlocker } from "./maintenance.ts";
12
13
  import { isVersionCommand, VERSION_COMMAND } from "./version.ts";
13
14
  import { isUsageCommand, USAGE_COMMAND } from "./usage.ts";
14
15
  import { COMPACT_COMMAND, compactInstructions } from "./compact.ts";
15
16
  import { MessageReferenceError, referencedInput, resolveMessageReferences } from "./message-references.ts";
17
+ import type { AttachmentService } from "./attachments/service.ts";
18
+ import { AttachmentStoreError, type AttachmentMetadata } from "./attachments/types.ts";
16
19
  import { readProfile } from "./profile.ts";
17
- import type { MessageGuard, SecurityChecker } from "./security-check.ts";
20
+ import { countFindings, type MessageGuard, type SecurityChecker, type SecurityFinding } from "./security-check.ts";
18
21
  import { SecurityRuntimeError, type SecurityReceipt } from "./security-allow.ts";
19
22
  import { nativeReplyEntry, type NativeReplyObserver } from "./reply-notifications.ts";
20
23
  import { notificationHistory } from "./notification-history.ts";
21
24
  import { messageTimestamp, nativeTurnStarted, nativeTurnStats } from "./turn-stats.ts";
22
25
  import { join } from "node:path";
23
26
  import { mergeAgentDefinition, AgentDefinitionError, type MergedAgentDefinition } from "./agent/definition.ts";
27
+ import { legacyToolScopes } from "./agent/definition.ts";
28
+ import type { AgentToolMigrationCard } from "./agent/tool-permissions.ts";
24
29
  import { AgentFileStore } from "./agent/files.ts";
30
+ import type { CollaborationLifecycle } from "./agent/collaboration-session.ts";
31
+ import { CollaborationStoreError, collaborationNever } from "./agent/collaboration-types.ts";
25
32
  import { type Language, say, serverLanguage, type Text, textIn } from "./server-text.ts";
33
+ import { createSecretKey, randomBytes, randomUUID, type KeyObject } from "node:crypto";
34
+ import { ManagedCallError, verifyManagedCall, type ManagedCallBinding, type ManagedCallBindingResolver, type ManagedCallIdentity, type VerifiedManagedCall } from "./agent/managed-call.ts";
35
+ import type { ManagedSession } from "./attachments/managed-binding.ts";
36
+ import { AgentToolPermissionError, type AgentAccessView, type AgentToolGrantRow, type AgentToolRequestInput, type AgentToolRequestRecord } from "./agent/tool-permissions.ts";
37
+ import { agentToolActionId, agentToolOperationId, engineCommands, grantedOperations, pushSessionPolicy } from "./agent/tool-delivery.ts";
38
+ import { ASIDE_OPERATIONS } from "./agent/tool-catalog.ts";
39
+ import { maintenanceAuth } from "./maintenance.ts";
40
+ import { testCrashPoint } from "./test-crash.ts";
26
41
 
27
42
  class AgentSessionError extends Error {
28
43
  constructor() {
@@ -79,6 +94,10 @@ export interface HubOptions {
79
94
  usageReport?: () => Promise<string>;
80
95
  /** Improvement 165: the security check of user text; a finding keeps the text from omo and the store. */
81
96
  guard?: Pick<MessageGuard, "check" | "hold" | "existing">;
97
+ /** Selected host documents; absent when this gateway has no attachment service wired. */
98
+ documents?: AttachmentService;
99
+ /** 294: agent session binding for /internal/agent-tools/*; `ready` runs after a bind so held grants are delivered. */
100
+ agentTools?: { readonly bindings: AgentToolBindings; readonly ready?: (convId: string) => void };
82
101
  }
83
102
 
84
103
  /** Anthropic rejects images over 5 MB; bigger attachments reach the agent as a file path only. */
@@ -232,6 +251,8 @@ function describeBlockers(blockers: readonly RotationBlocker[]): string {
232
251
  const HANDOFF_MAX_CHARS = 8000;
233
252
  const HANDOFF_LINE_CHARS = 600;
234
253
  const CONTEXT_OVERFLOW = /prompt is too long|context overflow/i;
254
+ /** ensureSession/prompt refusals that mean "the session is not ready now", not a dispatch failure. */
255
+ const DISPATCH_NOT_READY = new Set(["omo_not_ready", "stale_open_session", "session_rotating", "omo_owner_not_ready", "agent_busy"]);
235
256
  const COMPACTION_REQUIRED_MESSAGE: Text = {
236
257
  en: "The engine did not receive this message because the conversation summary did not finish. The message stays in the history and will not be sent again automatically. Fix the cause of the summary failure or press 'New session'.",
237
258
  ko: "대화 요약이 끝나지 않아 엔진이 이 메시지를 받지 않았습니다. 메시지는 기록에 남아 있고 자동으로 다시 보내지 않습니다. 요약 실패 원인을 해결하거나 '새 세션'을 눌러 주세요.",
@@ -281,8 +302,33 @@ export const HUB_TEXT = {
281
302
  recoveredMany: { en: " Filled in {count} missed answers.", ko: " 놓친 답변 {count}개를 채웠습니다." },
282
303
  multiSelect: { en: " (multiple choices allowed)", ko: " (여러 개 선택 가능)" },
283
304
  questionUnreachable: { en: "The question window did not reach the web. Press Stop, then answer in the chat.", ko: "질문 창이 웹에 도착하지 않았습니다. 중단 버튼을 누른 뒤 채팅으로 답해 주세요." },
305
+ collaborationRequestSent: { en: "Sent a collaboration request", ko: "협업 요청을 보냈습니다" },
306
+ collaborationRequestReceived: { en: "Received a collaboration request", ko: "협업 요청을 받았습니다" },
307
+ collaborationResultSent: { en: "Sent the collaboration result", ko: "협업 결과를 보냈습니다" },
308
+ collaborationResultReceived: { en: "Received the collaboration result", ko: "협업 결과를 받았습니다" },
309
+ collaborationEndedWithoutResult: { en: "The collaboration request ended without a result", ko: "협업 요청이 결과 없이 끝났습니다" },
284
310
  } as const satisfies Record<string, Text>;
285
311
 
312
+ /**
313
+ * C-4 fallback for apps that do not know `message.collaboration`: one `notice {kind:"collaboration",requestId,direction,message}`
314
+ * in the same conversation as each stored collaboration message (the web skips it next to its card). A result-less final
315
+ * reply (`agent.collaboration.final_reply`) is the requester's reply too and names its final state and reason. Null for anything else.
316
+ */
317
+ export function collaborationNotice(event: Pick<StoredEvent, "type" | "data">): { readonly kind: "collaboration"; readonly requestId: string; readonly direction: "request" | "reply"; readonly message: string } | null {
318
+ const data = event.data;
319
+ if (event.type === "agent.collaboration.final_reply" && typeof data === "object" && data !== null && "requestId" in data && typeof data.requestId === "string" &&
320
+ "state" in data && typeof data.state === "string" && "reason" in data && typeof data.reason === "string") {
321
+ return { kind: "collaboration", requestId: data.requestId, direction: "reply", message: `${say(HUB_TEXT.collaborationEndedWithoutResult)}: ${data.state} (${data.reason})` };
322
+ }
323
+ if (event.type !== "message.collaboration" || typeof data !== "object" || data === null || !("requestId" in data) || typeof data.requestId !== "string" ||
324
+ !("direction" in data) || (data.direction !== "request" && data.direction !== "reply")) return null;
325
+ const outgoing = "outgoing" in data && data.outgoing === true;
326
+ const label = data.direction === "request" ? (outgoing ? HUB_TEXT.collaborationRequestSent : HUB_TEXT.collaborationRequestReceived)
327
+ : (outgoing ? HUB_TEXT.collaborationResultSent : HUB_TEXT.collaborationResultReceived);
328
+ const text = "text" in data && typeof data.text === "string" ? data.text.trim() : "";
329
+ return { kind: "collaboration", requestId: data.requestId, direction: data.direction, message: text ? `${say(label)}: ${text}` : `${say(label)}.` };
330
+ }
331
+
286
332
  /** Why a session was replaced. The user notice uses the UI language; the engine context keeps the Korean text. */
287
333
  const ROTATION_REASON = {
288
334
  size: { en: "the session file reached {mb} MB", ko: "세션 파일이 {mb}MB에 닿음" },
@@ -343,6 +389,10 @@ interface ConvRuntime {
343
389
  atomicQueuedResume: boolean;
344
390
  /** `open_session` state.sessionId: the session file's own identity, which survives reconnects. */
345
391
  durableId: string | null;
392
+ /** Current attachment-read binding of this native session. */
393
+ documentIncarnation?: string;
394
+ /** The host advertises document input and this session's gateway extension registered the resolver. */
395
+ documentResolver?: boolean;
346
396
  waking: Promise<void> | null;
347
397
  wake: WorkWake | null;
348
398
  notified: Set<string>;
@@ -430,6 +480,12 @@ export const MAIN_QUESTION_RELEASED =
430
480
  "question open in the user's inbox and returned at once. The user has not answered yet. The answer arrives later as a " +
431
481
  "ZGW_DECISION_COMMAND user message with the question and the response. Until then, do not take the action that depends on " +
432
482
  "this answer and do not ask it again; handle every other message as usual.";
483
+ /** Bug 236: the tool result a work gets for its required question when a newer instruction was queued behind it. */
484
+ export const WORK_QUESTION_RELEASED =
485
+ "ZGW_QUESTION_OPEN This note is from the gateway, not the user's answer. A newer instruction arrived while this work waited " +
486
+ "on this question, so the gateway ended the wait and kept the question open in the user's inbox. The user has not answered " +
487
+ "yet. The answer arrives later as a ZGW_WORK_COMMAND with the question and the response. Until then, do not take the action " +
488
+ "that depends on this answer and do not ask it again; handle the newer instruction and every other message as usual.";
433
489
  const PERMISSION_PREFIX = "Permission required:";
434
490
  const AUTO_CONTINUE_LIMIT = 2;
435
491
  const CONTINUE_PROMPT =
@@ -471,6 +527,11 @@ export function modelOptions(models: OmoModel[]): ModelChoice[] {
471
527
  }
472
528
 
473
529
  export class Hub {
530
+ private collaboration: CollaborationLifecycle | undefined;
531
+ private readonly collaborationFlights = new Map<string, Promise<void>>();
532
+ private readonly settling = new Map<string, number>();
533
+ private readonly collaborationGeneration = crypto.randomUUID();
534
+ private readonly collaborationIncarnations = new Map<string, string>();
474
535
  private readonly runtime = new Map<string, ConvRuntime>();
475
536
  private readonly deletingAgents = new Set<string>();
476
537
  private readonly sessionToConv = new Map<string, string>();
@@ -514,12 +575,266 @@ export class Hub {
514
575
  ) {
515
576
  if (!store.getConversation("main")) store.createConversation("main", "main");
516
577
  this.maintenanceId = store.getSetting("maintenance.owner") || null;
578
+ store.onCollaborationEvents = () => this.publishCollaborationEvents();
517
579
  }
518
580
 
519
581
  attachOmo(omo: OmoRpc): void {
520
582
  this.omo = omo;
521
583
  }
522
584
 
585
+ attachCollaboration(lifecycle: CollaborationLifecycle): void { this.collaboration = lifecycle; }
586
+
587
+ checkCollaborationText(text: string): void {
588
+ // Artifact/peer review is not an owner message closure. T7 owns that purpose-specific ingress.
589
+ if (this.options.guard?.check([text])) throw new CollaborationStoreError("collaboration_security_review_required");
590
+ }
591
+
592
+ /** The finding kinds and counts of the texts a collaboration read would release; empty when the check passes or no guard is set. */
593
+ collaborationFindings(texts: readonly string[]): SecurityFinding[] {
594
+ return countFindings(this.options.guard?.check(texts) ?? []);
595
+ }
596
+
597
+ /** A security hold's decision changed outside the DecisionService (created, reviewed, settled, resumed): publish its decision.updated. */
598
+ refreshDecisions(): void {
599
+ this.decisions?.refresh();
600
+ }
601
+
602
+ /** Owner allow and the reader's settle both call this; whichever comes second queues the one reviewed read. */
603
+ resumeArtifactReview(holdId: string): void {
604
+ try {
605
+ const hold = this.store.artifactSecurityHold(holdId), lifecycle = this.collaboration;
606
+ if (!lifecycle || !hold || hold.state !== "allowed" || hold.answerSubmissionId === null || !hold.continuation.settledReceipt ||
607
+ hold.continuation.resumeSubmissionId !== null) return;
608
+ this.store.resumeArtifactRead({ holdId, expectedRevision: hold.revision, resumeSubmissionId: hold.answerSubmissionId, readOperationId: hold.readOperationId,
609
+ scopeDigest: hold.scopeDigest, ownerPrincipalRef: hold.ownerPrincipalRef }, this.store.artifactReaderBinding(hold.readOperationId), lifecycle.artifactRead(hold));
610
+ void this.dispatchCollaboration(hold.readScope.granteeConv);
611
+ } finally {
612
+ // The settle before this call and the resume itself both moved the hold's decision revision.
613
+ this.decisions?.refresh();
614
+ }
615
+ }
616
+
617
+ /** Internal 294 consumer. This is not an owner answer endpoint or an ordinary permission router. */
618
+ acceptCollaborationPermission(outcome: import("./agent/collaboration-types.ts").CollaborationPermissionOutcome) {
619
+ if (!this.collaboration) throw new CollaborationStoreError("authority_adapter_required", 503);
620
+ const activation = this.collaboration.permission(outcome);
621
+ void this.dispatchCollaboration(activation.targetConv);
622
+ return { activationId: activation.id, inputId: activation.inputId, state: activation.state };
623
+ }
624
+
625
+ /** A lease of an already-open native session; HTTP callers cannot open or select another conversation. */
626
+ collaborationSession(convId: string) {
627
+ const conv = this.store.listConversations(false).find(row => row.id === convId), rt = this.runtime.get(convId);
628
+ const incarnation = this.collaborationIncarnations.get(convId);
629
+ if (!conv || !conv.threadId || !rt?.sessionId || !rt.durableId || !incarnation ||
630
+ this.omoStatus !== "ready" || rt.rotating || this.maintenanceId || this.deletingAgents.has(convId)) return null;
631
+ const sessionId = rt.sessionId, durableId = rt.durableId, generation = this.generation;
632
+ const ownerInputRevision = this.store.lastSeq(convId, "message.user");
633
+ const current = () => this.omoStatus === "ready" && generation === this.generation && !rt.rotating &&
634
+ !this.maintenanceId && rt.sessionId === sessionId && rt.durableId === durableId &&
635
+ this.collaborationIncarnations.get(convId) === incarnation &&
636
+ this.sessionToConv.get(sessionId) === convId && this.store.getConversation(convId)?.threadId === conv.threadId &&
637
+ this.store.listConversations(false).some(row => row.id === convId && row.createdAt === conv.createdAt && row.agentId === conv.agentId) &&
638
+ !this.deletingAgents.has(convId) && this.store.lastSeq(convId, "message.user") === ownerInputRevision;
639
+ return { sessionId, durableId, sessionPath: conv.threadId, agentId: conv.agentId ?? null, incarnation,
640
+ generation: `${this.collaborationGeneration}:${generation}`, ownerInputRevision,
641
+ convEpoch: Date.parse(conv.createdAt), current,
642
+ rpc: (type: string, params: Record<string, unknown> = {}): Promise<unknown> =>
643
+ this.requireOmo().command(type, { ...params, sessionId }) };
644
+ }
645
+
646
+ /**
647
+ * A peer's tool call can reach HTTP before this Hub handles the input's user message_end frame. The caller has already
648
+ * authenticated the native consumption; record it from the same ledger evidence so the Store never refuses the call.
649
+ */
650
+ async recordCollaborationConsumption(convId: string, inputId: string): Promise<void> {
651
+ const sessionId = this.rt(convId).sessionId, generation = this.generation;
652
+ const input = this.store.mainTurn(convId)?.inputs.find(row => (row.data as MainInput).input_id === inputId);
653
+ if (!sessionId || !input || this.store.mainInputConsumed(input)) return;
654
+ const data: MainInput = JSON.parse(JSON.stringify(input.data));
655
+ if (data.origin !== "agent_collaboration" && data.origin !== "agent_tool_permission") return;
656
+ const ledger = await this.currentLedger(convId, sessionId, generation);
657
+ const entry = ledger && this.ledgerInputEntry(ledger, data);
658
+ if (!ledger || !entry || generation !== this.generation) return;
659
+ const consumed = this.store.consumeMainInput(input, { entry, durableId: ledger.durableId });
660
+ if (consumed) this.publishStored(consumed);
661
+ }
662
+
663
+ /** Publish only the committed queued records; T6 owns subsequent dispatch and consumption. */
664
+ collaborationQueued(requestId: string, cursors: ReadonlyMap<string, number>): void {
665
+ for (const [conv, cursor] of cursors) for (const event of this.store.eventsAfter(conv, cursor)) {
666
+ if (event.type === "message.collaboration" || event.type === "agent.collaboration.status") {
667
+ const data = event.data;
668
+ if (typeof data === "object" && data !== null && "requestId" in data && data.requestId === requestId) {
669
+ this.publishStored(event);
670
+ this.noticeCollaboration(event);
671
+ }
672
+ }
673
+ }
674
+ // The same admission may have moved the parent request (waiting_peer); its status goes out with the rest.
675
+ this.publishCollaborationEvents();
676
+ const request = this.store.getAgentRequest(requestId);
677
+ if (request) void this.dispatchCollaboration(request.targetConv);
678
+ }
679
+
680
+ /**
681
+ * F3 D1: every stored collaboration event the Store queued since the last call reaches the open pages now, on the same
682
+ * frames their history replay uses (the requester's status changes and a result's two reply messages), so a page never
683
+ * has to reload to see a card finish, stop or wait for the owner. The receiving conversation stores no status of its
684
+ * own (its listing carries the state), so its open page gets the requester's status as a live frame that is not stored.
685
+ * Called at the Hub's own points and by the Store one microtask after any other transaction that queued events.
686
+ */
687
+ publishCollaborationEvents(): void {
688
+ for (const event of this.store.takeCollaborationEvents()) {
689
+ this.publishStored(event);
690
+ if (event.type !== "agent.collaboration.status") continue;
691
+ const data = event.data;
692
+ const request = typeof data === "object" && data !== null && "requestId" in data && typeof data.requestId === "string" ? this.store.getAgentRequest(data.requestId) : null;
693
+ if (request && request.targetConv !== event.conv) this.emit(request.targetConv, "agent.collaboration.status", data, false);
694
+ }
695
+ }
696
+
697
+ /** Asks the conversation's current extension to drain and seal the outputs listed by its frozen result execute. */
698
+ async collaborationSnapshot(convId: string, call: { readonly requestId: string; readonly requestRevision: number; readonly operationKey: string;
699
+ readonly bindingDigest: string; readonly paths: readonly string[] }): Promise<
700
+ { readonly ok: true; readonly files: readonly { readonly sealedName: string; readonly sha256: string; readonly bytes: number; readonly mediaType: string; readonly name: string }[] } |
701
+ { readonly ok: false; readonly error: string }> {
702
+ const sessionId = this.rt(convId).sessionId;
703
+ if (!sessionId || this.omoStatus !== "ready") throw new CollaborationStoreError("collaboration_session_unavailable", 503);
704
+ return this.requireOmo().command("extension_request", { sessionId, name: "zgw.agent_snapshot", data: { ...call, paths: [...call.paths] } });
705
+ }
706
+
707
+ /**
708
+ * Cooperative interrupt for executions whose request is cancel_requested, sent only while that exact activation is still
709
+ * the conversation's current binding and run. An owner turn or a later activation is never aborted.
710
+ */
711
+ async interruptCancelledCollaboration(): Promise<void> {
712
+ for (const { conv, activationId } of this.store.cancelRequestedExecutions()) {
713
+ const rt = this.rt(conv), sessionId = rt.sessionId;
714
+ if (!sessionId || !rt.running || this.omoStatus !== "ready" || this.store.agentBinding(conv)?.activationId !== activationId) continue;
715
+ await this.requireOmo().command("abort", { sessionId });
716
+ }
717
+ }
718
+
719
+ /** A settled execution commits its explicit result once (waking the requester) or records result_missing. */
720
+ private finalizeCollaboration(activationId: string): void {
721
+ const lifecycle = this.collaboration;
722
+ if (!lifecycle) return;
723
+ const finished = this.store.finalizeAgentActivation(activationId, request => lifecycle.gate(request));
724
+ if (finished.result) {
725
+ testCrashPoint("collaboration.result.committed");
726
+ // The committed status and reply messages reach the open pages before their fallback notices.
727
+ this.publishCollaborationEvents();
728
+ // The result's two reply messages were just committed as the last collaboration message of each side.
729
+ const request = this.store.getAgentRequest(finished.result.requestId);
730
+ for (const conv of request ? [request.requesterConv, request.targetConv] : []) {
731
+ const reply = this.store.getEvent(this.store.lastSeq(conv, "message.collaboration"));
732
+ const data = reply?.data;
733
+ if (reply && typeof data === "object" && data !== null && "resultId" in data && data.resultId === finished.result.id) this.noticeCollaboration(reply);
734
+ }
735
+ }
736
+ for (const decisionId of this.store.collaborationPermissionDecisions(activationId)) this.continueCollaborationPermission(decisionId);
737
+ this.wakeCollaborationReplies();
738
+ }
739
+
740
+ /** Sent once, when the collaboration message itself is first published, so a replay never repeats it. */
741
+ private noticeCollaboration(event: StoredEvent): void {
742
+ const notice = collaborationNotice(event);
743
+ if (notice) this.emit(event.conv, "notice", notice);
744
+ }
745
+
746
+ /** A reply queued by a result or by a final state without one wakes its requester; dispatch keeps owner turns first. */
747
+ wakeCollaborationReplies(): void {
748
+ // The pages get a cancel or a result-less end at once, whatever the engine's state (a closed Store queues nothing).
749
+ this.publishCollaborationEvents();
750
+ // A disconnected Hub dispatches nothing; its Store may already be closed under a late continuation.
751
+ if (this.omoStatus !== "ready") return;
752
+ for (const reply of this.store.takeFinalReplies()) this.noticeCollaboration(reply);
753
+ for (const conv of this.store.queuedAgentReplyConvs()) void this.dispatchCollaboration(conv);
754
+ }
755
+
756
+ /** Only Hub opens the existing conversation. A queued peer never steers an active owner. */
757
+ dispatchCollaboration(convId: string): Promise<void> {
758
+ const active = this.collaborationFlights.get(convId);
759
+ if (active) return active;
760
+ const lifecycle = this.collaboration, rt = this.rt(convId);
761
+ const available = () => this.omoStatus === "ready" && !this.maintenanceId && !rt.rotating &&
762
+ !this.closeUncertain(convId) && !rt.running && rt.admitting === 0 && !rt.waking && !rt.wake &&
763
+ !this.settling.get(convId) && !this.store.mainTurn(convId) && this.store.pendingMainUsers(convId).length === 0;
764
+ // Liveness first: a prompt finishing after its engine disconnected (and its Store closed) must not read the Store.
765
+ if (!lifecycle || !available()) return Promise.resolve();
766
+ const first = this.store.nextAgentInput(convId);
767
+ const firstRead = this.store.nextArtifactReadInput(convId);
768
+ if (!first && !firstRead) return Promise.resolve();
769
+ const generation = this.generation, run = rt.run;
770
+ const flight = (async () => {
771
+ const conv = this.store.getConversation(convId);
772
+ if (!conv) return;
773
+ let readable = firstRead;
774
+ if (readable) {
775
+ try { lifecycle.artifactRead(readable.hold); }
776
+ catch (error) {
777
+ if (!(error instanceof CollaborationStoreError) || error.status !== 503) throw error;
778
+ this.emit(convId, "agent.collaboration.blocked", { code: error.code, holdId: readable.hold.id });
779
+ readable = null;
780
+ }
781
+ }
782
+ if (!first && !readable) return;
783
+ const { sessionId } = await this.ensureSession(conv);
784
+ const state = await this.requireOmo().command<{ isStreaming?: boolean; pendingMessageCount?: number; isCompacting?: boolean;
785
+ isBashRunning?: boolean }>("get_state", { sessionId });
786
+ const runtime = await this.requireOmo().command<{ ownedInFlight?: number }>("extension_request",
787
+ { sessionId, name: "zgw.agent_collaboration_runtime" });
788
+ if (generation !== this.generation || rt.run !== run || rt.sessionId !== sessionId || !available() ||
789
+ state.isStreaming !== false || state.pendingMessageCount !== 0 || state.isCompacting || state.isBashRunning ||
790
+ runtime.ownedInFlight !== 0) return;
791
+ const next = this.store.nextAgentInput(convId);
792
+ const read = readable && this.store.nextArtifactReadInput(convId);
793
+ if (read && (!next || read.seq < next.activation.inputSeq)) {
794
+ const body = `ZGW_COLLABORATION_INPUT ${JSON.stringify({ origin: "collaboration_artifact_read",
795
+ holdId: read.hold.id, readOperationId: read.hold.readOperationId, continuationId: read.hold.continuation.id,
796
+ reviewedScopeDigest: read.hold.scopeDigest, read: read.hold.readArguments, trust: "delegated" })}`;
797
+ await this.prompt(convId, body, { artifactRead: read.hold });
798
+ return;
799
+ }
800
+ if (!next) return;
801
+ const event = this.store.getEvent(next.activation.inputSeq);
802
+ const intent: import("./agent/collaboration-types.ts").CollaborationIntent = JSON.parse(JSON.stringify(event?.data));
803
+ let payload: unknown;
804
+ switch (intent.origin) {
805
+ case "agent_collaboration": payload = this.store.getEvent(intent.messageSeq)?.data; break;
806
+ case "agent_tool_permission": payload = intent.outcome; break;
807
+ case "collaboration_artifact_read": case "collaboration_stage": throw new CollaborationStoreError("input_source_mismatch");
808
+ default: return collaborationNever(intent);
809
+ }
810
+ // A single JSON value keeps slash/control/envelope-looking peer bytes literal.
811
+ const body = `ZGW_COLLABORATION_INPUT ${JSON.stringify({ origin: intent.origin, requestId: next.request.id,
812
+ rootId: next.request.rootId, activationId: next.activation.id, trust: "delegated", payload })}`;
813
+ await this.prompt(convId, body, { collaboration: next });
814
+ })().catch(error => {
815
+ // Every caller drops this promise (`void`), so nothing may escape it. A flight whose engine generation ended, or
816
+ // whose session was not ready, is stale rather than failed: its input was never claimed and stays queued for the
817
+ // next ready/settle dispatch.
818
+ if (generation !== this.generation || this.omoStatus !== "ready" || error instanceof MaintenanceError ||
819
+ (error instanceof Error && DISPATCH_NOT_READY.has(error.message))) {
820
+ console.info(`collaboration dispatch (${convId}) not ready: ${errorMessage(error)}`);
821
+ return;
822
+ }
823
+ // Any other failure is logged and reported on the conversation. An input that never crossed the prompt stays
824
+ // queued (the next dispatch retries it); prompt() itself already recorded the delivery state of one that did.
825
+ console.error(`collaboration dispatch failed (${convId}): ${errorMessage(error)}`);
826
+ this.emit(convId, "agent.collaboration.blocked", { code: error instanceof CollaborationStoreError ? error.code : "dispatch_failed" });
827
+ }).finally(() => {
828
+ if (this.collaborationFlights.get(convId) !== flight) return;
829
+ this.collaborationFlights.delete(convId);
830
+ // A settle before the prompt ACK must not lose the wake for the next FIFO item.
831
+ if (available() && (this.store.nextAgentInput(convId)?.activation.id !== first?.activation.id ||
832
+ this.store.nextArtifactReadInput(convId)?.hold.id !== firstRead?.hold.id)) void this.dispatchCollaboration(convId);
833
+ }).catch(error => console.error(`collaboration dispatch wake failed (${convId}): ${errorMessage(error)}`));
834
+ this.collaborationFlights.set(convId, flight);
835
+ return flight;
836
+ }
837
+
523
838
  /** Explicit mode activation snapshots every existing main source before admitting new occurrences. */
524
839
  async notificationBaseline(): Promise<void> {
525
840
  for (const conv of this.store.listConversations()) {
@@ -539,6 +854,27 @@ export class Hub {
539
854
 
540
855
  attachDecisions(decisions: DecisionService): void {
541
856
  this.decisions = decisions;
857
+ decisions.useToolAnswered(decisionId => this.continueCollaborationPermission(decisionId));
858
+ }
859
+
860
+ /**
861
+ * Q-07: the owner's stored answer to a 294 card asked inside a collaboration execution continues that request once.
862
+ * Called after the answer commits and after the asking execution settles; the dispatcher dedupes the exact outcome, so
863
+ * whichever comes second is the one that continues. A refusal is reported on the request's conversation.
864
+ */
865
+ continueCollaborationPermission(decisionId: string): void {
866
+ const outcome = this.store.collaborationPermissionOutcome(decisionId);
867
+ if (!outcome || !this.collaboration) return;
868
+ const blocked = this.store.getAgentActivation(outcome.blockedActivationId);
869
+ if (!blocked?.quiesced) return;
870
+ try {
871
+ this.acceptCollaborationPermission(outcome);
872
+ // The answer also released the requests below the held one (as a question answer does).
873
+ for (const conv of this.store.agentDecisionDependents(decisionId)) void this.dispatchCollaboration(conv);
874
+ } catch (error) {
875
+ if (!(error instanceof CollaborationStoreError)) throw error;
876
+ this.emit(blocked.targetConv, "agent.collaboration.blocked", { code: error.code, decisionId });
877
+ }
542
878
  }
543
879
 
544
880
  decisionLiveBinding(decision: Decision): DecisionSubmission["liveBinding"] {
@@ -572,7 +908,7 @@ export class Hub {
572
908
  async dispatchDecisionMain(input: StoredEvent): Promise<void> {
573
909
  const data = input.data as { text: string };
574
910
  this.publishStored(input);
575
- await this.prompt(input.conv, data.text, [], input.seq, true);
911
+ await this.prompt(input.conv, data.text, { messageSeq: input.seq, admitted: true });
576
912
  }
577
913
 
578
914
  /**
@@ -583,9 +919,12 @@ export class Hub {
583
919
  const conv = this.store.getConversation(convId);
584
920
  if (!conv || this.omoStatus !== "ready") return;
585
921
  const generation = this.generation;
922
+ const started = conv.threadId;
586
923
  const { sessionId } = await this.ensureSession(conv);
587
924
  const ledger = await this.currentLedger(convId, sessionId, generation, 30_000);
588
- if (!ledger) return;
925
+ // Evidence read for the settled session never judges the decisions of a session the conversation rotated to meanwhile.
926
+ if (!ledger || (started && ledger.sessionPath !== started) ||
927
+ ledger.sessionPath !== this.store.getConversation(convId)?.threadId) return;
589
928
  const path = ledger.sessionPath;
590
929
  const owner = { conv: convId, scope: this.conversationScope(convId), sessionPath: path };
591
930
  const userMessages = this.store.ledgerUserTexts(path);
@@ -625,6 +964,10 @@ export class Hub {
625
964
  return this.omoStatus === "ready" && sessionId !== undefined && this.sessionToConv.has(sessionId);
626
965
  }
627
966
 
967
+ hasSession(conv: string): boolean {
968
+ return this.runtime.get(conv)?.sessionId != null;
969
+ }
970
+
628
971
  /** Execution indicator, not authority to replace the native runtime. */
629
972
  busy(): boolean {
630
973
  for (const rt of this.runtime.values()) if (rt.admitting > 0 || rt.running || rt.rotating || rt.opening || rt.waking || rt.wake) return true;
@@ -750,12 +1093,13 @@ export class Hub {
750
1093
  }
751
1094
 
752
1095
  /**
753
- * Main questions kept open without their native wait still wait for the user: their cards (and so the device question
754
- * notifications) come from the stored decisions, which outlive a gateway restart.
1096
+ * Main questions kept open without their native wait, and work questions released for a newer instruction (bug 236),
1097
+ * still wait for the user: their cards (and so the device question notifications) come from the stored decisions,
1098
+ * which outlive a gateway restart.
755
1099
  */
756
1100
  private releasedMainCards(): (QuestionCard & { decisionId: string })[] {
757
- return this.store.allDecisions().filter((d) => d.workId === null && d.method === "question" && d.status === "pending" &&
758
- d.bindingState === "closed" && d.reason === "main_released" && d.sourceSessionPath !== null).map((d) => {
1101
+ return this.store.allDecisions().filter((d) => d.method === "question" && d.status === "pending" && d.sourceSessionPath !== null &&
1102
+ ((d.workId === null && d.bindingState === "closed" && d.reason === "main_released") || releasedWorkQuestion(d))).map((d) => {
759
1103
  const payload = d.payload as { title?: unknown; questions?: unknown };
760
1104
  return {
761
1105
  actionId: d.legacyActionId ?? scopedActionId(d.scope, d.sourceRequestId), scope: d.scope, conv: d.ownerConv,
@@ -787,7 +1131,8 @@ export class Hub {
787
1131
  */
788
1132
  history(conv: string, query: HistoryQuery = {}): HistoryPage {
789
1133
  const limit = Math.min(Math.max(Math.trunc(query.limit ?? HISTORY_PAGE), 1), HISTORY_MAX_PAGE);
790
- const toEnvelope = (e: StoredEvent): Envelope => {
1134
+ const toEnvelope = (stored: StoredEvent): Envelope => {
1135
+ const e = this.withAttachmentState(stored);
791
1136
  const action = e.data;
792
1137
  if (e.type !== "action.request" || typeof action !== "object" || action === null || Array.isArray(action) ||
793
1138
  !("actionId" in action) || typeof action.actionId !== "string" || "decisionId" in action) {
@@ -912,7 +1257,9 @@ export class Hub {
912
1257
  rt.sessionId = null;
913
1258
  }
914
1259
  const entry = trash();
915
- this.store.setConversationHidden(convId, true);
1260
+ const hide = () => this.store.setConversationHidden(convId, true);
1261
+ if (this.options.documents) this.options.documents.retireRecipient(convId, conv.agentId, hide);
1262
+ else hide();
916
1263
  return entry;
917
1264
  } finally {
918
1265
  this.deletingAgents.delete(convId);
@@ -925,6 +1272,8 @@ export class Hub {
925
1272
  // A connection change is an observation too: a ledger read issued before it is never shared after it.
926
1273
  this.eventCount += 1;
927
1274
  if (status !== "ready") {
1275
+ this.collaboration?.retire();
1276
+ this.collaborationIncarnations.clear();
928
1277
  this.generation += 1;
929
1278
  for (const recovery of this.closeRecoveries.values()) {
930
1279
  if (recovery.timer !== undefined) this.clearCloseTimer(recovery.timer);
@@ -954,6 +1303,8 @@ export class Hub {
954
1303
  }
955
1304
  }
956
1305
  }
1306
+ // A fresh connection (gateway start or reconnect) owns no earlier native run: deliveries left mid-boundary are uncertain.
1307
+ if (status === "ready") this.store.markUncertainAgentDeliveries();
957
1308
  this.publishAllActivities();
958
1309
  this.emit("*", "omo.status", { status, detail: detail ?? null }, false);
959
1310
  return status === "ready" && !this.maintenanceId
@@ -983,9 +1334,9 @@ export class Hub {
983
1334
  if (pendingUsers.length > 0) {
984
1335
  await this.ensureSession(current);
985
1336
  for (const user of pendingUsers) {
986
- const data = user.data as { text: string; images?: string[]; references?: MessageReference[] };
987
- const input = referencedInput(data.text, data.images ?? [], data.references);
988
- await this.prompt(conv.id, input.text, input.images, user.seq, true);
1337
+ const data = user.data as { text: string; images?: string[]; references?: MessageReference[]; attachments?: AttachmentMetadata[]; documentFallback?: boolean };
1338
+ const input = referencedInput(data.documentFallback ? "" : data.text, data.images ?? [], data.references, data.attachments);
1339
+ await this.prompt(conv.id, input.text, { images: input.images, messageSeq: user.seq, admitted: true, documents: data.attachments });
989
1340
  }
990
1341
  } else if (this.hasUnfinishedTurn(conv.id)) await this.recoverConversation(current);
991
1342
  else if (this.openQuestionCards(conv.id, this.conversationScope(conv.id)).size > 0) await this.ensureSession(current);
@@ -1004,6 +1355,7 @@ export class Hub {
1004
1355
  }
1005
1356
  // Opening main comes first. A notification ACK or worker reconciliation cannot hold readiness.
1006
1357
  void this.notifyWorkUpdates(conv.id);
1358
+ void this.dispatchCollaboration(conv.id);
1007
1359
  } catch (error) {
1008
1360
  if (generation === this.generation && activityRecovery === this.activityRecoveryGeneration) {
1009
1361
  this.activityRecoveryFailedGeneration = activityRecovery;
@@ -1015,7 +1367,7 @@ export class Hub {
1015
1367
  }
1016
1368
 
1017
1369
  /** `images` are names of files already uploaded to the media folder; unknown names are dropped. */
1018
- async sendText(convId: string, text: string, src: Source, trust: Trust, images: string[] = [], referenceIds?: unknown, checked = false, submissionId?: string): Promise<void> {
1370
+ async sendText(convId: string, text: string, src: Source, trust: Trust, images: string[] = [], referenceIds?: unknown, checked = false, submissionId?: string, principal: string | null = null): Promise<void> {
1019
1371
  if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
1020
1372
  this.requireVisibleAgent(convId);
1021
1373
  if (submissionId !== undefined && !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(submissionId)) throw new SecurityRuntimeError("held_conflict");
@@ -1027,6 +1379,9 @@ export class Hub {
1027
1379
  const body = text.trim();
1028
1380
  const source = structuredClone(src);
1029
1381
  const references = structuredClone(resolveMessageReferences(this.store, convId, referenceIds));
1382
+ if (references.some((reference) => reference.attachments?.length)) {
1383
+ return this.sendDocuments({ convId, text, src: source, trust, images, referenceIds, attachments: [], submissionId, principal });
1384
+ }
1030
1385
  if (this.options.versionReport && images.length === 0 && references.length === 0 && isVersionCommand(body)) {
1031
1386
  this.emit(convId, "notice", { kind: "version", level: "info", message: await this.options.versionReport() });
1032
1387
  return;
@@ -1069,7 +1424,8 @@ export class Hub {
1069
1424
  message: say(HUB_TEXT.closeChecking) });
1070
1425
  return;
1071
1426
  }
1072
- await this.prompt(convId, prompt.text, prompt.images, input.seq, true, attachmentSnapshot, receipt !== undefined);
1427
+ await this.prompt(convId, prompt.text, { images: prompt.images, messageSeq: input.seq, admitted: true,
1428
+ attachmentSnapshot, protectedPayload: receipt !== undefined });
1073
1429
  };
1074
1430
  // The approved closure owns the inspected references, body and image bytes. Never re-resolve reference ids.
1075
1431
  const found = checked ? null : this.options.guard?.check([prompt.text]);
@@ -1080,6 +1436,108 @@ export class Hub {
1080
1436
  await send();
1081
1437
  }
1082
1438
 
1439
+ /**
1440
+ * Documents enter through one Store transaction after the exact manifest is inspected. This path has no command
1441
+ * shortcuts, and a held send keeps its fixed plan: release fails if the prepared content changed meanwhile.
1442
+ */
1443
+ async sendDocuments(input: { convId: string; text: string; src: Source; trust: Trust; images: string[]; referenceIds: unknown; attachments: string[]; submissionId: string | undefined; principal: string | null; reply?: (frame: unknown) => void }): Promise<void> {
1444
+ const { convId, text, src, trust, images, referenceIds, attachments, submissionId, principal } = input;
1445
+ if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
1446
+ this.requireVisibleAgent(convId);
1447
+ const documents = this.options.documents;
1448
+ if (!documents) throw new AttachmentStoreError("attachment_provider_invalid", 503);
1449
+ if (principal === null) throw new AttachmentStoreError("attachment_owner_inactive", 403);
1450
+ if (submissionId === undefined) throw new AttachmentStoreError("invalid_attachment_input", 400);
1451
+ const context = { key: JSON.stringify(["message", convId, submissionId]), submissionId,
1452
+ snapshot: structuredClone({ text, src, trust, images, referenceIds, attachments }) };
1453
+ // An approved hold is already an accepted submission: the plan below returns its receipt or refuses a changed payload.
1454
+ const held = this.options.guard?.existing(context);
1455
+ if (held && held.status !== "sent") return;
1456
+ const references = structuredClone(resolveMessageReferences(this.store, convId, referenceIds));
1457
+ const dir = this.options.media?.dir;
1458
+ const attached = dir ? [...new Set(images)].filter((n) => mediaFile(dir, n)) : [];
1459
+ const plan = await documents.plan({ convId, principal, text, images: attached, references, src: structuredClone(src), trust, submissionId, attachments });
1460
+ const imageSnapshot = this.attachments(attached);
1461
+ const send = async (receipt?: (ref: SecurityReceipt) => void) => {
1462
+ this.requireVisibleAgent(convId);
1463
+ const accepted = documents.commit(plan, receipt);
1464
+ const stored = this.store.getEvent(accepted.messageSeq);
1465
+ // Only the live frame names the submission: the stored message and its request digest keep their shape.
1466
+ const echoed = stored && { ...stored, data: { ...(this.withAttachmentState(stored).data as Record<string, unknown>), submissionId } };
1467
+ if (!accepted.fresh) {
1468
+ if (echoed) input.reply?.({ v: PROTOCOL_VERSION, id: this.nextId("e"), ts: echoed.ts, conv: echoed.conv, type: echoed.type, data: echoed.data, seq: echoed.seq });
1469
+ return;
1470
+ }
1471
+ if (echoed) this.publishStored(echoed);
1472
+ this.decisions?.refresh();
1473
+ const rotationDone = this.rt(convId).rotationDone;
1474
+ if (rotationDone) await rotationDone;
1475
+ if (this.closeUncertain(convId)) {
1476
+ this.scheduleCloseRecovery(convId);
1477
+ return;
1478
+ }
1479
+ const prompt = referencedInput(plan.body, attached, references, accepted.attachments);
1480
+ await this.prompt(convId, prompt.text, { images: prompt.images, messageSeq: accepted.messageSeq, admitted: true,
1481
+ attachmentSnapshot: imageSnapshot, protectedPayload: receipt !== undefined, documents: accepted.attachments });
1482
+ };
1483
+ if (plan.findings) {
1484
+ this.options.guard?.hold(convId, "message", [...plan.findings], send, plan.body, context);
1485
+ return;
1486
+ }
1487
+ await send();
1488
+ }
1489
+
1490
+ /**
1491
+ * The live session's provider, API and input types pick the representation; a client never supplies them. A native
1492
+ * route also needs a host that registered this gateway's resolver, otherwise support is unproven and pages are used.
1493
+ */
1494
+ async documentRoute(convId: string): Promise<"native-document" | "pages" | "ocr"> {
1495
+ const conv = this.store.getConversation(convId);
1496
+ if (!conv) throw new Error(`unknown_conversation:${convId}`);
1497
+ const { sessionId } = await this.ensureSession(conv);
1498
+ const state = await this.requireOmo().command<{ model?: { input?: unknown; provider?: unknown; api?: unknown } | null }>("get_state", { sessionId });
1499
+ const input = state.model?.input;
1500
+ if (!Array.isArray(input)) throw new Error("model_capability_unknown");
1501
+ if (!input.includes("image")) return "ocr";
1502
+ const publicRoute = (state.model?.provider === "openai" && state.model?.api === "openai-responses") ||
1503
+ (state.model?.provider === "anthropic" && state.model?.api === "anthropic-messages");
1504
+ return publicRoute && this.rt(convId).documentResolver === true ? "native-document" : "pages";
1505
+ }
1506
+
1507
+ emitNotice(convId: string, kind: string): void {
1508
+ this.emit(convId, "notice", { kind, level: "warning", message: kind });
1509
+ }
1510
+
1511
+ /** Rotates the read secret on every native open; a failed bind leaves only a refused tool. */
1512
+ private async bindDocuments(conv: ConversationInfo, sessionId: string, sessionPath: string, rt: ConvRuntime, generation: number): Promise<void> {
1513
+ const documents = this.options.documents;
1514
+ if (!documents || !rt.durableId) return;
1515
+ const issued = documents.bindings.bind({
1516
+ role: conv.agentId ? "agent" : "main", agentId: conv.agentId ?? null, convId: conv.id, convEpoch: 1, lifecycleEpoch: documents.lifecycleEpoch(conv.id),
1517
+ sessionPath, durableSessionId: rt.durableId,
1518
+ }, () => generation === this.generation && rt.sessionId === sessionId);
1519
+ rt.documentIncarnation = issued.identity.sessionIncarnation;
1520
+ const last = this.store.getEvent(this.store.lastSeq(conv.id, "main.input"));
1521
+ const data = last?.data as { input_id?: unknown; session_path?: unknown } | undefined;
1522
+ const inputId = typeof data?.input_id === "string" && data.session_path === sessionPath ? data.input_id : null;
1523
+ if (last && inputId) documents.bindings.setInput(conv.id, issued.identity.sessionIncarnation, inputId, last.seq);
1524
+ try {
1525
+ const bound = await this.requireOmo().command<{ documentResolver?: unknown } | null>("extension_request", { sessionId, name: "zgw.attachment_bind", data: { secret: issued.secret, identity: issued.identity, inputId } });
1526
+ const protocol = this.requireOmo().protocolInfo as { capabilities?: unknown } | null;
1527
+ rt.documentResolver = bound?.documentResolver === true && Array.isArray(protocol?.capabilities) && protocol.capabilities.includes("document_content_v1");
1528
+ } catch (error) {
1529
+ documents.bindings.retire(conv.id);
1530
+ rt.documentResolver = false;
1531
+ console.error(`attachment read bind failed (${conv.id}): ${errorMessage(error)}`);
1532
+ }
1533
+ }
1534
+
1535
+ /** The extension signs with this id, so it must learn it before the native turn can call the tool. */
1536
+ private async announceDocumentInput(sessionId: string, incarnation: string, inputId: string): Promise<void> {
1537
+ try { await this.requireOmo().command("extension_request", { sessionId, name: "zgw.attachment_input", data: { sessionIncarnation: incarnation, inputId } }); }
1538
+ catch (error) { console.error(`attachment read input failed: ${errorMessage(error)}`); }
1539
+ }
1540
+
1083
1541
  /** Reserve gateway report admission before its HTTP body is parsed. The source
1084
1542
  * is not known yet, so this short request conservatively holds every conversation.
1085
1543
  * It never waits for a worker run or interrupts native work.
@@ -1115,6 +1573,8 @@ export class Hub {
1115
1573
  notifyWorkUpdates(convId: string): Promise<void> {
1116
1574
  if (this.maintenanceId) return Promise.resolve();
1117
1575
  const rt = this.rt(convId);
1576
+ const source = this.store.mainTurn(convId)?.source;
1577
+ if (source && source.origin !== "user" && source.origin !== "work_update") return Promise.resolve();
1118
1578
  if (this.omoStatus !== "ready") return Promise.resolve();
1119
1579
  if (rt.rotating || this.closeUncertain(convId)) return Promise.resolve();
1120
1580
  if (rt.waking) return rt.waking;
@@ -1139,6 +1599,7 @@ export class Hub {
1139
1599
  }
1140
1600
 
1141
1601
  private async injectWorkUpdates(convId: string, generation: number): Promise<void> {
1602
+ this.collaboration?.retire(convId);
1142
1603
  const conv = this.store.getConversation(convId);
1143
1604
  if (!conv) return;
1144
1605
  const { sessionId } = await this.ensureSession(conv);
@@ -1223,6 +1684,56 @@ export class Hub {
1223
1684
  const generation = this.generation;
1224
1685
  const run = rt.run;
1225
1686
  const wake = rt.wake;
1687
+ const authored = this.store.mainTurn(conv)?.inputs.filter(input => {
1688
+ const data: MainInput = JSON.parse(JSON.stringify(input.data));
1689
+ return data.origin !== "user" && data.origin !== "work_update";
1690
+ }) ?? [];
1691
+ if (authored.length) {
1692
+ const [ledger, state, runtime] = await Promise.all([
1693
+ this.currentLedger(conv, sessionId, generation),
1694
+ this.requireOmo().command<{ isStreaming?: boolean; pendingMessageCount?: number }>("get_state", { sessionId }),
1695
+ this.requireOmo().command<{ ownedInFlight?: number }>("extension_request", { sessionId, name: "zgw.agent_collaboration_runtime" }),
1696
+ ]);
1697
+ if (!ledger || generation !== this.generation || rt.sessionId !== sessionId || rt.run !== run ||
1698
+ state.isStreaming !== false || state.pendingMessageCount !== 0 || runtime.ownedInFlight !== 0) return;
1699
+ for (const input of authored) {
1700
+ const data: MainInput = JSON.parse(JSON.stringify(input.data));
1701
+ const entry = this.ledgerInputEntry(ledger, data);
1702
+ const after = entry && this.ledgerAfter(ledger, entry);
1703
+ // The cooperative cancel interrupt ends its run with an aborted final message; that run is over, so it settles too.
1704
+ // A stopped reply to a root requester executes no request, so nothing else could ever settle it.
1705
+ const cancelInterrupted = !!after && turnAborted(after) && (data.origin === "agent_collaboration" || data.origin === "agent_tool_permission") &&
1706
+ (this.store.cancelRequestedExecutions().some(execution => execution.activationId === data.input_ref.activation_id) ||
1707
+ this.store.agentReplyWithoutExecution(data.input_ref.activation_id));
1708
+ if (!entry || !after || !(turnFinished(after) || cancelInterrupted)) return;
1709
+ const consumed = this.store.consumeMainInput(input, { entry, durableId: ledger.durableId });
1710
+ if (consumed) this.publishStored(consumed);
1711
+ switch (data.origin) {
1712
+ case "agent_collaboration": case "agent_tool_permission": {
1713
+ const activation = this.store.getAgentActivation(data.input_ref.activation_id);
1714
+ if (activation?.binding && activation.state === "consumed" && !activation.quiesced) {
1715
+ const evidence = { settlementId: `${ledger.durableId}:${ledger.leafId}`, ownedInFlight: runtime.ownedInFlight ?? -1, reconciled: true };
1716
+ this.store.settleAgentActivation({ activationId: activation.id, expectedRevision: activation.revision, ...evidence }, activation.binding);
1717
+ // A read held in this activation becomes resumable only at its exact settle.
1718
+ for (const hold of this.store.unsettledArtifactHolds(activation.id)) {
1719
+ this.store.settleArtifactRead(hold.id, evidence, activation.binding);
1720
+ this.resumeArtifactReview(hold.id);
1721
+ }
1722
+ this.finalizeCollaboration(activation.id);
1723
+ }
1724
+ break;
1725
+ }
1726
+ case "collaboration_artifact_read": {
1727
+ const binding = this.store.agentBinding(conv);
1728
+ if (binding && binding.inputId === data.input_id) this.store.settleArtifactRead(data.input_ref.holdId,
1729
+ { settlementId: `${ledger.durableId}:${ledger.leafId}`, ownedInFlight: runtime.ownedInFlight, reconciled: true }, binding);
1730
+ break;
1731
+ }
1732
+ case "user": case "work_update": break;
1733
+ default: return collaborationNever(data);
1734
+ }
1735
+ }
1736
+ }
1226
1737
  try {
1227
1738
  if (this.store.mainConsumedSource(conv)?.origin !== "user" &&
1228
1739
  wake && !this.store.mainInputConsumed(wake.input)) {
@@ -1367,6 +1878,17 @@ export class Hub {
1367
1878
  return { images, note: `[첨부 이미지 ${names.length}장: ${names.map((n) => `${where}/${n}`).join(", ")}]` };
1368
1879
  }
1369
1880
 
1881
+ /**
1882
+ * Opens an agent conversation's next session now, after a rotation that asked for it, so a saved definition is applied
1883
+ * without a user message (ensureSession records agent.applied). Main never opens here: its rotation behaviour is unchanged.
1884
+ */
1885
+ async openAgentSession(convId: string): Promise<boolean> {
1886
+ const conv = this.store.getConversation(convId);
1887
+ if (!conv || conv.agentId === null) return false;
1888
+ await this.ensureSession(conv);
1889
+ return true;
1890
+ }
1891
+
1370
1892
  /**
1371
1893
  * Moves the conversation to a fresh omo session file. The chat history stays; the old session file stays on disk,
1372
1894
  * and the next prompt carries a short note with the recent exchange (the handoff) so the new session has context.
@@ -1922,13 +2444,20 @@ export class Hub {
1922
2444
  }
1923
2445
 
1924
2446
  /** Sends input once. Native same-session compaction owns overflow recovery, never gateway replay. */
1925
- private async prompt(convId: string, body: string, images: string[] = [], messageSeq?: number, admitted = false, attachmentSnapshot?: ReturnType<Hub["attachments"]>, protectedPayload = false): Promise<void> {
2447
+ private async prompt(convId: string, body: string, options: { readonly images?: string[]; readonly messageSeq?: number;
2448
+ readonly admitted?: boolean; readonly attachmentSnapshot?: ReturnType<Hub["attachments"]>; readonly protectedPayload?: boolean;
2449
+ readonly documents?: readonly AttachmentMetadata[];
2450
+ } & ({ readonly collaboration?: NonNullable<ReturnType<Store["nextAgentInput"]>>; readonly artifactRead?: never } |
2451
+ { readonly artifactRead: import("./security-allow.ts").ArtifactSecurityHold; readonly collaboration?: never }) = {}): Promise<void> {
2452
+ const { images = [], messageSeq, admitted = false, attachmentSnapshot, protectedPayload = false, documents = [], collaboration, artifactRead } = options;
2453
+ const authored = collaboration !== undefined || artifactRead !== undefined;
1926
2454
  this.requireVisibleAgent(convId);
1927
2455
  const conv = this.store.getConversation(convId);
1928
2456
  if (!conv) return;
1929
2457
  const rt = this.rt(convId);
1930
2458
  if (rt.rotating) return; // Accepted user intent remains definitely unsent until rotation finishes.
1931
2459
  const generation = this.generation;
2460
+ const run = rt.run;
1932
2461
  let input: StoredEvent | null = null;
1933
2462
  let nativeCommand = false;
1934
2463
  let sessionId: string | null = null;
@@ -1941,39 +2470,96 @@ export class Hub {
1941
2470
  try {
1942
2471
  ({ sessionId } = await this.ensureSession(conv));
1943
2472
  if (generation !== this.generation || this.omoStatus !== "ready") return;
2473
+ this.toolMigrationBeforeTurn(convId);
1944
2474
  const reference = messageSeq ?? this.store.lastSeq(convId, "message.user");
1945
2475
  // sendText and reconnect recovery can share admission; only one may cross the write boundary.
1946
2476
  if (admitted && this.store.mainUserDispatched(convId, reference)) return;
1947
2477
  const commandName = body.slice(1).split(" ", 1)[0];
1948
- nativeCommand = (body === "." && images.length === 0) ||
1949
- (body.startsWith("/") && (await this.commands(convId)).some((command) => command.name === commandName));
2478
+ nativeCommand = !authored && ((body === "." && images.length === 0) ||
2479
+ (body.startsWith("/") && (await this.commands(convId)).some((command) => command.name === commandName)));
1950
2480
  if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId) return;
1951
2481
  if (admitted && this.store.mainUserDispatched(convId, reference)) return;
1952
- const handoff = nativeCommand ? null : this.store.getSessionMeta(convId).handoff;
2482
+ const handoff = nativeCommand || authored ? null : this.store.getSessionMeta(convId).handoff;
1953
2483
  const attached = attachmentSnapshot ?? this.attachments(images);
1954
2484
  const text = [body, attached.note].filter(Boolean).join("\n\n");
1955
2485
  const context = handoff ? `${handoff}\n\nZGW_WORK_STATE ${JSON.stringify(this.workSummary(convId))}` : null;
1956
- const inputId = crypto.randomUUID();
2486
+ const inputId = collaboration?.activation.inputId ?? crypto.randomUUID();
1957
2487
  const message = context ? `${context}\n\n[이번 메시지]\n${text}` : text;
1958
2488
  const wire = nativeCommand || rt.clientInputIdentity ? message : `ZGW_MAIN_INPUT ${JSON.stringify({ input_id: inputId, message_seq: reference })}\n${message}`;
1959
2489
  const params: Record<string, unknown> = {
1960
2490
  sessionId, message: wire,
1961
2491
  streamingBehavior: "steer",
1962
- sessionTitlePrompt: body,
2492
+ ...(!authored ? { sessionTitlePrompt: body } : {}),
1963
2493
  ...(rt.clientInputIdentity ? { clientInputId: inputId } : {}),
1964
2494
  };
1965
2495
  if (attached.images.length) params.images = attached.images;
1966
- input = this.emit(convId, "main.input", {
2496
+ // Mac documents and their read binding belong to the owner's own input only; authored peer inputs never carry them.
2497
+ const incarnation = !authored && this.options.documents ? rt.documentIncarnation : undefined;
2498
+ if (!authored) {
2499
+ const native = documents.filter((item) => item.readMode === "native-document");
2500
+ if (native.length && this.options.documents && rt.clientInputIdentity) {
2501
+ try { Object.assign(params, this.options.documents.dispatchDocuments(convId, native)); }
2502
+ catch (error) { if (!(error instanceof AttachmentStoreError)) throw error; }
2503
+ }
2504
+ if (incarnation && sessionId) await this.announceDocumentInput(sessionId, incarnation, inputId);
2505
+ }
2506
+ if (authored) {
2507
+ const lifecycle = this.collaboration;
2508
+ if (!lifecycle) throw new CollaborationStoreError("collaboration_session_unavailable", 503);
2509
+ this.checkCollaborationText(body);
2510
+ const previousBinding = this.store.agentBinding(convId);
2511
+ if (!rt.clientInputIdentity) throw new CollaborationStoreError("client_input_identity_unavailable", 503);
2512
+ const context = collaboration ? { activationId: collaboration.activation.id,
2513
+ requestId: collaboration.activation.direction === "reply" ? collaboration.request.parentId : collaboration.request.id,
2514
+ rootId: collaboration.request.rootId } : { activationId: null, requestId: null, rootId: null };
2515
+ await lifecycle.bind(convId, inputId, { ...context,
2516
+ ...(artifactRead ? { read: { continuationId: artifactRead.continuation.id, arguments: artifactRead.readArguments } } : {}),
2517
+ claim: binding => {
2518
+ if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId ||
2519
+ rt.running || rt.run !== run || rt.admitting !== 1 || rt.rotating || this.maintenanceId) {
2520
+ throw new CollaborationStoreError("conversation_busy");
2521
+ }
2522
+ if (collaboration) {
2523
+ const { activation, request } = collaboration;
2524
+ input = this.store.admitAgentInput({ activationId: activation.id, activationRevision: activation.revision,
2525
+ requestRevision: request.revision, previousBinding, wireMessage: wire, nativeIdentity: rt.clientInputIdentity },
2526
+ binding, lifecycle.gate(request));
2527
+ } else if (artifactRead && previousBinding) {
2528
+ input = this.store.admitArtifactRead({ holdId: artifactRead.id, expectedRevision: artifactRead.revision,
2529
+ previousBinding, wireMessage: wire }, binding, lifecycle.artifactRead(artifactRead));
2530
+ } else throw new CollaborationStoreError("artifact_resume_conflict");
2531
+ this.publishStored(input);
2532
+ } });
2533
+ } else input = this.emit(convId, "main.input", {
1967
2534
  origin: "user", input_ref: { message_seq: reference },
1968
2535
  session_path: this.store.getConversation(convId)?.threadId, handoff,
1969
2536
  ...(!nativeCommand || rt.clientInputIdentity ? {
1970
2537
  input_id: inputId, wire_message: wire, native_identity: rt.clientInputIdentity,
1971
2538
  } : {}),
1972
2539
  });
2540
+ if (incarnation && input) this.options.documents?.bindings.setInput(convId, incarnation, inputId, input.seq);
1973
2541
  if (admitted) this.store.appendEvent(convId, "main.user.dispatched", { message_seq: reference, input_seq: input?.seq });
1974
- const receipt = await this.requireOmo().command<{ disposition?: string }>("prompt", params);
2542
+ if (!authored) {
2543
+ if (!nativeCommand && rt.clientInputIdentity) {
2544
+ // The owner's own message never depends on collaboration capability: a refused bind only leaves the tools unbound.
2545
+ try { await this.collaboration?.bind(convId, inputId); }
2546
+ catch (error) {
2547
+ if (!(error instanceof CollaborationStoreError) && !(error instanceof OmoCommandError)) throw error;
2548
+ this.collaboration?.retire(convId);
2549
+ this.emit(convId, "agent.collaboration.blocked", { code: error instanceof CollaborationStoreError ? error.code : "collaboration_native_unavailable" });
2550
+ }
2551
+ } else this.collaboration?.retire(convId);
2552
+ }
2553
+ const sent = this.requireOmo().command<{ disposition?: string }>("prompt", params);
2554
+ if (collaboration) testCrashPoint("collaboration.input.rpc-written");
2555
+ const receipt = await sent;
1975
2556
  if (input && generation === this.generation && this.omoStatus === "ready" && rt.sessionId === sessionId) {
1976
2557
  this.store.ackMainInput(input);
2558
+ if (collaboration) {
2559
+ const activation = this.store.getAgentActivation(collaboration.activation.id);
2560
+ if (activation?.binding) this.store.recordAgentActivationEvidence({ activationId: activation.id,
2561
+ expectedRevision: activation.revision, state: "acknowledged" }, activation.binding);
2562
+ }
1977
2563
  if (nativeCommand && receipt?.disposition === "handled") this.emit(convId, "main.input.handled", { input_seq: input.seq });
1978
2564
  if (!rt.running && receipt?.disposition === "queued") void this.reconcileMainUsers(convId, sessionId, generation);
1979
2565
  }
@@ -1989,11 +2575,35 @@ export class Hub {
1989
2575
  if (refused && input) this.emit(convId, "main.input.handled", { input_seq: input.seq, reason: "prompt_rejected" });
1990
2576
  if (protectedPayload) throw new SecurityRuntimeError("dispatch_unknown");
1991
2577
  if (retired) return;
2578
+ if (collaboration) {
2579
+ const activation = this.store.getAgentActivation(collaboration.activation.id);
2580
+ if (error instanceof CollaborationStoreError && ["target_forbidden", "lifecycle_conflict", "agent_access_blocked",
2581
+ "authority_epoch_mismatch", "collaboration_security_review_required", "version_conflict"].includes(error.code)) {
2582
+ this.store.rejectQueuedAgentInput(collaboration.activation.id, error.code);
2583
+ this.wakeCollaborationReplies();
2584
+ }
2585
+ if (activation?.binding && activation.state !== "queued") this.store.recordAgentActivationEvidence({
2586
+ activationId: activation.id, expectedRevision: activation.revision, state: "unknown" }, activation.binding);
2587
+ if (error instanceof CollaborationStoreError) this.emit(convId, "agent.collaboration.blocked", { code: error.code });
2588
+ else this.emit(convId, "agent.collaboration.blocked", { code: "dispatch_unknown" });
2589
+ return;
2590
+ }
2591
+ if (artifactRead) {
2592
+ const hold = this.store.artifactSecurityHold(artifactRead.id);
2593
+ if (hold?.continuation.state === "dispatched" && hold.continuation.targetBinding) this.store.recordArtifactDelivery({
2594
+ holdId: hold.id, dispatchReceiptId: hold.continuation.dispatchReceiptId,
2595
+ releaseReceiptId: hold.continuation.releaseReceiptId, state: "unknown" }, hold.continuation.targetBinding);
2596
+ this.emit(convId, "agent.collaboration.blocked", { code: error instanceof CollaborationStoreError ? error.code : "dispatch_unknown",
2597
+ holdId: artifactRead.id });
2598
+ return;
2599
+ }
1992
2600
  if (error instanceof AgentSessionError) return; // The definition failure was already persisted and broadcast.
1993
2601
  const message = errorMessage(error);
1994
2602
  this.emit(convId, "error", refused ? { kind: "compaction-required", message: say(COMPACTION_REQUIRED_MESSAGE) } : this.continuityError(message));
1995
2603
  } finally {
1996
2604
  releaseAdmission();
2605
+ // An owner input that superseded a peer execution ended that request and queued its requester's reply.
2606
+ if (!authored) { void this.dispatchCollaboration(convId); this.wakeCollaborationReplies(); }
1997
2607
  }
1998
2608
  }
1999
2609
 
@@ -2026,7 +2636,19 @@ export class Hub {
2026
2636
  if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
2027
2637
  const rt = this.rt(convId);
2028
2638
  if (!rt.sessionId || !rt.running) return;
2029
- await this.requireOmo().command("abort", { sessionId: rt.sessionId });
2639
+ // Stopping a run that executes a peer request is the owner cancelling that request: it is recorded before the abort,
2640
+ // so the aborted run settles once on the cancel path and the requester gets one result-less final reply.
2641
+ const activationId = this.store.agentBinding(convId)?.activationId ?? null;
2642
+ const executing = activationId !== null && (this.store.mainTurn(convId)?.inputs.some(input => {
2643
+ const data: MainInput = JSON.parse(JSON.stringify(input.data));
2644
+ return (data.origin === "agent_collaboration" || data.origin === "agent_tool_permission") && data.input_ref.activation_id === activationId;
2645
+ }) ?? false);
2646
+ const cancelled = executing ? this.store.cancelStoppedAgentExecution(convId, activationId, "owner_stopped") : null;
2647
+ if (cancelled) {
2648
+ this.wakeCollaborationReplies();
2649
+ // Aborts this run and any running execution of a child cancelled with it.
2650
+ await this.interruptCancelledCollaboration();
2651
+ } else await this.requireOmo().command("abort", { sessionId: rt.sessionId });
2030
2652
  this.dialogs.cancelScope(this.conversationScope(convId), say(HUB_TEXT.userStopped));
2031
2653
  }
2032
2654
 
@@ -2140,7 +2762,7 @@ export class Hub {
2140
2762
  return this.dialogs.answer(request.actionId, answer);
2141
2763
  }
2142
2764
 
2143
- onEvent(event: OmoEvent): void | Promise<void> {
2765
+ async onEvent(event: OmoEvent): Promise<void> {
2144
2766
  this.eventCount += 1;
2145
2767
  const sessionId = typeof event.sessionId === "string" ? event.sessionId : null;
2146
2768
  const conv = sessionId ? this.sessionToConv.get(sessionId) : undefined;
@@ -2210,7 +2832,20 @@ export class Hub {
2210
2832
  if (message?.role === "user") {
2211
2833
  const input = this.store.mainUserInput(conv, owner.sessionPath, contentText(message.content), message.clientInputId);
2212
2834
  if (input) {
2213
- const consumed = this.store.consumeMainInput(input);
2835
+ const data: MainInput = JSON.parse(JSON.stringify(input.data));
2836
+ let native: { entry: LedgerEntry; durableId: string } | undefined;
2837
+ switch (data.origin) {
2838
+ case "user": case "work_update": break;
2839
+ case "agent_collaboration": case "agent_tool_permission": case "collaboration_artifact_read": {
2840
+ const ledger = await this.currentLedger(conv, sessionId, this.generation);
2841
+ const entry = ledger && this.ledgerInputEntry(ledger, data);
2842
+ if (!ledger || !entry) return;
2843
+ native = { entry, durableId: ledger.durableId };
2844
+ break;
2845
+ }
2846
+ default: return collaborationNever(data);
2847
+ }
2848
+ const consumed = this.store.consumeMainInput(input, native);
2214
2849
  if (consumed) this.publishStored(consumed);
2215
2850
  this.startMainTurn(conv);
2216
2851
  }
@@ -2307,18 +2942,34 @@ export class Hub {
2307
2942
  return;
2308
2943
  }
2309
2944
  case "agent_settled": {
2945
+ const source = this.store.mainTurn(conv)?.source;
2946
+ if (source && source.origin !== "user" && source.origin !== "work_update") {
2947
+ const generation = this.generation, run = rt.run;
2948
+ const state = await this.requireOmo().command<{ isStreaming?: boolean; pendingMessageCount?: number }>("get_state", { sessionId });
2949
+ if (generation !== this.generation || rt.run !== run || rt.sessionId !== sessionId ||
2950
+ state.isStreaming !== false || state.pendingMessageCount !== 0) return;
2951
+ }
2952
+ this.collaboration?.retire(conv);
2310
2953
  rt.running = false;
2311
2954
  rt.statsTurnId = null;
2312
2955
  rt.statsMessageIndex = 0;
2313
2956
  this.publishActivity(conv);
2314
2957
  this.unwatchConversation(conv);
2315
2958
  this.dialogs.cancelScope(owner.scope, say(HUB_TEXT.turnEnded));
2959
+ this.toolMigrationIdle(conv);
2316
2960
  if (this.decisions) {
2961
+ const settledPath = this.store.getConversation(conv)?.threadId;
2317
2962
  void this.reconcileDecisionMain(conv)
2318
- .then(() => this.decisions?.settled(owner.scope))
2963
+ .then(() => { if (this.store.getConversation(conv)?.threadId === settledPath) this.decisions?.settled(owner.scope); })
2319
2964
  .catch((error) => console.error("main decision reconciliation:", error));
2320
2965
  }
2321
- const settled = this.settleMainTurn(conv, sessionId);
2966
+ this.settling.set(conv, (this.settling.get(conv) ?? 0) + 1);
2967
+ const settled = this.settleMainTurn(conv, sessionId).finally(() => {
2968
+ const remaining = (this.settling.get(conv) ?? 1) - 1;
2969
+ if (remaining) this.settling.set(conv, remaining);
2970
+ else this.settling.delete(conv);
2971
+ void this.dispatchCollaboration(conv);
2972
+ });
2322
2973
  return conv === "main" && this.modelPending
2323
2974
  ? Promise.all([settled, this.refreshPendingModel()]).then(() => undefined)
2324
2975
  : settled;
@@ -2328,6 +2979,8 @@ export class Hub {
2328
2979
  return;
2329
2980
  case "session_closed":
2330
2981
  case "session_parked":
2982
+ this.collaboration?.retire(conv);
2983
+ this.collaborationIncarnations.delete(conv);
2331
2984
  this.sessionToConv.delete(sessionId);
2332
2985
  this.releaseScopedUiOwner(owner);
2333
2986
  rt.sessionId = null;
@@ -2403,6 +3056,7 @@ export class Hub {
2403
3056
  ...(event.nativePermission ? { nativePermission: event.nativePermission } : {}) },
2404
3057
  deadlineAt: typeof event.timeout === "number" ? Date.now() + event.timeout : request.deadlineAt,
2405
3058
  });
3059
+ if (decision) this.linkCollaborationQuestion(owner.conv, decision.id);
2406
3060
  this.emit(owner.conv, "action.request", decision ? { ...request, decisionId: decision.id } : request);
2407
3061
  }
2408
3062
 
@@ -2419,6 +3073,19 @@ export class Hub {
2419
3073
  this.emit(owner.conv, "action.resolved", resolution);
2420
3074
  }
2421
3075
 
3076
+ /**
3077
+ * A question asked inside a collaboration execution holds that request (and its descendants) in waiting_owner until
3078
+ * the owner's real answer; independent requests keep running. A refused link is reported, never treated as an answer.
3079
+ */
3080
+ private linkCollaborationQuestion(conv: string, decisionId: string): void {
3081
+ try {
3082
+ if (this.store.linkCurrentAgentDecision(conv, decisionId)) this.emit(conv, "agent.collaboration.waiting_owner", { decisionId });
3083
+ } catch (error) {
3084
+ if (!(error instanceof CollaborationStoreError)) throw error;
3085
+ this.emit(conv, "agent.collaboration.blocked", { code: error.code, decisionId });
3086
+ }
3087
+ }
3088
+
2422
3089
  /** Shows an ask_user_question card once; a replay of a card already in the conversation history only re-arms it. */
2423
3090
  private openQuestion(owner: ScopedUiOwner, frame: ScopedQuestionFrame): void {
2424
3091
  const actionId = scopedActionId(owner.scope, frame.id);
@@ -2448,6 +3115,7 @@ export class Hub {
2448
3115
  ...(frame.waitForAnswer === false ? { waitForAnswer: false } : {}) },
2449
3116
  deadlineAt: frame.deadlineAtMs ?? null,
2450
3117
  });
3118
+ if (decision) this.linkCollaborationQuestion(owner.conv, decision.id);
2451
3119
  const open = this.openQuestionCards(owner.conv, owner.scope);
2452
3120
  if (open.has(frame.id)) this.legacyQuestionHistoryAliases.set(actionId, frame.id);
2453
3121
  if (!open.has(actionId) && !open.has(frame.id)) {
@@ -2461,6 +3129,23 @@ export class Hub {
2461
3129
  }
2462
3130
  }
2463
3131
 
3132
+ /**
3133
+ * Bug 236: a newer instruction was queued behind this work's turn. Each required question the turn waits on ends its
3134
+ * native wait (the queued instruction then runs at the tool boundary) and stays open in the inbox.
3135
+ */
3136
+ releaseScopedQuestions(owner: ScopedUiOwner): void {
3137
+ if (!this.isCurrentScopedUiOwner(owner)) return;
3138
+ for (const [actionId, card] of [...this.questions]) {
3139
+ if (card.scope !== owner.scope || card.targetSessionPath !== owner.sessionPath) continue;
3140
+ const decision = this.store.getDecisionBySource({ scope: owner.scope, sourceSessionPath: owner.sessionPath, sourceRequestId: card.sourceRequestId });
3141
+ if (!decision || !this.decisions?.releaseWorkWait(decision.id)) continue;
3142
+ this.questions.delete(actionId);
3143
+ this.legacyQuestionHistoryAliases.delete(actionId);
3144
+ this.releasedQuestions.add(actionId);
3145
+ this.omo?.send({ type: "extension_ui_response", id: card.sourceRequestId, sessionId: owner.sessionId, answers: {}, comment: WORK_QUESTION_RELEASED });
3146
+ }
3147
+ }
3148
+
2464
3149
  private answerQuestion(card: QuestionCard, data: ActionAnswerData): boolean {
2465
3150
  const owner = this.currentScopedUiOwner(card.scope, card.targetSessionPath);
2466
3151
  if (!owner || !this.omo) return false;
@@ -2596,6 +3281,22 @@ export class Hub {
2596
3281
  if (!ledger) throw new Error("session_history_unavailable");
2597
3282
  const currentTurn = this.store.mainTurn(conv.id);
2598
3283
  if (!currentTurn || currentTurn.seq !== turn.seq) return;
3284
+ switch (turn.source.origin) {
3285
+ case "agent_collaboration": case "agent_tool_permission": case "collaboration_artifact_read":
3286
+ // Reconcile exact evidence only. Recovery never creates a replacement peer input.
3287
+ for (const input of turn.inputs) {
3288
+ const data: MainInput = JSON.parse(JSON.stringify(input.data));
3289
+ const entry = this.ledgerInputEntry(ledger, data);
3290
+ if (!entry) continue;
3291
+ const consumed = this.store.consumeMainInput(input, { entry, durableId: ledger.durableId });
3292
+ if (consumed) this.publishStored(consumed);
3293
+ }
3294
+ // A native run that finished while no gateway was attached sends no later agent_end; settle it on the same evidence.
3295
+ if (!streaming) await this.settleMainTurn(conv.id, sessionId);
3296
+ return;
3297
+ case "user": case "work_update": break;
3298
+ default: return collaborationNever(turn.source);
3299
+ }
2599
3300
  const workInputs = turn.inputs.filter((input) => (input.data as MainInput).origin === "work_update");
2600
3301
  for (const input of workInputs) {
2601
3302
  const data = input.data as MainInput;
@@ -2688,7 +3389,7 @@ export class Hub {
2688
3389
  const lastUser = this.store.getEvent(turn.source.input_ref.message_seq)?.data as { text?: string; images?: string[] } | undefined;
2689
3390
  const message = promptReached || !lastUser?.text ? CONTINUE_PROMPT : lastUser.text;
2690
3391
  this.emit(conv.id, "notice", { kind: "auto-continue", level: "warning", message: say(HUB_TEXT.autoContinue, { attempt: tries + 1, limit: AUTO_CONTINUE_LIMIT, recovered: recoveredNote(recovered) }) });
2691
- await this.prompt(conv.id, message, promptReached ? [] : lastUser?.images ?? [], turn.source.input_ref.message_seq);
3392
+ await this.prompt(conv.id, message, { images: promptReached ? [] : lastUser?.images ?? [], messageSeq: turn.source.input_ref.message_seq });
2692
3393
  }
2693
3394
 
2694
3395
  private hasUnfinishedTurn(conv: string): boolean {
@@ -2812,7 +3513,12 @@ export class Hub {
2812
3513
  }
2813
3514
 
2814
3515
  private ledgerInputEntry(ledger: LedgerState, data: MainInput): LedgerEntry | null {
2815
- if (data.origin !== "user" || typeof data.input_id !== "string") return null;
3516
+ switch (data.origin) {
3517
+ case "work_update": return null;
3518
+ case "user": case "agent_collaboration": case "agent_tool_permission": case "collaboration_artifact_read": break;
3519
+ default: return collaborationNever(data);
3520
+ }
3521
+ if (typeof data.input_id !== "string" || data.session_path !== ledger.sessionPath) return null;
2816
3522
  if (data.native_identity) return this.store.ledgerUser(ledger.sessionPath, { clientInputId: data.input_id });
2817
3523
  return typeof data.wire_message === "string" ? this.store.ledgerUser(ledger.sessionPath, { text: data.wire_message }) : null;
2818
3524
  }
@@ -2893,6 +3599,8 @@ export class Hub {
2893
3599
  throw new AgentSessionError();
2894
3600
  }
2895
3601
  }
3602
+ const resumed = existsSync(sessionPath);
3603
+ const agentMode = conv.agentId ? this.store.toolPermissions.mode(conv.agentId) : null;
2896
3604
  const opened = this.requireOmo().command<{ sessionId: string; attached?: boolean; state?: {
2897
3605
  isStreaming?: boolean; pendingQuestions?: ScopedQuestionFrame[];
2898
3606
  clientInputIdentity?: boolean; atomicQueuedResume?: boolean;
@@ -2903,15 +3611,16 @@ export class Hub {
2903
3611
  ...(model ?? {}),
2904
3612
  ...(typeof agent?.fields.thinking === "string" ? { thinkingLevel: agent.fields.thinking } : {}),
2905
3613
  cwd,
2906
- permissionPreset: this.options.permissionPreset,
3614
+ permissionPreset: agentSessionPreset(this.options.permissionPreset, agentMode),
2907
3615
  retain_on_disconnect: true,
2908
3616
  kind: "interactive",
2909
3617
  promptSurface: "app",
2910
3618
  context: conv.agentId
2911
- ? { zgw_conv: conv.id, zgw_role: "agent", zgw_agent: conv.agentId }
3619
+ // C6: the session's adapter learns that the agent authority (not a native preset) governs it.
3620
+ ? { zgw_conv: conv.id, zgw_role: "agent", zgw_agent: conv.agentId, ...(agentMode === "managed" ? { zgw_tool_authority: "managed" } : {}) }
2912
3621
  : { zgw_conv: conv.id, zgw_role: "main" },
2913
3622
  });
2914
- return { sessionPath, opened, agent };
3623
+ return { sessionPath, opened, agent, resumed };
2915
3624
  }
2916
3625
 
2917
3626
  private watchAsk(conv: string, toolCallId: string, args: unknown): void {
@@ -2964,12 +3673,13 @@ export class Hub {
2964
3673
  if (rt.sessionId) return { sessionId: rt.sessionId, streaming: rt.running };
2965
3674
  if (rt.opening) return rt.opening;
2966
3675
  const generation = this.generation;
2967
- const { sessionPath, opened, agent } = this.openSession(conv);
3676
+ const { sessionPath, opened, agent, resumed } = this.openSession(conv);
2968
3677
  const opening = opened
2969
3678
  .then(async (data) => {
2970
3679
  if (generation !== this.generation || this.omoStatus !== "ready") throw new Error("stale_open_session");
2971
3680
  rt.sessionId = data.sessionId;
2972
3681
  rt.durableId = typeof data.state?.sessionId === "string" ? data.state.sessionId : null;
3682
+ this.collaborationIncarnations.set(conv.id, crypto.randomUUID());
2973
3683
  rt.clientInputIdentity = data.state?.clientInputIdentity === true;
2974
3684
  rt.atomicQueuedResume = data.state?.atomicQueuedResume === true;
2975
3685
  this.sessionToConv.set(data.sessionId, conv.id);
@@ -2980,19 +3690,65 @@ export class Hub {
2980
3690
  }
2981
3691
  const owner = { conv: conv.id, scope: this.conversationScope(conv.id), sessionId: data.sessionId, sessionPath };
2982
3692
  if (!this.bindScopedUiOwner(owner)) throw new Error("omo_owner_not_ready");
2983
- if (agent && data.attached !== true) {
2984
- // Notification policy is a session snapshot too; edits must not change a live session.
2985
- this.emit(conv.id, "agent.applied", { sha: agent.sha, at: new Date(this.now()).toISOString(), notify: agent.fields.notify === true });
3693
+ await this.bindDocuments(conv, data.sessionId, sessionPath, rt, generation);
3694
+ await this.bindAgentTools(conv, data.sessionId, sessionPath, rt, generation);
3695
+ if (agent) {
3696
+ const rotated = this.store.lastSeq(conv.id, "session.rotated");
2986
3697
  let warnings: { code: string; message: string }[] = [];
3698
+ let appliedSha: string | undefined;
2987
3699
  try {
2988
- const result = await this.requireOmo().command<{ warnings?: { code: string; message: string }[] } | null>(
3700
+ const result = await this.requireOmo().command<{ warnings?: { code: string; message: string }[]; appliedSha?: unknown } | null>(
2989
3701
  "extension_request", { sessionId: data.sessionId, name: "zgw.agent_warnings" });
3702
+ if (typeof result?.appliedSha === "string") appliedSha = result.appliedSha;
2990
3703
  if (Array.isArray(result?.warnings)) warnings = result.warnings;
2991
3704
  else console.error(`agent startup warnings failed (${conv.id}): invalid warnings response`);
2992
3705
  } catch (error) {
2993
3706
  console.error(`agent startup warnings failed (${conv.id}): ${errorMessage(error)}`);
2994
3707
  }
2995
- for (const warning of warnings) this.emit(conv.id, "notice", { kind: "agent-warning", level: "warning", ...warning });
3708
+ // A report belongs to the session that requested it, even if a native handle is reused.
3709
+ if (generation !== this.generation || rt.sessionId !== data.sessionId ||
3710
+ rotated !== this.store.lastSeq(conv.id, "session.rotated")) throw new Error("stale_open_session");
3711
+ const last = this.store.eventsAfter(conv.id, this.store.lastSeq(conv.id, "session.rotated"))
3712
+ .filter(event => event.type === "agent.applied").at(-1)?.data;
3713
+ const previous = typeof last === "object" && last !== null ? last : undefined;
3714
+ let snapshot: Record<string, unknown> | undefined;
3715
+ if (data.attached !== true && !resumed) {
3716
+ // Only a new file uses creation defaults. Old extensions retain the open-time fallback.
3717
+ snapshot = appliedSha === undefined || appliedSha === agent.sha
3718
+ ? { sha: agent.sha, model: agent.fields.model ?? null, thinking: agent.fields.thinking ?? null }
3719
+ : { sha: null, mixed: true };
3720
+ } else if (appliedSha !== undefined) {
3721
+ if (data.attached === true) {
3722
+ // A retained native session never reloads its creation controls.
3723
+ if (previous && "sha" in previous && previous.sha !== appliedSha) snapshot = { sha: null, mixed: true };
3724
+ } else if (!previous || !("sha" in previous) || previous.sha !== appliedSha) {
3725
+ // Cold resume reloads the extension, but keeps the model/thinking in the file.
3726
+ let loaded = agent;
3727
+ if (loaded.sha !== appliedSha && conv.agentId) {
3728
+ try {
3729
+ const files = this.options.agents ?? new AgentFileStore();
3730
+ loaded = mergeAgentDefinition(files.read(conv.agentId), files.readDefaults());
3731
+ } catch {
3732
+ // A concurrent invalid save cannot prove which definition the extension loaded.
3733
+ console.error(`agent resumed definition unavailable (${conv.id})`);
3734
+ }
3735
+ }
3736
+ snapshot = loaded.sha === appliedSha && previous && "model" in previous && "thinking" in previous &&
3737
+ isDeepStrictEqual(previous.model, loaded.fields.model ?? null) && previous.thinking === (loaded.fields.thinking ?? null)
3738
+ ? { sha: appliedSha, model: loaded.fields.model ?? null, thinking: loaded.fields.thinking ?? null }
3739
+ : { sha: null, mixed: true };
3740
+ }
3741
+ }
3742
+ if (snapshot) {
3743
+ // Reattachment does not change the live notification policy.
3744
+ const notify = data.attached === true
3745
+ ? previous !== undefined && "notify" in previous && previous.notify === true
3746
+ : agent.fields.notify === true;
3747
+ this.emit(conv.id, "agent.applied", { ...snapshot, at: new Date(this.now()).toISOString(), notify });
3748
+ }
3749
+ if (data.attached !== true) {
3750
+ for (const warning of warnings) this.emit(conv.id, "notice", { kind: "agent-warning", level: "warning", ...warning });
3751
+ }
2996
3752
  }
2997
3753
  this.syncScopedQuestions(owner, data.state?.pendingQuestions ?? []);
2998
3754
  this.publishActivity(conv.id);
@@ -3024,6 +3780,84 @@ export class Hub {
3024
3780
  return rt;
3025
3781
  }
3026
3782
 
3783
+ /** 294: host-only engine commands for the conversation's current session; null while it has none. */
3784
+ agentToolEngine(convId: string): ((type: string, params: Record<string, unknown>) => Promise<unknown>) | null {
3785
+ const sessionId = this.runtime.get(convId)?.sessionId;
3786
+ if (!sessionId || this.omoStatus !== "ready") return null;
3787
+ return (type, params) => this.requireOmo().command(type, { ...params, sessionId });
3788
+ }
3789
+
3790
+ /**
3791
+ * 294 item 11: the migration card of a legacy agent, built from its current definition (C9). The same open card is
3792
+ * returned again; a managed agent (new, clone, or already switched) gets null. Creating the card grants nothing and
3793
+ * never blocks or restarts a running turn.
3794
+ */
3795
+ ensureToolMigration(agentId: string, files: AgentFileStore = this.options.agents ?? new AgentFileStore()): AgentToolMigrationCard | null {
3796
+ const ledger = this.store.toolPermissions;
3797
+ const current = ledger.currentMigration(agentId);
3798
+ if (current && current.state !== "applied") return current;
3799
+ if (ledger.mode(agentId) === "managed") return null;
3800
+ const conv = this.store.listConversations(true).find((row) => row.agentId === agentId);
3801
+ if (!conv) return null;
3802
+ const merged = mergeAgentDefinition(files.read(agentId), files.readDefaults());
3803
+ const card = this.store.createAgentToolMigration(agentId, conv.id, legacyToolScopes(merged, join(files.root, agentId, "knowledge")));
3804
+ this.decisions?.refresh();
3805
+ return card;
3806
+ }
3807
+
3808
+ /**
3809
+ * 294 item 11: an idle conversation about to start a turn is also an idle boundary, so a card answered while the agent
3810
+ * was idle applies to the next turn. A prompt steered into a running turn switches nothing; that turn ends legacy.
3811
+ */
3812
+ toolMigrationBeforeTurn(convId: string): void {
3813
+ if (!this.rt(convId).running) this.toolMigrationIdle(convId);
3814
+ }
3815
+
3816
+ /** 294 item 11: the idle boundary after a turn. An answered migration card switches its agent to managed here only. */
3817
+ toolMigrationIdle(convId: string): void {
3818
+ const agentId = this.store.getConversation(convId)?.agentId;
3819
+ if (!agentId) return;
3820
+ try {
3821
+ if (this.store.toolPermissions.applyMigrationAtIdle(agentId)) this.decisions?.refresh();
3822
+ } catch (error) {
3823
+ console.error(`agent tool migration switch failed (${convId}): ${errorMessage(error)}`);
3824
+ }
3825
+ }
3826
+
3827
+ /** 294: rotates the agent session's internal-route secret on every native open; a failed bind leaves only refused calls. */
3828
+ private async bindAgentTools(conv: ConversationInfo, sessionId: string, sessionPath: string, rt: ConvRuntime, generation: number): Promise<void> {
3829
+ if (conv.agentId) {
3830
+ try {
3831
+ this.ensureToolMigration(conv.agentId);
3832
+ } catch (error) {
3833
+ console.error(`agent tool migration card failed (${conv.id}): ${errorMessage(error)}`);
3834
+ }
3835
+ }
3836
+ const agentTools = this.options.agentTools;
3837
+ if (!agentTools || !conv.agentId || !rt.durableId) return;
3838
+ const issued = agentTools.bindings.bind({
3839
+ role: "agent", agentId: conv.agentId, convId: conv.id, convEpoch: 1, lifecycleEpoch: this.store.toolPermissions.policyGrants(conv.agentId).view.lifecycleEpoch,
3840
+ sessionPath, durableSessionId: rt.durableId,
3841
+ }, () => generation === this.generation && rt.sessionId === sessionId);
3842
+ try {
3843
+ await this.requireOmo().command("extension_request", { sessionId, name: "zgw.agent_tool_bind", data: { secret: issued.secret, identity: issued.identity } });
3844
+ } catch (error) {
3845
+ agentTools.bindings.retire(conv.id);
3846
+ console.error(`agent tool bind failed (${conv.id}): ${errorMessage(error)}`);
3847
+ return;
3848
+ }
3849
+ // D2: the authority is in place before the first turn, so an ungranted call is held for consent, not refused.
3850
+ try {
3851
+ const omo = this.requireOmo();
3852
+ const pushed = await pushSessionPolicy({ ledger: this.store.toolPermissions, engine: engineCommands((type, params) => omo.command(type, { ...params, sessionId })) },
3853
+ { agentId: conv.agentId, durableSessionId: rt.durableId });
3854
+ if (pushed.state === "policy_unacknowledged") console.error(`agent tool policy not acknowledged (${conv.id}): revision ${pushed.revision}`);
3855
+ } catch (error) {
3856
+ console.error(`agent tool policy push failed (${conv.id}): ${errorMessage(error)}`);
3857
+ }
3858
+ agentTools.ready?.(conv.id);
3859
+ }
3860
+
3027
3861
  private nextId(prefix: string): string {
3028
3862
  return `${prefix}${Date.now().toString(36)}${(this.counter++).toString(36)}`;
3029
3863
  }
@@ -3062,7 +3896,11 @@ export class Hub {
3062
3896
  console.error(`transcript append failed (${conv}): ${errorMessage(error)}`);
3063
3897
  }
3064
3898
  }
3065
- this.broadcast({ v: PROTOCOL_VERSION, id: this.nextId("e"), ts: stored.ts, conv, type, data, seq: stored.seq });
3899
+ this.broadcast({ v: PROTOCOL_VERSION, id: this.nextId("e"), ts: stored.ts, conv, type, data: this.withAttachmentState(stored).data, seq: stored.seq });
3900
+ }
3901
+
3902
+ private withAttachmentState(stored: StoredEvent): StoredEvent {
3903
+ return stored.type === "message.user" ? { ...stored, data: this.store.attachments.withGrantState(stored.data) } : stored;
3066
3904
  }
3067
3905
  }
3068
3906
 
@@ -3143,12 +3981,228 @@ function turnFinished(entries: readonly LedgerEntry[]): boolean {
3143
3981
  return last?.role === "assistant" && last.stopReason === "stop";
3144
3982
  }
3145
3983
 
3984
+ function turnAborted(entries: readonly LedgerEntry[]): boolean {
3985
+ const last = entries.filter((entry) => entry.type === "message").at(-1);
3986
+ return last?.role === "assistant" && last.stopReason === "aborted";
3987
+ }
3988
+
3146
3989
  /** The sentence added to a recovery notice for answers the gateway filled in while it was away (exported for tests). */
3147
3990
  export function recoveredNote(count: number): string {
3148
3991
  if (count <= 0) return "";
3149
3992
  return count === 1 ? say(HUB_TEXT.recoveredOne) : say(HUB_TEXT.recoveredMany, { count });
3150
3993
  }
3151
3994
 
3995
+ /**
3996
+ * 294 C6: a registered agent under managed tool permissions has one authority, the gateway's agent authority, which runs
3997
+ * before every native hook. Its session therefore opens full-access, so an owner-allowed stored call is not asked a second
3998
+ * time by a native preset prompt nobody answers. Main, work and legacy agents keep the gateway preset.
3999
+ */
4000
+ export function agentSessionPreset(gatewayPreset: string, agentMode: "legacy" | "managed" | null): string {
4001
+ return agentMode === "managed" ? "full-access" : gatewayPreset;
4002
+ }
4003
+
3152
4004
  function errorMessage(error: unknown): string {
3153
4005
  return error instanceof Error ? error.message : String(error);
3154
4006
  }
4007
+
4008
+ export const AGENT_TOOL_INTERNAL_ROUTES: Readonly<Record<string, { method: "GET" | "POST"; operation: string }>> = {
4009
+ "/internal/agent-tools/request": { method: "POST", operation: "agent_tool.request" },
4010
+ "/internal/agent-tools/action": { method: "POST", operation: "agent_tool.action" },
4011
+ "/internal/agent-tools/reserve": { method: "POST", operation: "agent_tool.reserve" },
4012
+ "/internal/agent-tools/start": { method: "POST", operation: "agent_tool.start" },
4013
+ "/internal/agent-tools/result": { method: "POST", operation: "agent_tool.result" },
4014
+ "/internal/agent-tools/policy": { method: "GET", operation: "agent_tool.policy" },
4015
+ "/internal/agent-tools/policy-ack": { method: "POST", operation: "agent_tool.policyAck" },
4016
+ };
4017
+
4018
+ interface AgentToolLive { readonly session: ManagedSession; readonly incarnation: string; readonly secret: KeyObject; readonly live: () => boolean; readonly nonces: Set<string> }
4019
+
4020
+ /**
4021
+ * 294 custody of the agent session secrets for /internal/agent-tools/*. Each bind rotates the secret; a call is accepted
4022
+ * only for an agent role, the live incarnation of this gateway generation, and a logical call id tied to its tool call.
4023
+ */
4024
+ export class AgentToolBindings {
4025
+ readonly generation = randomUUID();
4026
+ private readonly byConv = new Map<string, AgentToolLive>();
4027
+ bind(session: ManagedSession, live: () => boolean): { secret: string; identity: Omit<ManagedCallIdentity, "inputId" | "toolCallId" | "logicalCallId" | "activationId"> } {
4028
+ const raw = randomBytes(32), incarnation = randomUUID();
4029
+ this.byConv.set(session.convId, { session, incarnation, secret: createSecretKey(raw), live, nonces: new Set() });
4030
+ return { secret: raw.toString("hex"), identity: { ...session, sessionIncarnation: incarnation, gatewayGeneration: this.generation } };
4031
+ }
4032
+ retire(convId: string): void { this.byConv.delete(convId); }
4033
+ readonly resolver: ManagedCallBindingResolver = (identity): ManagedCallBinding | null => {
4034
+ const record = this.byConv.get(identity.convId);
4035
+ if (!record || !record.live() || record.session.role !== "agent" || record.session.agentId === null ||
4036
+ identity.logicalCallId !== `agent-tool:${identity.toolCallId}`) return null;
4037
+ return {
4038
+ identity: { ...record.session, sessionIncarnation: record.incarnation, gatewayGeneration: this.generation,
4039
+ inputId: identity.inputId, toolCallId: identity.toolCallId, logicalCallId: identity.logicalCallId, activationId: identity.activationId },
4040
+ secret: record.secret,
4041
+ claimNonce: (nonce) => {
4042
+ if (record.nonces.has(nonce) || record.nonces.size >= 65_536) return false;
4043
+ record.nonces.add(nonce);
4044
+ return true;
4045
+ },
4046
+ };
4047
+ };
4048
+ }
4049
+
4050
+ const agentToolRefuse = (error: string, status: number) => Response.json({ error }, { status, headers: { "Cache-Control": "no-store" } });
4051
+ const agentToolText = (value: unknown): value is string => typeof value === "string" && value.length > 0;
4052
+ const agentToolInt = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
4053
+ const agentToolRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
4054
+ const agentToolLabel = (value: unknown): value is string => typeof value === "string" && value.length <= 200;
4055
+ export type AgentToolActionScope = { operation: "click" | "fill"; args: Record<string, unknown>; scope: Record<string, string> };
4056
+
4057
+ /**
4058
+ * C8: the card scope of a click/fill the session prepared on a granted target. The owner sees the element and the exact
4059
+ * value; the target handle stays opaque. Anything malformed is refused, never shown partly. The scope kind follows the session's
4060
+ * own target: a work tab the session opened is `owned_target` (delivery never looks it up in the gateway registry, which holds
4061
+ * only borrowed tabs); a tab the owner designated is `browser_target` and still needs its gateway-bound handle.
4062
+ */
4063
+ export function agentToolActionScope(input: Record<string, unknown>): AgentToolActionScope {
4064
+ const { operation, args, target, element } = input;
4065
+ if ((operation !== "click" && operation !== "fill") || !agentToolRecord(args) || args.operation !== operation || !agentToolText(args.targetHandle) ||
4066
+ !agentToolRecord(target) || !agentToolText(target.origin) || target.origin.length > 2048 || (target.kind !== "owned" && target.kind !== "borrowed") || !agentToolRecord(element) ||
4067
+ !agentToolLabel(element.tag) || !agentToolLabel(element.role) || !agentToolLabel(element.name) || (operation === "fill") !== agentToolText(args.text)) {
4068
+ throw new AgentToolPermissionError("tool_request_invalid");
4069
+ }
4070
+ const label = `${element.tag}${element.role ? ` [${element.role}]` : ""}${element.name ? ` "${element.name}"` : ""}`;
4071
+ return { operation, args, scope: { kind: target.kind === "owned" ? "owned_target" : "browser_target", targetHandle: args.targetHandle, targetLabel: target.origin, element: label, ...(operation === "fill" ? { value: args.text as string } : {}) } };
4072
+ }
4073
+
4074
+ /**
4075
+ * 294 C3 internal routes. Listener and bearer first, then the signed session identity (shared managed-call verifier).
4076
+ * The agent, conversation and session come from the verified identity only; body agent names are never read.
4077
+ * Policy ACKs are kept per durable session in memory: a gateway restart means the engine must ACK again.
4078
+ */
4079
+ export class AgentToolInternalApi {
4080
+ constructor(private readonly d: {
4081
+ readonly store: Store;
4082
+ readonly resolver: ManagedCallBindingResolver;
4083
+ readonly omoToken: string;
4084
+ readonly changed: () => void;
4085
+ readonly policy: (view: AgentAccessView, grants: readonly AgentToolGrantRow[]) => unknown;
4086
+ }) {}
4087
+
4088
+ static route(pathname: string): boolean { return Object.hasOwn(AGENT_TOOL_INTERNAL_ROUTES, pathname); }
4089
+
4090
+
4091
+ async handle(req: Request, trusted: boolean): Promise<Response> {
4092
+ const denied = maintenanceAuth(req, trusted, this.d.omoToken);
4093
+ if (denied) return denied;
4094
+ const route = new URL(req.url).pathname;
4095
+ const spec = AGENT_TOOL_INTERNAL_ROUTES[route];
4096
+ if (!spec) return agentToolRefuse("not_found", 404);
4097
+ if (req.method !== spec.method) return agentToolRefuse("method_not_allowed", 405);
4098
+ const body = new Uint8Array(await req.arrayBuffer());
4099
+ if (body.byteLength > 64 * 1024) return agentToolRefuse("tool_request_too_large", 413);
4100
+ let call: VerifiedManagedCall;
4101
+ try {
4102
+ let proof: unknown;
4103
+ try { proof = JSON.parse(Buffer.from(req.headers.get("x-zigmini-managed-call") ?? "", "base64").toString("utf8")); }
4104
+ catch { throw new ManagedCallError("invalid_proof"); }
4105
+ call = verifyManagedCall({ method: spec.method, route, operation: spec.operation, body, proof }, this.d.resolver);
4106
+ } catch (error) {
4107
+ if (error instanceof ManagedCallError) return agentToolRefuse(error.code, error.code === "invalid_proof" ? 401 : 403);
4108
+ throw error;
4109
+ }
4110
+ if (call.role !== "agent" || call.agentId === null) return agentToolRefuse("agent_role_required", 403);
4111
+ let input: Record<string, unknown> = {};
4112
+ if (spec.method === "POST") {
4113
+ const parsed: unknown = (() => { try { return JSON.parse(new TextDecoder().decode(body)); } catch { return null; } })();
4114
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return agentToolRefuse("tool_request_invalid", 400);
4115
+ input = parsed as Record<string, unknown>;
4116
+ }
4117
+ try {
4118
+ return Response.json(this.run(route, call, call.agentId, input), { headers: { "Cache-Control": "no-store" } });
4119
+ } catch (error) {
4120
+ if (error instanceof AgentToolPermissionError) return agentToolRefuse(error.code, error.status);
4121
+ if (error instanceof CollaborationStoreError) return agentToolRefuse(error.code, error.status);
4122
+ throw error;
4123
+ }
4124
+ }
4125
+
4126
+ private owned(agentId: string, invocationId: unknown): string {
4127
+ if (!agentToolText(invocationId)) throw new AgentToolPermissionError("tool_request_invalid");
4128
+ const invocation = this.d.store.toolPermissions.invocation(invocationId);
4129
+ if (!invocation || invocation.agentId !== agentId) throw new AgentToolPermissionError("invocation_not_found");
4130
+ return invocationId;
4131
+ }
4132
+
4133
+ private policyOf(agentId: string): { revision: number; policy: unknown } {
4134
+ const { view, grants } = this.d.store.toolPermissions.policyGrants(agentId);
4135
+ // The engine requires revision >= 1, so an agent with no permission change yet is revision 1 on both sides.
4136
+ return { revision: Math.max(view.policyRevision, 1), policy: this.d.policy(view, grants) };
4137
+ }
4138
+
4139
+ private run(route: string, call: VerifiedManagedCall, agentId: string, input: Record<string, unknown>): unknown {
4140
+ const ledger = this.d.store.toolPermissions;
4141
+ switch (route) {
4142
+ case "/internal/agent-tools/request": {
4143
+ if (!agentToolText(input.operation) || typeof input.toolKey !== "object" || input.toolKey === null) {
4144
+ throw new AgentToolPermissionError("tool_request_invalid");
4145
+ }
4146
+ // Q-07: a call signed with a collaboration activation must name its request (and only such a call may).
4147
+ const activationId = call.activationId, collaborationRequestId = input.collaborationRequestId;
4148
+ if ((activationId !== null) !== agentToolText(collaborationRequestId)) throw new AgentToolPermissionError("tool_request_invalid");
4149
+ const card = {
4150
+ agentId, convId: call.convId,
4151
+ // The engine held this call under agent-op:<toolCallId>; delivery executes exactly that operation.
4152
+ durableSessionId: call.durableSessionId, logicalCallId: agentToolOperationId(call.toolCallId), sourceToolCallId: call.toolCallId,
4153
+ toolKey: input.toolKey as AgentToolRequestInput["toolKey"], operation: input.operation, args: input.args ?? null, scope: input.scope ?? null,
4154
+ };
4155
+ let request: AgentToolRequestRecord;
4156
+ // A collaboration card's original input is its execution's input (the Store reads it from the bound activation) and the
4157
+ // card is linked to that request in the same transaction (the request then waits for the owner).
4158
+ if (activationId !== null && agentToolText(collaborationRequestId)) request = this.d.store.createCollaborationToolRequest({ ...card, originKind: "agent_collaboration", collaborationRequestId, activationId });
4159
+ else {
4160
+ // C3: an owner card's original input comes from the trusted conversation, never from the body: its latest accepted user message.
4161
+ const originInputSeq = this.d.store.lastSeq(call.convId, "message.user");
4162
+ if (!agentToolInt(originInputSeq)) throw new AgentToolPermissionError("tool_request_invalid");
4163
+ request = this.d.store.createAgentToolRequest({ ...card, originInputSeq, originKind: "user", collaborationRequestId: null, activationId: null });
4164
+ }
4165
+ this.d.changed();
4166
+ return { state: "permission_pending", requestId: request.id, decisionId: request.decisionId };
4167
+ }
4168
+ case "/internal/agent-tools/action": {
4169
+ // C8: one agent_tool_action card per click/fill call; the session recorded the call as agent-action:<toolCallId>.
4170
+ const { operation, args, scope } = agentToolActionScope(input);
4171
+ if (typeof input.toolKey !== "object" || input.toolKey === null) throw new AgentToolPermissionError("tool_request_invalid");
4172
+ const originInputSeq = this.d.store.lastSeq(call.convId, "message.user");
4173
+ if (!agentToolInt(originInputSeq)) throw new AgentToolPermissionError("tool_request_invalid");
4174
+ const request = this.d.store.createAgentToolAction({
4175
+ agentId, convId: call.convId, originInputSeq, originKind: "user", durableSessionId: call.durableSessionId,
4176
+ logicalCallId: agentToolActionId(call.toolCallId), sourceToolCallId: call.toolCallId,
4177
+ toolKey: input.toolKey as AgentToolRequestInput["toolKey"], operation: ASIDE_OPERATIONS[operation]!, args, scope,
4178
+ });
4179
+ this.d.changed();
4180
+ return { state: "action_confirmation_pending", requestId: request.id, decisionId: request.decisionId };
4181
+ }
4182
+ case "/internal/agent-tools/reserve":
4183
+ if (!agentToolInt(input.executorGeneration)) throw new AgentToolPermissionError("tool_request_invalid");
4184
+ return ledger.reserve(this.owned(agentId, input.invocationId), input.executorGeneration);
4185
+ case "/internal/agent-tools/start": {
4186
+ if (!agentToolInt(input.executorGeneration)) throw new AgentToolPermissionError("tool_request_invalid");
4187
+ // The ledger checks the persisted ACK of the current revision in the start transaction (C5).
4188
+ return ledger.start(this.owned(agentId, input.invocationId), input.executorGeneration, { durableSessionId: call.durableSessionId });
4189
+ }
4190
+ case "/internal/agent-tools/result": {
4191
+ const state = input.state;
4192
+ if (!agentToolInt(input.executorGeneration) || !agentToolText(input.resultRef) ||
4193
+ (state !== "succeeded" && state !== "failed" && state !== "outcome_unknown")) throw new AgentToolPermissionError("tool_request_invalid");
4194
+ return ledger.result(this.owned(agentId, input.invocationId), input.executorGeneration, { state, resultRef: input.resultRef });
4195
+ }
4196
+ case "/internal/agent-tools/policy": {
4197
+ // The engine matches by name; `operations` lets an adapter (zgw_aside) refuse an operation the grant does not name.
4198
+ const { view, grants } = ledger.policyGrants(agentId);
4199
+ return { ...(this.policyOf(agentId).policy as object), operations: grantedOperations(view, grants) };
4200
+ }
4201
+ case "/internal/agent-tools/policy-ack": {
4202
+ if (!agentToolInt(input.revision)) throw new AgentToolPermissionError("tool_request_invalid");
4203
+ return ledger.ackPolicy(call.durableSessionId, agentId, input.revision);
4204
+ }
4205
+ }
4206
+ throw new AgentToolPermissionError("tool_request_invalid");
4207
+ }
4208
+ }