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

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 (141) hide show
  1. package/README.ko.md +36 -3
  2. package/README.md +36 -3
  3. package/assets/agent/gateway-request-rules.md +1 -1
  4. package/assets/agent/gateway-rules.md +6 -1
  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 +120 -11
  12. package/docs/manual.md +123 -12
  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-chatgpt-terra.patch +21 -0
  38. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +44 -6
  39. package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
  40. package/package.json +5 -3
  41. package/src/agent/api.ts +345 -20
  42. package/src/agent/artifacts.ts +196 -0
  43. package/src/agent/aside-target-routes.ts +369 -0
  44. package/src/agent/aside-targets.ts +215 -0
  45. package/src/agent/collaboration-admission.ts +47 -0
  46. package/src/agent/collaboration-read.ts +117 -0
  47. package/src/agent/collaboration-session.ts +196 -0
  48. package/src/agent/collaboration-types.ts +258 -0
  49. package/src/agent/collaboration.ts +399 -0
  50. package/src/agent/definition.ts +65 -3
  51. package/src/agent/fields.ts +9 -3
  52. package/src/agent/file-core.ts +325 -0
  53. package/src/agent/files.ts +87 -8
  54. package/src/agent/list.ts +3 -3
  55. package/src/agent/managed-call.ts +144 -0
  56. package/src/agent/tool-catalog.ts +184 -0
  57. package/src/agent/tool-delivery.ts +359 -0
  58. package/src/agent/tool-permission-types.ts +81 -0
  59. package/src/agent/tool-permissions.ts +1366 -0
  60. package/src/attachments/api.ts +161 -0
  61. package/src/attachments/document-reader.ts +325 -0
  62. package/src/attachments/host-selector.ts +410 -0
  63. package/src/attachments/managed-binding.ts +83 -0
  64. package/src/attachments/prepare.ts +214 -0
  65. package/src/attachments/selector-port.ts +28 -0
  66. package/src/attachments/service.ts +729 -0
  67. package/src/attachments/store.ts +698 -0
  68. package/src/attachments/types.ts +123 -0
  69. package/src/cli/doctor.ts +30 -10
  70. package/src/decisions.ts +109 -10
  71. package/src/engine/apply-patch.ts +21 -0
  72. package/src/engine/compat.json +2 -1
  73. package/src/engine/install.ts +45 -4
  74. package/src/engine/sandbox-cell.ts +2 -0
  75. package/src/gc.ts +33 -225
  76. package/src/housekeeping/api.ts +163 -0
  77. package/src/housekeeping/audit.ts +61 -0
  78. package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
  79. package/src/housekeeping/collectors/agent-drafts.ts +43 -0
  80. package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
  81. package/src/housekeeping/collectors/agent-trash.ts +28 -0
  82. package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
  83. package/src/housekeeping/collectors/db-backup.ts +25 -0
  84. package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
  85. package/src/housekeeping/collectors/docker-image.ts +135 -0
  86. package/src/housekeeping/collectors/omo-version.ts +32 -0
  87. package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
  88. package/src/housekeeping/collectors/reconcile-work.ts +55 -0
  89. package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
  90. package/src/housekeeping/collectors/shared.ts +149 -0
  91. package/src/housekeeping/collectors/temp-dir.ts +28 -0
  92. package/src/housekeeping/collectors/update-file.ts +25 -0
  93. package/src/housekeeping/collectors/worktree.ts +193 -0
  94. package/src/housekeeping/engine.ts +125 -0
  95. package/src/housekeeping/reconcile/work.ts +130 -0
  96. package/src/housekeeping/registry.ts +63 -0
  97. package/src/housekeeping/trash.ts +59 -0
  98. package/src/housekeeping/types.ts +31 -0
  99. package/src/hub.ts +1095 -41
  100. package/src/main.ts +230 -31
  101. package/src/message-references.ts +14 -5
  102. package/src/profile-prompt.ts +13 -1
  103. package/src/profile.ts +13 -5
  104. package/src/protocol.ts +41 -6
  105. package/src/security-allow.ts +79 -2
  106. package/src/security-check.ts +1 -1
  107. package/src/store.ts +1946 -23
  108. package/src/test-crash.ts +20 -0
  109. package/src/usage.ts +18 -7
  110. package/src/work-watch.ts +55 -7
  111. package/src/work.ts +192 -3
  112. package/web/DESIGN.md +35 -1
  113. package/web/agents.d.ts +189 -12
  114. package/web/agents.js +898 -39
  115. package/web/app.js +2634 -210
  116. package/web/attachments.d.ts +91 -0
  117. package/web/attachments.js +422 -0
  118. package/web/collaboration.d.ts +87 -0
  119. package/web/collaboration.js +290 -0
  120. package/web/decisions.d.ts +39 -2
  121. package/web/decisions.js +354 -2
  122. package/web/gc.d.ts +26 -5
  123. package/web/gc.js +113 -13
  124. package/web/i18n/agents.d.ts +4 -0
  125. package/web/i18n/agents.js +200 -8
  126. package/web/i18n/attachments.js +78 -0
  127. package/web/i18n/decisions.d.ts +4 -0
  128. package/web/i18n/decisions.js +68 -0
  129. package/web/i18n/gc.d.ts +4 -0
  130. package/web/i18n/gc.js +184 -2
  131. package/web/i18n/main.js +80 -8
  132. package/web/i18n/tour.d.ts +4 -0
  133. package/web/i18n/tour.js +37 -0
  134. package/web/index.html +20 -3
  135. package/web/profile.d.ts +2 -2
  136. package/web/profile.js +16 -10
  137. package/web/style.css +342 -21
  138. package/web/tooltip.d.ts +2 -0
  139. package/web/tooltip.js +12 -0
  140. package/web/tour.d.ts +57 -0
  141. package/web/tour.js +450 -0
@@ -0,0 +1,41 @@
1
+ import { asideBin } from "./agent-aside.ts";
2
+ import { asideAccount } from "./agent-aside-tool.ts";
3
+
4
+ // 294 D4: the bridge answers the gateway's `zgw_aside_tabs` with the tab list for the owner's picker. It runs the official
5
+ // CLI for listing only (fixed script, argv, no shell); no agent session is involved and no tab is opened or changed.
6
+
7
+ export const TABS_PREFIX = "ZGW_ASIDE_TABS ";
8
+ const TABS_SCRIPT = `const tabs = await listBrowserTabs(); console.log(${JSON.stringify(TABS_PREFIX)} + JSON.stringify(tabs.map((t) => ({ targetId: String(t.targetId), url: String(t.url), title: String(t.title ?? "") }))));`;
9
+ const LIST_TIMEOUT_MS = 60_000;
10
+
11
+ export interface AsideTab { readonly targetId: string; readonly url: string; readonly title: string }
12
+ export type TabsRunner = (bin: string, argv: readonly string[]) => Promise<{ code: number; stdout: string }>;
13
+
14
+ const runTabs: TabsRunner = async (bin, argv) => {
15
+ const child = Bun.spawn([bin, ...argv], { stdin: "ignore", stdout: "pipe", stderr: "ignore", timeout: LIST_TIMEOUT_MS });
16
+ const [stdout, code] = await Promise.all([new Response(child.stdout).text(), child.exited]);
17
+ return { code, stdout };
18
+ };
19
+
20
+ /** The last prefixed line of the listing; anything that is not a list of string-identified tabs is invalid. */
21
+ export function parseAsideTabs(stdout: string): AsideTab[] | null {
22
+ const line = stdout.split("\n").map((entry) => entry.replace(/\u001b\[[0-9;]*m/g, "").trim()).reverse().find((entry) => entry.startsWith(TABS_PREFIX)) ?? "";
23
+ let value: unknown;
24
+ try { value = JSON.parse(line.slice(TABS_PREFIX.length)); } catch { return null; }
25
+ if (!Array.isArray(value)) return null;
26
+ return value.filter((tab): tab is AsideTab => typeof tab?.targetId === "string" && tab.targetId !== "" && typeof tab.url === "string")
27
+ .map((tab) => ({ targetId: tab.targetId, url: tab.url, title: typeof tab.title === "string" ? tab.title : "" }));
28
+ }
29
+
30
+ /** No confirmed account means no listing (the picker answers target_identity_unclear); a failed CLI run is an error. */
31
+ export async function listAsideTabs(deps: { bin?: string; run?: TabsRunner; account?: (bin: string) => Promise<string | null> } = {}):
32
+ Promise<{ account: string | null; tabs: AsideTab[] }> {
33
+ const bin = deps.bin ?? asideBin();
34
+ const account = await (deps.account ?? asideAccount)(bin);
35
+ if (!account) return { account: null, tabs: [] };
36
+ const result = await (deps.run ?? runTabs)(bin, ["repl", "--account", account, TABS_SCRIPT]);
37
+ if (result.code !== 0) throw new Error("aside_failed");
38
+ const tabs = parseAsideTabs(result.stdout);
39
+ if (tabs === null) throw new Error("aside_output_invalid");
40
+ return { account, tabs };
41
+ }
@@ -0,0 +1,100 @@
1
+ import { createSecretKey, randomBytes, randomUUID, type KeyObject } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { parseManagedCallIdentity, type ManagedCallIdentity } from "../../src/agent/managed-call.ts";
4
+ import { attachmentDocumentCall, attachmentReadCall } from "../../src/attachments/managed-binding.ts";
5
+ import { runtimePaths } from "../../src/runtime/paths.ts";
6
+ import { buildManagedCallProof, type ManagedNativeContext } from "./managed-call.ts";
7
+
8
+ export const ATTACHMENT_READ_TOOL = "zgw_attachment_read";
9
+ const ROUTE = "/internal/attachment-read";
10
+ const DOCUMENT_ROUTE = "/internal/attachment-document";
11
+ type NativeContext = ManagedNativeContext & { readonly cwd?: string };
12
+ export interface AttachmentExtension {
13
+ readonly sessionKind: string;
14
+ readonly sessionContext: Readonly<Record<string, string>>;
15
+ readonly rpc: { handle(name: string, handler: (data: unknown) => unknown): void };
16
+ on(event: "session_start" | "session_shutdown", handler: (event: unknown, ctx: NativeContext) => unknown): void;
17
+ /** Present only on an engine that supports document input. */
18
+ setDocumentResolver?(resolve: (request: DocumentResolverRequest) => Promise<unknown>): void;
19
+ registerTool(tool: { name: string; label: string; description: string; parameters: unknown;
20
+ permissionParser(input: Record<string, unknown>): { permission: string; patterns: string[]; always: string[] }[];
21
+ execute(id: string, params: Record<string, unknown>, signal: AbortSignal | undefined, onUpdate: unknown, ctx: NativeContext): Promise<unknown> }): void;
22
+ }
23
+ interface DocumentResolverRequest { readonly reference: { readonly source: { readonly attachmentId: string; readonly grantId: string; readonly originalSha256: string } }; readonly mode: string; readonly sessionId: string; readonly signal?: AbortSignal }
24
+ type BoundIdentity = Omit<ManagedCallIdentity, "inputId" | "toolCallId" | "logicalCallId">;
25
+ const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
26
+
27
+ /** Registers only for managed main and agent sessions. Work and CLI sessions get no tool and no handler. */
28
+ export function installAttachmentRead(pi: AttachmentExtension): { bound(): boolean } {
29
+ const role = pi.sessionContext.zgw_role, conv = pi.sessionContext.zgw_conv;
30
+ if (pi.sessionKind !== "interactive" || !conv || (role !== "main" && role !== "agent")) return { bound: () => false };
31
+ let manager: ManagedNativeContext["sessionManager"] | undefined;
32
+ let binding: { identity: BoundIdentity; secret: KeyObject; inputId: string | null } | null = null;
33
+ pi.on("session_start", (_event, ctx) => { manager = ctx.sessionManager; binding = null; });
34
+ pi.on("session_shutdown", () => { manager = undefined; binding = null; });
35
+ pi.rpc.handle("zgw.attachment_bind", data => {
36
+ if (!record(data) || typeof data.secret !== "string" || !/^[a-f0-9]{64}$/.test(data.secret) || !record(data.identity) || !manager) throw new Error("attachment_bind_invalid");
37
+ const identity = parseManagedCallIdentity({ ...data.identity, inputId: "pending", toolCallId: "pending", logicalCallId: "pending" });
38
+ if (identity.convId !== conv || identity.role !== role || identity.agentId !== (pi.sessionContext.zgw_agent ?? null) ||
39
+ identity.durableSessionId !== manager.getSessionId() || identity.sessionPath !== manager.getSessionFile()) throw new Error("attachment_bind_mismatch");
40
+ binding = { identity, secret: createSecretKey(Buffer.from(data.secret, "hex")), inputId: typeof data.inputId === "string" ? data.inputId : null };
41
+ return { ok: true, documentResolver: resolverRegistered };
42
+ });
43
+ // Inline bytes are fetched here, per provider request, and never stored in the session. An unbound session gets the unavailable label.
44
+ const resolverRegistered = typeof pi.setDocumentResolver === "function";
45
+ pi.setDocumentResolver?.(async request => {
46
+ const current = binding, native = manager;
47
+ if (!current || !native || current.inputId === null || request.sessionId !== native.getSessionId()) return { status: "attachment_unavailable" };
48
+ const { attachmentId, grantId, originalSha256 } = request.reference.source;
49
+ const body = new TextEncoder().encode(JSON.stringify({ attachmentId, grantId, originalSha256, mode: request.mode }));
50
+ const toolCallId = `document-${randomUUID()}`;
51
+ const identity = parseManagedCallIdentity({ ...current.identity, inputId: current.inputId, toolCallId, logicalCallId: attachmentDocumentCall(toolCallId) });
52
+ const proof = buildManagedCallProof({ native: { sessionManager: native }, nativeInstance: native, identity, secret: current.secret,
53
+ currentIdentity: () => (binding === current && current.inputId === identity.inputId ? identity : null) },
54
+ { method: "POST", route: DOCUMENT_ROUTE, operation: "attachment.document", body, nonce: randomBytes(32).toString("hex") });
55
+ try {
56
+ const base = process.env.ZGW_WORK_URL ?? `http://127.0.0.1:${process.env.ZGW_PORT ?? "8700"}`;
57
+ const response = await fetch(new URL(DOCUMENT_ROUTE, base), { method: "POST", redirect: "error", body,
58
+ headers: { authorization: `Bearer ${readFileSync(runtimePaths().omoTokenFile, "utf8").trim()}`, "content-type": "application/json",
59
+ "x-zigmini-managed-call": Buffer.from(JSON.stringify(proof)).toString("base64") },
60
+ signal: request.signal ? AbortSignal.any([request.signal, AbortSignal.timeout(120_000)]) : AbortSignal.timeout(120_000) });
61
+ return response.ok ? await response.json() : { status: "attachment_unavailable" };
62
+ } catch (error) {
63
+ if (error instanceof Error && error.name === "AbortError") throw error;
64
+ return { status: "attachment_unavailable" };
65
+ }
66
+ });
67
+ pi.rpc.handle("zgw.attachment_input", data => {
68
+ if (!binding || !record(data) || data.sessionIncarnation !== binding.identity.sessionIncarnation || typeof data.inputId !== "string") throw new Error("attachment_input_mismatch");
69
+ binding.inputId = data.inputId;
70
+ return { ok: true };
71
+ });
72
+ pi.registerTool({
73
+ name: ATTACHMENT_READ_TOOL, label: "Attachment read",
74
+ // Sending a file is already the owner's permission to read it, so a read preset must not ask again (C9-1).
75
+ permissionParser: input => [{ permission: "read", patterns: [`zgw-attachment:${String(input.attachmentId)}`], always: [] }],
76
+ description: "Read a document the user attached to this conversation. Identify it only by attachmentId from the message metadata. view text returns extracted text; view pages returns rendered pages of a PDF. Continue with nextCursor until it is absent.",
77
+ parameters: { type: "object", additionalProperties: false, required: ["attachmentId", "view"], properties: {
78
+ attachmentId: { type: "string" }, view: { type: "string", enum: ["text", "pages"] },
79
+ cursor: { type: "string" }, limit: { type: "integer", minimum: 1, maximum: 2000 } } },
80
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
81
+ const current = binding, native = manager;
82
+ if (!current || !native || current.inputId === null) throw new Error("attachment_read_unbound");
83
+ const body = new TextEncoder().encode(JSON.stringify(params));
84
+ const identity = parseManagedCallIdentity({ ...current.identity, inputId: current.inputId, toolCallId, logicalCallId: attachmentReadCall(toolCallId) });
85
+ const proof = buildManagedCallProof({ native: ctx, nativeInstance: native, identity, secret: current.secret,
86
+ currentIdentity: () => (binding === current && current.inputId === identity.inputId ? identity : null) },
87
+ { method: "POST", route: ROUTE, operation: "attachment.read", body, nonce: randomBytes(32).toString("hex") });
88
+ const base = process.env.ZGW_WORK_URL ?? `http://127.0.0.1:${process.env.ZGW_PORT ?? "8700"}`;
89
+ const response = await fetch(new URL(ROUTE, base), { method: "POST", redirect: "error", body,
90
+ headers: { authorization: `Bearer ${readFileSync(runtimePaths().omoTokenFile, "utf8").trim()}`, "content-type": "application/json",
91
+ "x-zigmini-managed-call": Buffer.from(JSON.stringify(proof)).toString("base64") },
92
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(120_000)]) : AbortSignal.timeout(120_000) });
93
+ const result: unknown = await response.json();
94
+ if (!response.ok || !record(result) || !Array.isArray(result.content)) throw new Error(record(result) && typeof result.error === "string" ? result.error : `attachment_read_http_${response.status}`);
95
+ const { content, ...details } = result;
96
+ return { content: [{ type: "text", text: JSON.stringify(details) }, ...content], details };
97
+ },
98
+ });
99
+ return { bound: () => binding !== null };
100
+ }
@@ -13,6 +13,7 @@ import { bunInstallDir, guardMode, guardStart } from "../../src/engine/schema-gu
13
13
  import { bridgeMayRun, readRegistry } from "../../src/runtime/slots.ts";
14
14
  import { BridgeMaintenance } from "./maintenance.ts";
15
15
  import { asciiJson } from "./ascii-json.ts";
16
+ import { listAsideTabs } from "./aside-tabs.ts";
16
17
 
17
18
  const paths = runtimePaths();
18
19
  const home = paths.home;
@@ -506,6 +507,15 @@ const server = Bun.serve<Link>({
506
507
  }));
507
508
  return;
508
509
  }
510
+ // 294 D4: the owner's Aside tab picker asks the bridge (token-authenticated link) for the tab list; the CLI lists
511
+ // only and no agent session is involved, so a link without a requester identity is answered too.
512
+ if (record(request) && request.type === "zgw_aside_tabs") {
513
+ const id = request.id;
514
+ const answer = (body: Record<string, unknown>) => deliver(link, JSON.stringify({ type: "response", id, command: "zgw_aside_tabs", ...body }));
515
+ void listAsideTabs().then((data) => answer({ success: true, data }),
516
+ (error: unknown) => answer({ success: false, error: error instanceof Error ? error.message : String(error) }));
517
+ return;
518
+ }
509
519
  if (record(request) && link.identity) {
510
520
  if (request.type === "zgw_transport_info" || request.type === "zgw_transport_resume") {
511
521
  if (request.type === "zgw_transport_resume" && !link.ready) {
@@ -0,0 +1,46 @@
1
+ import type { KeyObject } from "node:crypto";
2
+ import {
3
+ ManagedCallError, managedCallBodyDigest, managedCallSignature, parseManagedCallIdentity, sameManagedCallIdentity,
4
+ type ManagedCallIdentity, type ManagedCallProof, type ManagedCallRequest,
5
+ } from "../../src/agent/managed-call.ts";
6
+
7
+ /** Structural SDK port; getters on the real native context enforce runner retirement. */
8
+ export interface ManagedNativeContext {
9
+ readonly sessionManager: {
10
+ getSessionId(): string;
11
+ getSessionFile(): string | undefined;
12
+ };
13
+ readonly signal?: AbortSignal;
14
+ }
15
+
16
+ /**
17
+ * Private adapter state, NEVER tool parameters, sessionContext metadata or log data.
18
+ * The trusted bind pins the native manager object and execution identity. Input/tool/
19
+ * logical-call IDs come from the host's actual invocation and operation ledger.
20
+ * Provisioning/Hub identity RPC and retirement wiring are owned by the consumer.
21
+ */
22
+ export interface ManagedCallContext {
23
+ readonly native: ManagedNativeContext;
24
+ readonly nativeInstance: ManagedNativeContext["sessionManager"];
25
+ readonly identity: ManagedCallIdentity;
26
+ readonly secret: KeyObject;
27
+ /** Current trusted execution identity; null after retirement/cancel/settle. */
28
+ currentIdentity(): ManagedCallIdentity | null;
29
+ }
30
+
31
+ export function buildManagedCallProof(
32
+ context: ManagedCallContext,
33
+ request: ManagedCallRequest & { readonly nonce: string },
34
+ ): ManagedCallProof {
35
+ // Access the guarded native getter on EVERY call, including retries.
36
+ const manager = context.native.sessionManager;
37
+ const identity = parseManagedCallIdentity(context.identity);
38
+ if (manager !== context.nativeInstance || manager.getSessionId() !== identity.durableSessionId ||
39
+ manager.getSessionFile() !== identity.sessionPath || context.native.signal?.aborted)
40
+ throw new ManagedCallError("native_context_mismatch");
41
+ const current = context.currentIdentity();
42
+ if (current === null || !sameManagedCallIdentity(identity, current)) throw new ManagedCallError("binding_mismatch");
43
+ const content = { nonce: request.nonce, bodySha256: managedCallBodyDigest(request.body) };
44
+ const mac = managedCallSignature({ identity, secret: context.secret }, request, content);
45
+ return Object.freeze({ version: 1, identity, ...content, mac });
46
+ }
@@ -1,13 +1,18 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import { registerAgentCollaboration, COLLABORATION_TOOLS, CollaborationExtensionError, type CollaborationControl } from "./agent-collaboration.ts";
5
+ import { CollaborationSnapshots, type NativeFileSdk } from "./agent-collaboration-files.ts";
6
+ import { loadEngineSdk } from "../../src/engine/index.ts";
2
7
  import { isDeepStrictEqual } from "node:util";
3
- import { runtimePaths } from "../../src/runtime/paths.ts";
8
+ import { runtimeHome, runtimePaths } from "../../src/runtime/paths.ts";
4
9
  import { agentRules } from "../../src/agent-rules.ts";
5
10
  import { applyRequestRules, loadRequestRules, type ProviderRequestEvent } from "../../src/agent-request-rules.ts";
6
11
  import { nicknamePrompt, PROFILE_TIMEOUT_MS, readPromptProfile, type PromptProfile } from "../../src/profile-prompt.ts";
7
12
 
8
13
  // The pinned runtime supplies this SDK surface, just as it does for model-control.ts.
9
14
  type Entry = { id: string; type: string; customType?: string; data?: unknown; message?: { role: string; content: unknown; toolName?: string; details?: unknown; toolCallId?: string } };
10
- type Context = { sessionManager: { getSessionFile(): string | undefined; getEntries(): Entry[] } };
15
+ type Context = { sessionManager: { getSessionFile(): string | undefined; getEntries(): Entry[] }; readonly cwd?: string; readonly model?: unknown; readonly thinkingLevel?: string };
11
16
  type Params = Record<string, unknown>;
12
17
  type Tool = {
13
18
  name: string; label: string; description: string; parameters: unknown;
@@ -16,11 +21,14 @@ type Tool = {
16
21
  interface ExtensionApi {
17
22
  readonly sessionKind: string;
18
23
  readonly sessionContext: Readonly<Record<string, string>>;
19
- readonly rpc: { handle(name: string, handler: () => unknown): void; emit(name: string, data: unknown): void };
24
+ readonly rpc: { handle(name: string, handler: (data: unknown) => unknown): void; emit(name: string, data: unknown): void };
20
25
  readonly events: { on(name: string, handler: (data: unknown) => void): () => void };
21
26
  on(event: "session_start", handler: (event: unknown, ctx: Context) => void): void;
22
27
  on(event: "before_agent_start", handler: (event: { systemPrompt: string }) => unknown, options: { previewSafe: boolean }): void;
23
28
  on(event: "before_provider_request", handler: (event: ProviderRequestEvent) => unknown): void;
29
+ on(event: "input", handler: (event: { inputId: string; text: string }) => unknown): void;
30
+ on(event: "session_abort" | "session_shutdown" | "agent_settled", handler: () => void): void;
31
+ on(event: "tool_call", handler: (event: { toolName: string }) => unknown): void;
24
32
  registerTool(tool: Tool): void;
25
33
  appendEntry(customType: string, data: unknown): void;
26
34
  }
@@ -127,19 +135,41 @@ async function postWork(operation: string, requestKey: string, sourceSessionPath
127
135
  return result;
128
136
  }
129
137
 
130
- export default function taskControl(pi: ExtensionApi): void {
138
+ // allow: SIZE_OK - existing main/work lifecycle and report protocol; the shared file adapter owns the new file logic.
139
+ export default function taskControl(pi: ExtensionApi, loadSdk: () => Promise<NativeFileSdk> = () => loadEngineSdk<NativeFileSdk>()): void {
131
140
  const role = pi.sessionContext.zgw_role;
132
141
  const workId = pi.sessionContext.zgw_work;
133
142
  const conv = pi.sessionContext.zgw_conv;
134
143
  let runtime: Context | undefined;
135
- pi.on("session_start", (_event, ctx) => { runtime = ctx; });
144
+ let collaboration: CollaborationControl | undefined;
145
+ pi.on("session_start", async (_event, ctx) => {
146
+ runtime = ctx; collaboration?.start(ctx);
147
+ collaboration?.applied({ definitionSha: mainSha, fingerprint: createHash("sha256").update(JSON.stringify({ mainSha, model: ctx.model, thinking: ctx.thinkingLevel })).digest("hex") });
148
+ if (collaboration) {
149
+ if (!ctx.cwd) throw new CollaborationExtensionError("native_cwd_missing");
150
+ const sdk = await loadSdk();
151
+ new CollaborationSnapshots({ root: join(runtimeHome(), "agents"), control: collaboration, queue: sdk.withFileMutationQueue,
152
+ assertKnowledgeLock() { throw new CollaborationExtensionError("read_only_continuation"); } }).install(pi, { sdk, cwd: ctx.cwd });
153
+ }
154
+ });
136
155
  pi.rpc.handle("zgw.work_identity", () => ({ role, workId, conv, sessionPath: runtime?.sessionManager.getSessionFile() }));
137
156
  const isMain = role === "main" && pi.sessionKind === "interactive" && Boolean(conv);
138
157
  const isWork = role === "work" && pi.sessionKind === "worker" && Boolean(workId);
139
158
  if (!isMain && !isWork) return; // No task tools in unrelated CLI or speech-summary workers.
159
+ collaboration = isMain ? registerAgentCollaboration(pi) : undefined;
160
+ if (collaboration) {
161
+ pi.on("input", event => { collaboration?.input(event); });
162
+ for (const event of ["session_abort", "session_shutdown", "agent_settled"] as const) pi.on(event, () => { collaboration?.revoke(); });
163
+ pi.on("tool_call", event => {
164
+ try { if (!COLLABORATION_TOOLS.includes(event.toolName)) collaboration?.assertGeneralTool(); }
165
+ catch (error) { if (error instanceof CollaborationExtensionError) return { block: true, reason: error.code }; throw error; }
166
+ if (collaboration?.isPeer() && ["bash", "eval", "task", "zgw_work_report"].includes(event.toolName)) return { block: true, reason: "collaboration_tool_forbidden" };
167
+ });
168
+ }
140
169
  const rules = agentRules();
141
170
  console.warn(rules.log);
142
171
  const rulesText = rules.text === "" ? "" : `\n\n${rules.text}`;
172
+ const mainSha = createHash("sha256").update(rulesText).digest("hex");
143
173
  const requestRules = loadRequestRules();
144
174
  pi.on("before_provider_request", (event) => applyRequestRules(event, requestRules));
145
175
 
@@ -168,6 +198,7 @@ export default function taskControl(pi: ExtensionApi): void {
168
198
  name: "zgw_precomment", label: "Precomment", description: PRECOMMENT_DESCRIPTION,
169
199
  parameters: { type: "object", properties: { text: { type: "string", minLength: 1, maxLength: PRECOMMENT_MAX } }, required: ["text"], additionalProperties: false },
170
200
  async execute(toolCallId, params, signal, _update, ctx) {
201
+ collaboration?.assertGeneralTool();
171
202
  // A precomment is advisory: a recording failure is reported to the model and never blocks the turn.
172
203
  try {
173
204
  const text = precommentText(params.text);
@@ -189,6 +220,7 @@ export default function taskControl(pi: ExtensionApi): void {
189
220
  description: "List the user's agents read-only: name, one-line description and the web link of each agent's conversation. Use it to answer which agents exist or to give the user the link to open one. Returns no agent knowledge.",
190
221
  parameters: { type: "object", properties: {}, additionalProperties: false },
191
222
  async execute(_toolCallId, _params, signal) {
223
+ collaboration?.assertGeneralTool();
192
224
  const token = readFileSync(runtimePaths().omoTokenFile, "utf8").trim();
193
225
  const response = await fetch(new URL("/internal/agent-list", gatewayBase()), {
194
226
  headers: { authorization: `Bearer ${token}` },
@@ -212,11 +244,13 @@ export default function taskControl(pi: ExtensionApi): void {
212
244
  name: `zgw_work_${operation}`, label: `Work ${operation}`, description: spec.description,
213
245
  parameters: { type: "object", properties: spec.fields, required: spec.required, additionalProperties: false },
214
246
  async execute(toolCallId, params, signal, _update, ctx) {
247
+ collaboration?.assertGeneralTool();
215
248
  const sourceSessionPath = ctx.sessionManager.getSessionFile();
216
249
  if (!sourceSessionPath) throw new Error("work_source_session_missing");
217
250
  const requestKey = JSON.stringify([sourceSessionPath, toolCallId]);
218
251
  let payload: Params = params;
219
252
  if (operation === "report") {
253
+ if (isMain && collaboration?.isPeer()) throw new Error("collaboration_owner_answer_forbidden");
220
254
  if (isMain && (params.kind !== "question" ||
221
255
  Object.keys(params).some((key) => !["kind", "summary", "details"].includes(key)))) {
222
256
  throw new Error("main_question_only");
@@ -0,0 +1,138 @@
1
+ # Unreleased attachment 293 engine candidate
2
+
3
+ This directory is a test candidate, not a release registration. The released
4
+ manifest, existing patches, compatibility pins, installer and build-ID code are
5
+ unchanged. Revision 4 is only the existing builder's nested directory label under
6
+ a task-owned, manifest-hashed root. Never install this candidate over a published
7
+ or installed engine.
8
+
9
+ ## Reproduction
10
+
11
+ `test/fixtures/attachments/engine/candidate.ts` installs the stock
12
+ `omo-ai@5.1.51` package with the existing `installStock` (npm registry, or
13
+ `ZIGMINI_REGISTRY_URL`) as its input. It reads nothing from `.omo/evidence`
14
+ (bug 304), so a fresh clone, CI and another Mac build the same tree. It stages this manifest, the
15
+ unchanged baseline patches and the document patch, then calls the existing
16
+ `buildPatched(version, omoDir, patchDir)` with both directories explicit. The
17
+ builder checks fixed original and patched hashes. It never derives expected
18
+ hashes from the candidate it is checking.
19
+
20
+ The output is `<cache>/<manifest-sha>/omo/5.1.51-zgw.4`, where `<cache>` is
21
+ `ATTACHMENT_ENGINE_CACHE` or `zgw-attachment-engine` in the OS temp folder.
22
+ Importing `candidate-ready.ts` (or awaiting `buildCandidate()`) builds the
23
+ candidate once; later runs verify the fixed patched hashes. Importing
24
+ `candidate.ts` alone has no side effect. `ATTACHMENT_ENGINE_ROOT` uses an already built candidate instead.
25
+ Without network access and without that variable, the engine tests fail; they
26
+ are never skipped.
27
+ The source snippets in `document-content.js` and `document-content.types.txt`
28
+ are included in the frozen unified patch, not imported from this directory at
29
+ runtime. Changing a snippet requires updating that patch and its fixed manifest
30
+ hashes together.
31
+
32
+ From the task worktree:
33
+
34
+ ```sh
35
+ env -i HOME="$HOME" PATH="$PATH" USER="$USER" LOGNAME="$LOGNAME" \
36
+ SHELL="$SHELL" TMPDIR="${TMPDIR:-/tmp}" LANG=en_US.UTF-8 \
37
+ bun run heavy omo-check -- perl -e 'alarm shift; exec @ARGV' 600 \
38
+ bun test/fixtures/attachments/engine/run.ts \
39
+ bun test/fixtures/attachments/engine/candidate.ts
40
+ ```
41
+
42
+ Use the same clean wrapper with the `test` slot for
43
+ `bun test test/contract/attachment-engine.contract.test.ts` and
44
+ `bun test test/unit/attachment-engine.test.ts`.
45
+ Use the separate `mutation` slot for
46
+ `bun test test/mutation/attachment-engine.test.ts`.
47
+ `ATTACHMENT_MUTATION_SEED` can reproduce a recorded seed when set after slot
48
+ acquisition. Normal mutation runs choose a new seed.
49
+ `fingerprint.ts` hashes the complete candidate tree and rejects escaping links.
50
+
51
+ ## Engine boundary
52
+
53
+ RPC advertises `document_content_v1`. `prompt`, `steer` and `follow_up` accept the
54
+ approved PDF `DocumentContent`, owner metadata and client input identity.
55
+ Ingress checks PDF magic, original digest, size and exact attachment/grant
56
+ correlation. The persisted input is a `DocumentReference`; base64 is not stored
57
+ in admission, queue or transcript records. Compaction checkpoints retain
58
+ references and owner/input provenance even after the resident mirror is trimmed.
59
+ `append_session_entry` also accepts validated owner-document history for native
60
+ session replacement. Bare `append_user_message` cannot invent document
61
+ provenance; it remains text/image-only.
62
+
63
+ The gateway extension registers the resolver with
64
+ `pi.setDocumentResolver(resolve)`, either while the extension loads or later.
65
+ Only one extension owns it per session; another owner gets
66
+ `document_resolver_already_registered`. An in-process host may call
67
+ `AgentSession.setDocumentResolver(resolve)` directly.
68
+ The resolver receives the current native session/input identity, the originating
69
+ input ID, exact reference, selected model, mode and cancellation signal. It must
70
+ use the verified managed binding and active grant ledger. A filename, attachment
71
+ ID or callback registration is not authentication. No resolver means a typed
72
+ unavailable label; there is no implicit filesystem reader.
73
+
74
+ A ready resolver reply carries:
75
+
76
+ - Original SHA, the reader's **unchanged** representation SHA and grant revision.
77
+ - `payloadSha256`: SHA256 of UTF-8
78
+ `JSON.stringify({content, coverage, ...(nextCursor === undefined ? {} : {nextCursor})})`.
79
+ This delivery-envelope digest is separate from the native reader's complete
80
+ prepared-text digest. T4's digest and cursor contracts are not redefined.
81
+ - The validated representation, coverage, optional opaque continuation and
82
+ a conservative input-token estimate from the preparing service.
83
+
84
+ The native helper's internal scalar cursor is not this engine's opaque cursor.
85
+ T6 must translate it through its grant-bound cursor service and recheck the
86
+ reader's original/representation digest before constructing a delivery envelope.
87
+ The engine does not issue grants, inspect text-security holds, or implement HMAC.
88
+
89
+ `ready` is the resolver's complete-preparation prerequisite. `coverage` describes
90
+ only the current bounded delivery, not all prepared pages. A partial initial
91
+ delivery requires `nextCursor`, including OCR. The engine retains that opaque
92
+ cursor with the exact source/grant, representation digest and grant revision;
93
+ it does not fetch or concatenate subsequent chunks. Subsequent
94
+ `zgw_attachment_read` replies remain bounded text/image tool results, with T6
95
+ responsible for their cursor scope, range completion and release checks.
96
+ Non-native delivered `rendered-image` pages require exactly one actual image
97
+ block per declared page. Text coverage requires actual text that isn't only
98
+ whitespace after trimming, and a truly empty page uses `verified-blank`. The
99
+ aggregate text content is bounded to 48 KiB in UTF-8, not JavaScript characters.
100
+
101
+ Public OpenAI Responses and Anthropic Messages use top-level native PDF blocks.
102
+ Subscription requests use prepared text/images; text-only models use prepared
103
+ OCR text. Requests reselect their representation, including after reopen,
104
+ compaction and a provider change. Revoked/unavailable replies become typed
105
+ labels. `attachment_text_held` stops the request without content.
106
+
107
+ The engine bounds original bytes, base64, coverage, image count/bytes and
108
+ serialized requests. Native size/page/context overflow selects pages before a
109
+ provider dispatch where that representation fits. Provider errors carrying
110
+ document input do not retry an uncertain request. Claude document requests use a
111
+ fresh cold seed with all authorized document page images, rather than the old
112
+ eight-image history elision.
113
+
114
+ ## Evidence boundaries
115
+
116
+ The contracts execute the candidate's native CLI runtime, RPC handler, durable
117
+ queue/transcript and compaction pipeline. Public and ChatGPT adapters use real
118
+ loopback HTTP with scripted model replies. Claude's actual adapter is exercised
119
+ at its SDK query/prompt boundary with scripted SDK replies; this is not proof of
120
+ Claude SDK-internal HTTP framing or real-model comprehension.
121
+
122
+ Prepared page/OCR inputs in these engine fixtures are synthetic representation
123
+ inputs. They are not evidence of PDFKit rendering or Vision recognition. Those
124
+ native operations remain T4's proof. Managed-call authentication and rotation
125
+ remain 292 T4-owned. The fixture's reopen/compaction checks do not claim that
126
+ unimplemented authentication integration ran. Gateway/T6 registration and final
127
+ combined-channel tests remain separate.
128
+
129
+ Primary provider documents read on 2026-11-06:
130
+
131
+ - `https://developers.openai.com/api/docs/guides/file-inputs`
132
+ - `https://platform.claude.com/docs/en/build-with-claude/pdf-support`
133
+
134
+ The local OpenAI serialized ceiling is 50,000,000 bytes, stricter than the
135
+ documented aggregate decoded-file limit after base64 expansion. The public
136
+ Anthropic ceiling is 128,000,000 bytes. Subscription input uses a conservative
137
+ 24 MiB engine-request ceiling. These are payload checks, not live semantic
138
+ verification or authorization to call configured providers.