zigmini 1.0.0-beta.3 → 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.
- package/README.ko.md +27 -2
- package/README.md +27 -2
- package/assets/agent/gateway-request-rules.md +1 -1
- package/assets/agent/gateway-rules.md +6 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/assets/document-reader/document-reader +0 -0
- package/assets/document-reader/host-selector +0 -0
- package/assets/document-reader/manifest.json +27 -0
- package/docs/manual.ko.md +114 -10
- package/docs/manual.md +117 -11
- package/host/document-reader/DocumentRead.swift +370 -0
- package/host/document-reader/HostSelector.swift +192 -0
- package/host/document-reader/Protocol.swift +647 -0
- package/host/document-reader/main.swift +22 -0
- package/host/omo-bridge/agent-aside-tool.ts +119 -0
- package/host/omo-bridge/agent-aside.ts +522 -0
- package/host/omo-bridge/agent-collaboration-files.ts +282 -0
- package/host/omo-bridge/agent-collaboration.ts +385 -0
- package/host/omo-bridge/agent-control.ts +400 -28
- package/host/omo-bridge/agent-execution.sb +42 -0
- package/host/omo-bridge/agent-execution.ts +158 -0
- package/host/omo-bridge/agent-tool-adapters.ts +388 -0
- package/host/omo-bridge/agent-tool-permissions.ts +96 -0
- package/host/omo-bridge/aside-tabs.ts +41 -0
- package/host/omo-bridge/attachment-read.ts +100 -0
- package/host/omo-bridge/bridge.ts +10 -0
- package/host/omo-bridge/managed-call.ts +46 -0
- package/host/omo-bridge/task-control.ts +39 -5
- package/host/omo-check/patches/attachments-293/README.md +138 -0
- package/host/omo-check/patches/attachments-293/document-content.js +234 -0
- package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
- package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +28 -6
- package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
- package/package.json +5 -3
- package/src/agent/api.ts +345 -20
- package/src/agent/artifacts.ts +196 -0
- package/src/agent/aside-target-routes.ts +369 -0
- package/src/agent/aside-targets.ts +215 -0
- package/src/agent/collaboration-admission.ts +47 -0
- package/src/agent/collaboration-read.ts +117 -0
- package/src/agent/collaboration-session.ts +196 -0
- package/src/agent/collaboration-types.ts +258 -0
- package/src/agent/collaboration.ts +399 -0
- package/src/agent/definition.ts +65 -3
- package/src/agent/fields.ts +9 -3
- package/src/agent/file-core.ts +325 -0
- package/src/agent/files.ts +87 -8
- package/src/agent/list.ts +3 -3
- package/src/agent/managed-call.ts +144 -0
- package/src/agent/tool-catalog.ts +184 -0
- package/src/agent/tool-delivery.ts +359 -0
- package/src/agent/tool-permission-types.ts +81 -0
- package/src/agent/tool-permissions.ts +1366 -0
- package/src/attachments/api.ts +161 -0
- package/src/attachments/document-reader.ts +325 -0
- package/src/attachments/host-selector.ts +410 -0
- package/src/attachments/managed-binding.ts +83 -0
- package/src/attachments/prepare.ts +214 -0
- package/src/attachments/selector-port.ts +28 -0
- package/src/attachments/service.ts +729 -0
- package/src/attachments/store.ts +698 -0
- package/src/attachments/types.ts +123 -0
- package/src/cli/doctor.ts +30 -10
- package/src/decisions.ts +109 -10
- package/src/engine/apply-patch.ts +21 -0
- package/src/engine/compat.json +2 -1
- package/src/engine/install.ts +45 -4
- package/src/engine/sandbox-cell.ts +2 -0
- package/src/gc.ts +33 -225
- package/src/housekeeping/api.ts +163 -0
- package/src/housekeeping/audit.ts +61 -0
- package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
- package/src/housekeeping/collectors/agent-drafts.ts +43 -0
- package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
- package/src/housekeeping/collectors/agent-trash.ts +28 -0
- package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
- package/src/housekeeping/collectors/db-backup.ts +25 -0
- package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
- package/src/housekeeping/collectors/docker-image.ts +135 -0
- package/src/housekeeping/collectors/omo-version.ts +32 -0
- package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
- package/src/housekeeping/collectors/reconcile-work.ts +55 -0
- package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
- package/src/housekeeping/collectors/shared.ts +149 -0
- package/src/housekeeping/collectors/temp-dir.ts +28 -0
- package/src/housekeeping/collectors/update-file.ts +25 -0
- package/src/housekeeping/collectors/worktree.ts +193 -0
- package/src/housekeeping/engine.ts +125 -0
- package/src/housekeeping/reconcile/work.ts +130 -0
- package/src/housekeeping/registry.ts +63 -0
- package/src/housekeeping/trash.ts +59 -0
- package/src/housekeeping/types.ts +31 -0
- package/src/hub.ts +1095 -41
- package/src/main.ts +228 -30
- package/src/message-references.ts +14 -5
- package/src/profile-prompt.ts +13 -1
- package/src/protocol.ts +41 -6
- package/src/security-allow.ts +79 -2
- package/src/security-check.ts +1 -1
- package/src/store.ts +1946 -23
- package/src/test-crash.ts +20 -0
- package/src/work-watch.ts +55 -7
- package/src/work.ts +192 -3
- package/web/DESIGN.md +33 -0
- package/web/agents.d.ts +189 -12
- package/web/agents.js +898 -39
- package/web/app.js +2634 -210
- package/web/attachments.d.ts +91 -0
- package/web/attachments.js +422 -0
- package/web/collaboration.d.ts +87 -0
- package/web/collaboration.js +290 -0
- package/web/decisions.d.ts +39 -2
- package/web/decisions.js +354 -2
- package/web/gc.d.ts +26 -5
- package/web/gc.js +113 -13
- package/web/i18n/agents.d.ts +4 -0
- package/web/i18n/agents.js +200 -8
- package/web/i18n/attachments.js +78 -0
- package/web/i18n/decisions.d.ts +4 -0
- package/web/i18n/decisions.js +68 -0
- package/web/i18n/gc.d.ts +4 -0
- package/web/i18n/gc.js +184 -2
- package/web/i18n/main.js +80 -8
- package/web/i18n/tour.d.ts +4 -0
- package/web/i18n/tour.js +37 -0
- package/web/index.html +20 -3
- package/web/profile.d.ts +2 -2
- package/web/profile.js +11 -6
- package/web/style.css +342 -21
- package/web/tooltip.d.ts +2 -0
- package/web/tooltip.js +12 -0
- package/web/tour.d.ts +57 -0
- 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 {
|
|
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
|
-
|
|
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 === "
|
|
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;
|
package/src/engine/compat.json
CHANGED
package/src/engine/install.ts
CHANGED
|
@@ -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");
|