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,123 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ export type AttachmentFormat = "html" | "md" | "txt" | "pdf";
4
+ export type AttachmentReadMode = "text" | "native-document" | "pages" | "ocr";
5
+ export type AttachmentState = "snapshotting" | "sealed" | "preparing" | "ready" | "bound" | "discarded" | "error" | "revoked";
6
+ export type AttachmentEncoding = "utf-8" | "utf-16le" | "utf-16be" | "euc-kr" | "cp949";
7
+ export interface AttachmentRecipient {
8
+ readonly kind: "main" | "agent";
9
+ readonly id: string;
10
+ readonly epoch: number;
11
+ }
12
+ export interface AttachmentScope {
13
+ readonly ownerPrincipal: string;
14
+ readonly draftId: string;
15
+ readonly convId: string;
16
+ readonly convEpoch: number;
17
+ readonly recipient: AttachmentRecipient;
18
+ }
19
+ export interface AttachmentManifest {
20
+ readonly attachmentId: string;
21
+ readonly expectedRevision: number;
22
+ readonly preparationRevision: number;
23
+ readonly originalSha256: string;
24
+ readonly representationSha256: string;
25
+ }
26
+ /** Frozen attachmentAdmission DTO; full existing message data is a separate argument. */
27
+ export interface AttachmentAdmission extends AttachmentScope {
28
+ readonly submissionId: string;
29
+ readonly requestDigest: string;
30
+ readonly inspectedTextSha256: string;
31
+ readonly text: string;
32
+ readonly attachments: readonly AttachmentManifest[];
33
+ }
34
+ export interface AttachmentMetadata {
35
+ readonly id: string;
36
+ readonly grantId: string;
37
+ readonly name: string;
38
+ readonly format: AttachmentFormat;
39
+ readonly bytes: number;
40
+ readonly sha256: string;
41
+ readonly readMode: AttachmentReadMode;
42
+ readonly pageCount?: number;
43
+ /** Client frames only. Read from this grant's row when sent; never stored in the message. */
44
+ readonly grantRevoked?: boolean;
45
+ }
46
+ export interface AttachmentAccepted {
47
+ readonly convId: string;
48
+ readonly submissionId: string;
49
+ readonly messageSeq: number;
50
+ readonly pendingDispatchSeq: number;
51
+ readonly attachments: readonly AttachmentMetadata[];
52
+ }
53
+ export interface AttachmentCAS {
54
+ readonly expectedRevision: number;
55
+ readonly jobGeneration: string;
56
+ }
57
+ export interface AttachmentJob {
58
+ readonly attachmentId: string;
59
+ readonly state: AttachmentState;
60
+ readonly revision: number;
61
+ readonly jobGeneration: string;
62
+ readonly preparationRevision: number;
63
+ }
64
+ export interface AttachmentPreparation {
65
+ readonly originalSha256: string;
66
+ readonly preparationRevision: number;
67
+ readonly representationSha256: string;
68
+ readonly inspectedTextSha256: string;
69
+ readonly readMode: AttachmentReadMode;
70
+ readonly pageCount?: number;
71
+ }
72
+ export interface AttachmentSelection {
73
+ readonly scope: AttachmentScope;
74
+ readonly operationId: string;
75
+ readonly entryIdentity: Readonly<Record<string, unknown>>;
76
+ readonly sourceIdentity: Readonly<Record<string, unknown>>;
77
+ readonly name: string;
78
+ readonly format: AttachmentFormat;
79
+ readonly bytes: number;
80
+ readonly jobGeneration: string;
81
+ }
82
+ /** Structurally compatible with confirmed 292 AttachmentOrigin, not an authority token. */
83
+ export interface SelectedAttachmentExport {
84
+ readonly attachmentId: string;
85
+ readonly sourceGrantId: string;
86
+ readonly sourceGrantRevision: number;
87
+ readonly originMessageSeq: number;
88
+ readonly sourceConvId: string;
89
+ readonly sourceRecipientId: string;
90
+ readonly sourceConvEpoch: number;
91
+ readonly sourceRecipientEpoch: number;
92
+ readonly originalSha256: string;
93
+ readonly bytes: number;
94
+ readonly format: string;
95
+ readonly name: string;
96
+ }
97
+ export interface AttachmentCurrent {
98
+ readonly convEpoch: number;
99
+ readonly recipient: AttachmentRecipient;
100
+ readonly ownerActive: boolean;
101
+ /** Omitted in standalone mode; supplied by the real 294 ledger in combined mode. */
102
+ readonly authority?: { readonly epoch: number; readonly accessState: "open" | "blocked"; readonly policyRevision: number };
103
+ }
104
+ /** Trusted internal adapters, never model/client DTOs. All calls are synchronous on the writer connection.
105
+ * current reads the existing lifecycle/identity ledger; no attachment-owned lifecycle counter exists.
106
+ * original checks the sealed descriptor bytes, not source_identity_json or the original host pathname.
107
+ * inspect enforces the existing text security guard against the exact admission + preparation digests.
108
+ */
109
+ export interface AttachmentGate {
110
+ readonly current: (tx: Database, scope: AttachmentScope) => AttachmentCurrent;
111
+ readonly original: (tx: Database, attachmentId: string) => { readonly bytes: number; readonly sha256: string } | null;
112
+ readonly inspect: (tx: Database, admission: AttachmentAdmission, preparations: readonly AttachmentPreparation[]) => undefined;
113
+ }
114
+ export type AttachmentErrorCode =
115
+ | "invalid_attachment_input" | "attachment_not_found" | "attachment_scope_mismatch" | "attachment_owner_inactive"
116
+ | "attachment_cas_conflict" | "attachment_not_ready" | "attachment_already_bound" | "attachment_manifest_conflict"
117
+ | "attachment_original_unavailable" | "operation_conflict" | "submission_conflict" | "request_digest_mismatch"
118
+ | "staging_quota_exceeded" | "too_many_attachments" | "attachment_reference_unavailable" | "attachment_revoked"
119
+ | "attachment_busy" | "attachment_tombstoned" | "attachment_provider_invalid" | "attachment_transaction_required"
120
+ | "agent_access_blocked" | "authority_epoch_mismatch";
121
+ export class AttachmentStoreError extends Error {
122
+ constructor(readonly code: AttachmentErrorCode, readonly status = 409) { super(code); this.name = "AttachmentStoreError"; }
123
+ }
package/src/cli/doctor.ts CHANGED
@@ -4,7 +4,7 @@ import { relative, resolve, sep } from "node:path";
4
4
  import { consentState, loadNotice, readConsent } from "../consent/consent.ts";
5
5
  import { OMO_REGISTRY_URL, engine } from "../engine/index.ts";
6
6
  import { bunInstallDir, checkSchema, globalEngineVersion, globalEngineWarning, readStoreSchema, SCHEMA_TOO_NEW, type SchemaCheck } from "../engine/schema-guard.ts";
7
- import { GC_PATH } from "../gc.ts";
7
+ import { HOUSEKEEPING_PATH } from "../housekeeping/api.ts";
8
8
  import { agentDir } from "../model-accounts.ts";
9
9
  import { type InstallKind } from "../runtime/install-kind.ts";
10
10
  import { readVoiceSetting } from "../voice-setting.ts";
@@ -45,7 +45,10 @@ export interface DoctorDeps extends LaunchdDeps {
45
45
  readonly schema: () => SchemaCheck;
46
46
  readonly diskFree: (path: string) => number | null;
47
47
  readonly consent: () => ConsentView;
48
+ /** Open candidates of the housekeeping gc role (회수); null when the gateway did not answer. */
48
49
  readonly gcCandidates?: () => number | null | Promise<number | null>;
50
+ /** Open candidates of the housekeeping reconcile role (상태 정리); null when the gateway did not answer. */
51
+ readonly reconcileCandidates?: () => number | null | Promise<number | null>;
49
52
  /** npm install only (Todo 26): the raw voice setting and whether <home>/voice/installed.json exists; null elsewhere. */
50
53
  readonly voiceInstall?: () => { readonly on: boolean; readonly installed: boolean } | null;
51
54
  /** GET /api/update/status of the running gateway; null when it did not answer. */
@@ -63,6 +66,7 @@ export interface DoctorReport {
63
66
  readonly counts: DoctorCounts | null;
64
67
  readonly rpcDaemons: readonly string[];
65
68
  readonly gcCandidates: number | null;
69
+ readonly reconcileCandidates: number | null;
66
70
  readonly fix?: { readonly targets: readonly Service[]; readonly failed: readonly Service[] };
67
71
  }
68
72
 
@@ -149,7 +153,9 @@ export async function collect(deps: DoctorDeps, ctx: LaunchdContext): Promise<Do
149
153
  detail: free === null ? "확인 실패" : `${(free / 1024 ** 3).toFixed(1)}GB` });
150
154
 
151
155
  const gc = deps.gcCandidates === undefined ? null : await deps.gcCandidates();
152
- if (gc !== null) items.push({ id: "gc", label: "정리 후보", status: "info", detail: `${gc}개` });
156
+ if (gc !== null) items.push({ id: "gc", label: "정리 후보 (회수)", status: "info", detail: `${gc}개` });
157
+ const reconcile = deps.reconcileCandidates === undefined ? null : await deps.reconcileCandidates();
158
+ if (reconcile !== null) items.push({ id: "reconcile", label: "정리 후보 (상태 정리)", status: "info", detail: `${reconcile}개` });
153
159
 
154
160
  if (deps.access !== undefined) items.push(await deps.access());
155
161
 
@@ -163,7 +169,7 @@ export async function collect(deps: DoctorDeps, ctx: LaunchdContext): Promise<Do
163
169
  ? `만료(동의한 버전 ${consent.version}, 지금 ${consent.noticeVersion}). \`zigmini consent\`를 실행하세요` : "없음. `zigmini consent`를 실행하세요" });
164
170
 
165
171
  return { ok: !items.some((item) => item.status === "bad"), items, consent, omo: { status: health?.omo ?? null, pinned, global, latest },
166
- counts, rpcDaemons: rpc ?? [], gcCandidates: gc };
172
+ counts, rpcDaemons: rpc ?? [], gcCandidates: gc, reconcileCandidates: reconcile };
167
173
  }
168
174
 
169
175
  /** Only the broken launchd agents; consent and a too-new schema are not fixed by a restart. */
@@ -254,12 +260,31 @@ export function gcTotal(body: unknown): number | null {
254
260
  return typeof body === "object" && body !== null ? count((body as { total?: unknown }).total) : null;
255
261
  }
256
262
 
263
+ /** Per-role open candidate counts of GET /api/housekeeping; null unless both are counts. */
264
+ export function housekeepingCounts(body: unknown): { reconcile: number; gc: number } | null {
265
+ if (typeof body !== "object" || body === null) return null;
266
+ const roles = (body as { roles?: { reconcile?: { total?: unknown }; gc?: { total?: unknown } } }).roles;
267
+ const reconcile = count(roles?.reconcile?.total);
268
+ const gc = count(roles?.gc?.total);
269
+ return reconcile === null || gc === null ? null : { reconcile, gc };
270
+ }
271
+
257
272
  export function engineFolder(omoRoot: string, target: string): string | null {
258
273
  const version = relative(omoRoot, resolve(omoRoot, target));
259
274
  return version === "" || version.startsWith("..") || version.includes(sep) ? null : version;
260
275
  }
261
276
 
262
277
  export function systemDoctorDeps(base: LaunchdDeps, ctx: LaunchdContext, kind: InstallKind): DoctorDeps {
278
+ // One doctor scan per run serves both role counts.
279
+ let scanned: Promise<{ reconcile: number; gc: number } | null> | null = null;
280
+ const housekeeping = () => scanned ??= (async () => {
281
+ try {
282
+ const url = `http://127.0.0.1:${ctx.paths.ports.web}${HOUSEKEEPING_PATH}?trigger=doctor`;
283
+ return housekeepingCounts(await (await fetch(url, { signal: AbortSignal.timeout(60_000) })).json());
284
+ } catch {
285
+ return null;
286
+ }
287
+ })();
263
288
  return {
264
289
  ...base,
265
290
  portOpen: canConnect,
@@ -284,13 +309,8 @@ export function systemDoctorDeps(base: LaunchdDeps, ctx: LaunchdContext, kind: I
284
309
  return null;
285
310
  }
286
311
  },
287
- gcCandidates: async () => {
288
- try {
289
- return gcTotal(await (await fetch(`http://127.0.0.1:${ctx.paths.ports.web}${GC_PATH}`, { signal: AbortSignal.timeout(30_000) })).json());
290
- } catch {
291
- return null;
292
- }
293
- },
312
+ gcCandidates: async () => (await housekeeping())?.gc ?? null,
313
+ reconcileCandidates: async () => (await housekeeping())?.reconcile ?? null,
294
314
  pinnedEngine: () => {
295
315
  try {
296
316
  return engineFolder(ctx.paths.omo, readlinkSync(ctx.paths.omoCurrent));
package/src/decisions.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import { decisionContext } from "./decision-context.ts";
3
3
  import { SecurityCheckError, type MessageGuard } from "./security-check.ts";
4
- import { securityDecision, SecurityRuntimeError } from "./security-allow.ts";
4
+ import { artifactReviewDecision, securityDecision, SecurityRuntimeError, type ArtifactSecurityHold } from "./security-allow.ts";
5
+ import { collaborationStageDecision } from "./agent/collaboration-types.ts";
6
+ import { AgentToolPermissionError, parseToolGrantAnswer, type OwnerPrincipal } from "./agent/tool-permissions.ts";
5
7
  import type { OmoEvent } from "./omo/client.ts";
6
8
  import { say, type Text } from "./server-text.ts";
7
- import type { ActionAnswerData, DecisionAnswerRequest, DecisionInfo, DecisionsPageData, DecisionUpdatedData } from "./protocol.ts";
8
- import { blockedMainQuestion, blockedQuestion, DecisionStoreError, RESUMABLE_CLOSED, Store, type Decision, type DecisionMethod, type DecisionSubmission, type StoredEvent } from "./store.ts";
9
+ import type { ActionAnswerData, ArtifactReviewInfo, DecisionAnswerRequest, DecisionInfo, DecisionsPageData, DecisionUpdatedData } from "./protocol.ts";
10
+ import { blockedMainQuestion, blockedQuestion, DecisionStoreError, INSTRUCTION_RELEASED, releasedWorkQuestion, RESUMABLE_CLOSED, Store, type Decision, type DecisionMethod, type DecisionSubmission, type StoredEvent } from "./store.ts";
9
11
 
10
12
  export interface DecisionOwner {
11
13
  readonly conv: string;
@@ -30,6 +32,8 @@ export interface DecisionPorts {
30
32
  sendLive(decision: Decision): boolean;
31
33
  dispatchWork(workId: string): Promise<void>;
32
34
  dispatchMain(input: StoredEvent): Promise<void>;
35
+ /** Wakes the shared collaboration dispatcher of a conversation whose request continuation now holds an owner answer. */
36
+ dispatchCollaboration?(conv: string): Promise<void>;
33
37
  /** Reads existing owning-session evidence. Must not replay an uncertain command. */
34
38
  reconcile(decision: Decision): Promise<void>;
35
39
  }
@@ -141,6 +145,7 @@ export class DecisionService {
141
145
  private stopped = false;
142
146
  private refreshing = false;
143
147
  private readonly receiptMs: number;
148
+ private toolPrincipal: ((req: Request) => OwnerPrincipal | Response) | null = null;
144
149
 
145
150
  constructor(
146
151
  private readonly store: Store,
@@ -164,8 +169,64 @@ export class DecisionService {
164
169
  return decision;
165
170
  }
166
171
 
172
+ /** The caller helper (main.ts) that verifies the owner of a tool purpose answer. */
173
+ useToolPrincipal(resolver: (req: Request) => OwnerPrincipal | Response): void {
174
+ this.toolPrincipal = resolver;
175
+ }
176
+
177
+ /**
178
+ * Tool purpose answers (294 C3): only the structured toolGrant or cancelled body with a verified owner. A plain
179
+ * confirm, value or free text answer stays pending with tool_grant_response_required; no grant or execution is made.
180
+ */
181
+ private answerToolGrant(id: string, input: unknown, principal: OwnerPrincipal | null): DecisionInfo {
182
+ const request = parseToolGrantAnswer(input);
183
+ if (!request) {
184
+ parseDecisionAnswer(input);
185
+ throw new DecisionStoreError("tool_grant_response_required", this.required(id));
186
+ }
187
+ if (!principal) throw new DecisionStoreError("tool_grant_principal_required", this.required(id));
188
+ let answered: ReturnType<Store["answerAgentToolDecision"]>;
189
+ try {
190
+ answered = this.store.answerAgentToolDecision(id, request, principal);
191
+ } finally {
192
+ this.refresh();
193
+ }
194
+ // Only a freshly committed allow carries an execution (deny/cancel have none); a replayed submission never runs again.
195
+ const invocationId = answered.fresh ? answered.result?.invocationId ?? null : null;
196
+ if (invocationId) {
197
+ try { this.toolGranted?.(invocationId); }
198
+ catch (error) { this.error(error); }
199
+ }
200
+ if (answered.fresh) {
201
+ try { this.toolAnswered?.(id); }
202
+ catch (error) { this.error(error); }
203
+ }
204
+ return this.info(this.required(id));
205
+ }
206
+
207
+ private toolGranted: ((invocationId: string) => void) | undefined;
208
+
209
+ /** 294 item 5: called once after an owner allow commits; main.ts starts the delivery of that execution. */
210
+ useToolGranted(listener: (invocationId: string) => void): void {
211
+ this.toolGranted = listener;
212
+ }
213
+
214
+ private toolAnswered: ((decisionId: string) => void) | undefined;
215
+
216
+ /** Q-07: called once after any owner answer of a tool card commits (allow, deny or cancel); Hub continues a collaboration card. */
217
+ useToolAnswered(listener: (decisionId: string) => void): void {
218
+ this.toolAnswered = listener;
219
+ }
220
+
167
221
  info(decision: Decision): DecisionInfo {
222
+ if (collaborationStageDecision(decision)) return { ...decision, work: null, allowedActions: [] };
223
+ if (this.store.toolPermissions.isToolPurpose(decision.id)) {
224
+ return { ...decision, work: null, allowedActions: decision.status === "pending" && decision.submissionId === null ? ["answer"] : [] };
225
+ }
168
226
  if (securityDecision(decision)) {
227
+ // A held artifact read is answered through the owner hold route; the generic answer stays refused (security_endpoint_required).
228
+ const hold = artifactReviewDecision(decision) ? this.store.artifactSecurityHold(decision.id) : null;
229
+ if (hold) return { ...decision, work: null, allowedActions: hold.state === "pending" ? ["answer"] : [], artifactReview: this.artifactReview(hold) };
169
230
  const security = this.store.securityHold(decision.id)?.state;
170
231
  return { ...decision, work: null, allowedActions: [], ...(security ? { security } : {}) };
171
232
  }
@@ -194,6 +255,15 @@ export class DecisionService {
194
255
  };
195
256
  }
196
257
 
258
+ /** The owner's view of a held artifact read: the hold state, the reading agent, the file, the part asked for and the finding summary. */
259
+ private artifactReview(hold: ArtifactSecurityHold): ArtifactReviewInfo {
260
+ const artifact = this.store.agentArtifact(hold.resourceRef.artifactId);
261
+ return { holdId: hold.id, revision: hold.revision, state: hold.state, resumeState: hold.continuation.state,
262
+ agentId: this.store.getConversation(hold.readScope.granteeConv)?.agentId ?? null,
263
+ requestId: hold.resourceRef.requestId, artifactId: hold.resourceRef.artifactId, fileName: artifact?.originalName || hold.resourceRef.artifactId,
264
+ view: hold.representation.view, range: { start: hold.representation.range.start, end: hold.representation.range.end }, summary: hold.summary ?? null };
265
+ }
266
+
197
267
  list(options: { limit?: number; cursor?: string } = {}): DecisionsPageData {
198
268
  this.refresh();
199
269
  const page = this.store.listDecisions(options);
@@ -208,6 +278,7 @@ export class DecisionService {
208
278
  answer(id: string, input: unknown, checked = false): DecisionInfo {
209
279
  const original = this.store.getDecision(id);
210
280
  if (original && securityDecision(original)) throw new DecisionStoreError("security_endpoint_required");
281
+ if (original && this.store.toolPermissions.isToolPurpose(id)) return this.answerToolGrant(id, input, null);
211
282
  const request = parseDecisionAnswer(input);
212
283
  if (!checked) {
213
284
  const owner = this.store.getDecision(id)?.ownerConv ?? "main";
@@ -248,6 +319,11 @@ export class DecisionService {
248
319
  this.dispatch(id, () => this.ports.dispatchMain(inputEvent), checked);
249
320
  } else if (operation?.data.commandId && decision.workId) {
250
321
  this.dispatch(id, () => this.ports.dispatchWork(decision.workId!), checked);
322
+ } else if (operation?.data.commandId?.startsWith("collaboration:") && operation.data.commandId !== "collaboration:ended") {
323
+ const wake = this.ports.dispatchCollaboration;
324
+ // The answer also unblocks queued input elsewhere in the same request tree; those conversations get no other wake.
325
+ const convs = new Set([decision.ownerConv, ...this.store.agentDecisionDependents(id)]);
326
+ if (wake) this.dispatch(id, () => Promise.all([...convs].map(conv => wake(conv))).then(() => undefined), checked);
251
327
  }
252
328
  this.refresh();
253
329
  return this.info(this.required(id));
@@ -311,7 +387,7 @@ export class DecisionService {
311
387
  throw new DecisionStoreError("invalid_decision_answer", decision);
312
388
  }
313
389
  // A main question kept open without its native wait takes a chat-card answer as a resume.
314
- const released = decision.bindingState === "closed" && decision.reason === "main_released";
390
+ const released = (decision.bindingState === "closed" && decision.reason === "main_released") || releasedWorkQuestion(decision);
315
391
  if (!supplied && !released && (decision.bindingState !== "live" || !this.ports.liveBinding(decision))) return false;
316
392
  const response: Record<string, unknown> = {};
317
393
  if (data.cancel !== undefined) {
@@ -387,6 +463,20 @@ export class DecisionService {
387
463
  return true;
388
464
  }
389
465
 
466
+ /**
467
+ * Bug 236 (operator decision 2026-11-05): a newer instruction was queued behind a work's turn that waits on this
468
+ * required question. The work's native wait ends so the instruction runs; the question stays open in the inbox and its
469
+ * answer reaches the work later as a follow-up command. True when the caller must now end the native wait.
470
+ */
471
+ releaseWorkWait(id: string): boolean {
472
+ if (this.stopped) return false;
473
+ const decision = this.required(id);
474
+ if (decision.workId === null || !blockedQuestion(decision) || decision.status !== "pending" || decision.submissionId !== null) return false;
475
+ this.store.setDecisionBinding(decision.id, decision.revision, { state: "closed", reason: INSTRUCTION_RELEASED });
476
+ this.refresh();
477
+ return true;
478
+ }
479
+
390
480
  /**
391
481
  * Improvement 77: a required question that a generation handoff parked comes back from the new generation with a new
392
482
  * request id (the frame id is a fresh routing id; the toolCallId stays). Exactly one unanswered decision of the same
@@ -535,7 +625,7 @@ export class DecisionService {
535
625
  this.store.reconcileDecisionReceipts();
536
626
  const now = Date.now();
537
627
  for (let decision of this.store.allDecisions()) {
538
- if (securityDecision(decision)) continue;
628
+ if (securityDecision(decision) || collaborationStageDecision(decision) || this.store.toolPermissions.isToolPurpose(decision.id)) continue;
539
629
  if (!unresolved(decision)) continue;
540
630
  const context = this.store.decisionContextReason(decision);
541
631
  if (context === "work_failed" || context === "work_cancelled") {
@@ -563,7 +653,7 @@ export class DecisionService {
563
653
  }
564
654
  }
565
655
  for (const decision of this.store.allDecisions()) {
566
- if (securityDecision(decision)) continue;
656
+ if (securityDecision(decision) || collaborationStageDecision(decision)) continue;
567
657
  if (decision.method === "question" && decision.status === "pending" && decision.bindingState === "live") {
568
658
  this.store.reconcileReissuedQuestion(decision.id, this.ports.liveBinding(decision));
569
659
  }
@@ -574,7 +664,7 @@ export class DecisionService {
574
664
  const previous = this.known.get(decision.id);
575
665
  this.known.set(decision.id, decision);
576
666
  if (!previous || previous.revision < decision.revision) {
577
- if (!securityDecision(decision) && previous && previous.status !== decision.status && !unresolved(decision)) {
667
+ if (!securityDecision(decision) && !collaborationStageDecision(decision) && previous && previous.status !== decision.status && !unresolved(decision)) {
578
668
  const answer = record(decision.answer) ? decision.answer : {};
579
669
  const kind = decision.method === "question" ? "question" : decision.method === "confirm" ? "confirm" : "value";
580
670
  const dismissedText = decision.status === "closed" && Object.hasOwn(DISMISSED_REASONS, decision.reason ?? "") ? DISMISSED_REASONS[decision.reason ?? ""] : undefined;
@@ -596,7 +686,7 @@ export class DecisionService {
596
686
  }
597
687
  const times: number[] = [];
598
688
  if (unresolved(decision) && decision.bindingState === "live" && decision.deadlineAt !== null) times.push(decision.deadlineAt);
599
- if (!securityDecision(decision) && decision.status === "submitted") {
689
+ if (!securityDecision(decision) && !collaborationStageDecision(decision) && decision.status === "submitted") {
600
690
  const operation = this.store.latestDecisionOperation(decision.id);
601
691
  times.push(Date.parse(operation?.ts ?? decision.updatedAt) + this.receiptMs);
602
692
  }
@@ -637,12 +727,17 @@ export class DecisionService {
637
727
  if (route[2] === "/dismiss" && req.method === "POST") {
638
728
  return Response.json(this.dismiss(id, await req.json().catch(() => null)));
639
729
  }
730
+ if (route[2] === "/answer" && req.method === "POST" && this.store.toolPermissions.isToolPurpose(id)) {
731
+ const principal = this.toolPrincipal ? this.toolPrincipal(req) : null;
732
+ if (principal instanceof Response) return principal;
733
+ return Response.json(this.answerToolGrant(id, await req.json().catch(() => null), principal));
734
+ }
640
735
  if (route[2] && req.method === "POST") {
641
736
  return Response.json(this.answer(id, await req.json().catch(() => null)));
642
737
  }
643
738
  if (!route[2] && req.method === "GET") {
644
739
  const decision = this.required(id);
645
- if (!securityDecision(decision) && unresolved(decision)) {
740
+ if (!securityDecision(decision) && !collaborationStageDecision(decision) && unresolved(decision)) {
646
741
  let timer: ReturnType<typeof setTimeout> | undefined;
647
742
  try {
648
743
  await Promise.race([
@@ -673,7 +768,11 @@ export class DecisionService {
673
768
  if (error instanceof DecisionStoreError) {
674
769
  return Response.json({
675
770
  error: error.code, decision: error.decision ? this.info(error.decision) : null,
676
- }, { status: error.code === "decision_not_found" ? 404 : error.code === "security_endpoint_required" || error.code.startsWith("invalid_") ? 400 : 409 });
771
+ }, { status: error.code === "decision_not_found" ? 404 : error.code === "tool_grant_principal_required" ? 403
772
+ : error.code === "security_endpoint_required" || error.code === "tool_grant_response_required" || error.code.startsWith("invalid_") ? 400 : 409 });
773
+ }
774
+ if (error instanceof AgentToolPermissionError) {
775
+ return Response.json({ error: error.code }, { status: error.status, headers: { "Cache-Control": "no-store" } });
677
776
  }
678
777
  if (error instanceof URIError) return Response.json({ error: "invalid_decision" }, { status: 400 });
679
778
  this.error(error);
@@ -6,6 +6,12 @@ export function patchPath(value: string): string {
6
6
  return value;
7
7
  }
8
8
 
9
+ /**
10
+ * A file section whose blocks start with this line replaces text instead of lines: `-<old>` then `+<new>`, where <old>
11
+ * occurs exactly once in the file. For one-line minified files, so a patch never repeats the whole line.
12
+ */
13
+ export const REPLACE_BLOCK = "@@ replace @@";
14
+
9
15
  export function applyPatch(patch: string, read: (path: string) => string): Map<string, string> {
10
16
  const lines = patch.split("\n");
11
17
  if (lines.pop() !== "") throw new Error("patch_missing_final_newline");
@@ -18,6 +24,21 @@ export function applyPatch(patch: string, read: (path: string) => string): Map<s
18
24
  const path = patchPath(header.slice(6).split("\t")[0]!);
19
25
  const next = lines[i++] ?? "";
20
26
  if (!next.startsWith("+++ b/") || next.slice(6).split("\t")[0] !== path) throw new Error("patch_new_header_invalid");
27
+ if (lines[i] === REPLACE_BLOCK) {
28
+ let text = result.get(path) ?? read(path);
29
+ while (lines[i] === REPLACE_BLOCK) {
30
+ i++;
31
+ const from = lines[i++] ?? "";
32
+ const to = lines[i++] ?? "";
33
+ if (!from.startsWith("-") || from.length < 2 || !to.startsWith("+")) throw new Error(`patch_replace_invalid:${path}`);
34
+ const old = from.slice(1);
35
+ const at = text.indexOf(old);
36
+ if (at < 0 || text.includes(old, at + 1)) throw new Error(`patch_replace_match:${path}`);
37
+ text = text.slice(0, at) + to.slice(1) + text.slice(at + old.length);
38
+ }
39
+ result.set(path, text);
40
+ continue;
41
+ }
21
42
  const source = (result.get(path) ?? read(path)).match(/[^\n]*\n|[^\n]+$/g) ?? [];
22
43
  const output: string[] = [];
23
44
  let cursor = 0;
@@ -2,5 +2,6 @@
2
2
  "omo": "5.1.51",
3
3
  "senpi": "2026.10.27-1",
4
4
  "threadSchema": 10,
5
- "verifiedAt": "2026-10-31"
5
+ "capabilities": ["agent_tool_authority"],
6
+ "verifiedAt": "2026-11-11"
6
7
  }
@@ -196,8 +196,45 @@ async function bounded<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
196
196
  } finally { clearTimeout(timer); }
197
197
  }
198
198
 
199
+ export const ENGINE_CAPABILITY_MISSING = "engine_capability_missing";
200
+
201
+ type EngineCommand = (type: string, params: Record<string, unknown>) => Promise<unknown>;
202
+
203
+ /** One live proof per engine capability the gateway pin requires (compat.json `capabilities`). */
204
+ const CAPABILITY_PROBES: Readonly<Record<string, (command: EngineCommand, sessionId: string, candidate: string) => Promise<boolean>>> = {
205
+ // 294: the host pushes a revisioned policy and the engine acknowledges exactly it. Governed eval needs QuickJS.
206
+ async agent_tool_authority(command, sessionId, candidate) {
207
+ if (!existsSync(join(candidate, "node_modules/quickjs-wasi/package.json"))) return false;
208
+ const ack = await command("zgw.agent_tool_policy", { sessionId, policy: { revision: 1, grants: [], missing: "deny" } }) as
209
+ { readonly revision?: unknown; readonly grants?: unknown; readonly missing?: unknown } | null | undefined;
210
+ return ack?.revision === 1 && ack.grants === 0 && ack.missing === "deny";
211
+ },
212
+ };
213
+
214
+ /** Refuses the candidate unless every required capability passes its probe; a failed command is a missing capability. */
215
+ export async function proveCapabilities(command: EngineCommand, sessionId: string, candidate: string, capabilities: readonly string[]): Promise<void> {
216
+ for (const capability of capabilities) {
217
+ const probe = Object.hasOwn(CAPABILITY_PROBES, capability) ? CAPABILITY_PROBES[capability] : undefined;
218
+ if (probe === undefined) throw new Error(`engine_capability_unknown:${capability}`);
219
+ let proven: boolean;
220
+ try {
221
+ proven = await probe(command, sessionId, candidate);
222
+ } catch (error) {
223
+ throw new Error(`${ENGINE_CAPABILITY_MISSING}:${capability}`, { cause: error });
224
+ }
225
+ if (!proven) throw new Error(`${ENGINE_CAPABILITY_MISSING}:${capability}`);
226
+ }
227
+ }
228
+
229
+ /** The capability list of the compatibility pin; anything but an array of names is an invalid pin. */
230
+ export function pinCapabilities(pin: { readonly capabilities?: unknown }): readonly string[] {
231
+ if (pin.capabilities === undefined) return [];
232
+ if (!Array.isArray(pin.capabilities) || !pin.capabilities.every((name) => typeof name === "string")) throw new Error("engine_pin_invalid:capabilities");
233
+ return pin.capabilities;
234
+ }
235
+
199
236
  /** A private socket host, relayed to OmoClient, exercises the real protocol without any model calls. */
200
- export async function smokeEngine(home: string, candidate: string, options: { readonly port?: number; readonly timeoutMs?: number } = {}): Promise<void> {
237
+ export async function smokeEngine(home: string, candidate: string, options: { readonly port?: number; readonly timeoutMs?: number; readonly capabilities?: readonly string[] } = {}): Promise<void> {
201
238
  const scratch = mkdtempSync(join(home, "omo/.smoke-"));
202
239
  const socket = join(scratch, "rpc.sock");
203
240
  const timeout = options.timeoutMs ?? 30_000;
@@ -282,6 +319,8 @@ export async function smokeEngine(home: string, candidate: string, options: { re
282
319
  const opened = await bounded(client.command<{ sessionId: string }>("open_session", { cwd: scratch }), timeout);
283
320
  const tools = await bounded(client.command<string[]>("extension_request", { sessionId: opened.sessionId, name: "zigmini.install.probe" }), timeout);
284
321
  if (!tools.includes("zigmini_install_probe")) throw new Error("engine_smoke_extension_missing");
322
+ const live = client;
323
+ await bounded(proveCapabilities((type, params) => live.command(type, params), opened.sessionId, candidate, options.capabilities ?? []), timeout);
285
324
  await bounded(client.command("close_session", { sessionId: opened.sessionId }), timeout);
286
325
  } finally {
287
326
  client?.stop();
@@ -321,10 +360,12 @@ export async function installEngine(options: {
321
360
  readonly acceptLicense?: boolean;
322
361
  readonly engineOnly?: boolean;
323
362
  readonly confirmLicense?: (summary: string, path: string) => Promise<boolean>;
324
- readonly smoke?: { readonly port?: number; readonly timeoutMs?: number };
363
+ readonly smoke?: { readonly port?: number; readonly timeoutMs?: number; readonly capabilities?: readonly string[] };
325
364
  }): Promise<string> {
326
- const pin: { readonly omo: string; readonly threadSchema?: number } = JSON.parse(readFileSync(new URL("./compat.json", import.meta.url), "utf8"));
365
+ const pin: { readonly omo: string; readonly threadSchema?: number; readonly capabilities?: unknown } = JSON.parse(readFileSync(new URL("./compat.json", import.meta.url), "utf8"));
327
366
  const version = options.version ?? pin.omo;
367
+ // The pinned build must prove the pin's capabilities before `current` moves; running engines are never stopped here.
368
+ const capabilities = options.smoke?.capabilities ?? (version === pin.omo ? pinCapabilities(pin) : []);
328
369
  if (options.agentDir !== undefined && version === pin.omo) {
329
370
  const found = storeIncompatibility(readStoreSchema(options.agentDir), pin.threadSchema ?? null);
330
371
  if (found !== null) throw new Error(`${ENGINE_SCHEMA_INCOMPATIBLE}:store=${found.store},supported=${found.supported}`);
@@ -335,7 +376,7 @@ export async function installEngine(options: {
335
376
  const candidate = join(omoDir, target);
336
377
  if (options.agentDir !== undefined) assertStoreCompatible(options.agentDir, candidate);
337
378
  await acceptEngineLicense(options.home, candidate, options);
338
- await smokeEngine(options.home, candidate, options.smoke);
379
+ await smokeEngine(options.home, candidate, { ...options.smoke, capabilities });
339
380
  candidateExecutable(candidate);
340
381
  const current = join(omoDir, "current");
341
382
  if (existsSync(current) && !lstatSync(current).isSymbolicLink()) throw new Error("engine_current_not_symlink");
@@ -0,0 +1,2 @@
1
+ /** 294 item 6: the engine's sandbox cell module, relative to the pinned engine root (agent-execution runs it confined). */
2
+ export const SANDBOX_CELL_MODULE = "node_modules/@code-yeongyu/senpi-codemode/src/kernels/sandbox/sandbox-cell.ts";