zigmini 0.9.0-beta.1 → 0.9.0-beta.3

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/src/hub.ts CHANGED
@@ -13,7 +13,7 @@ import { isVersionCommand, VERSION_COMMAND } from "./version.ts";
13
13
  import { isUsageCommand, USAGE_COMMAND } from "./usage.ts";
14
14
  import { MessageReferenceError, referencedInput, resolveMessageReferences } from "./message-references.ts";
15
15
  import { readProfile } from "./profile.ts";
16
- import type { MessageGuard } from "./security-check.ts";
16
+ import type { MessageGuard, SecurityChecker } from "./security-check.ts";
17
17
  import { SecurityRuntimeError, type SecurityReceipt } from "./security-allow.ts";
18
18
  import { nativeReplyEntry, type NativeReplyObserver } from "./reply-notifications.ts";
19
19
  import { notificationHistory } from "./notification-history.ts";
@@ -52,7 +52,7 @@ export interface HubOptions {
52
52
  /** Where attached images live (`dir`, as this process sees it) and the same folder's path on the Mac, for the agent. */
53
53
  media?: { dir: string; hostDir?: string };
54
54
  now?: () => number;
55
- rotation?: { maxAgeMs?: number; idleMs?: number; forceMaxBytes?: number; forceUpdateCompactions?: number };
55
+ rotation?: { maxAgeMs?: number; idleMs?: number; forceMaxBytes?: number; forceUpdateCompactions?: number; freshSessionCompactionFailures?: number };
56
56
  /** Bug 152: bounded native ownership reconciliation; timers are injectable for deterministic tests. */
57
57
  closeRecovery?: {
58
58
  intervalMs?: number;
@@ -87,9 +87,62 @@ const COMPACTION_RECOVERY = {
87
87
  context_budget: { action: "retry", message: "요약 결과가 대화 모델의 입력 한도에 맞지 않습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
88
88
  stale: { action: "retry", message: "요약 중 대화가 바뀌었습니다. 최신 대화에서 다시 진행해 주세요." },
89
89
  provider: { action: "retry", message: "원인을 해결한 뒤 다시 진행해 주세요." },
90
+ account_blocked: { action: "account", message: "요약 공급자의 계정 묶음이 요청을 보내지 않았습니다. 원인이 사용 한도인지 인증인지는 확정되지 않았습니다. 계정 상태를 확인하거나 다른 모델을 고른 뒤 다시 진행해 주세요." },
90
91
  } as const satisfies Record<CompactionFailureKind, { readonly action: string; readonly message: string }>;
91
92
 
92
- export type RotationReason = "manual" | "age" | ForcedRotationReason;
93
+ /** Bug 223: the longest provider error a compaction failure log line keeps, after its sensitive values are removed. */
94
+ export const COMPACTION_ERROR_MAX_CHARS = 512;
95
+
96
+ /**
97
+ * Bug 223: the provider's own error text with every value the repository security check finds (tokens, keys,
98
+ * credentials, home paths, e-mail, internal addresses, device ids) replaced by [redacted], on one line, at most
99
+ * COMPACTION_ERROR_MAX_CHARS. The cause wording stays. Values are found before the text is cut.
100
+ */
101
+ export function sanitizeProviderError(text: unknown, checker: Pick<SecurityChecker, "runtimeOccurrences">): string | null {
102
+ if (typeof text !== "string") return null;
103
+ const withoutPrivateKeys = text.replace(/-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----|$)/g, "[redacted]");
104
+ const flat = withoutPrivateKeys.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ").replace(/\s+/g, " ").trim();
105
+ if (!flat) return null;
106
+ const spans = checker.runtimeOccurrences([flat])
107
+ .map((found) => [found.start ?? 0, found.end ?? flat.length] as const).sort((a, b) => a[0] - b[0]);
108
+ let kept = "";
109
+ let at = 0;
110
+ for (const [start, end] of spans) {
111
+ if (end <= at) continue;
112
+ if (start >= at) kept += `${flat.slice(at, start)}[redacted]`;
113
+ at = end;
114
+ }
115
+ kept += flat.slice(at);
116
+ return kept.length > COMPACTION_ERROR_MAX_CHARS ? `${kept.slice(0, COMPACTION_ERROR_MAX_CHARS)}…` : kept;
117
+ }
118
+
119
+ /**
120
+ * Bug 223: the fields of one gateway log line per failed blocking compaction. A provider/model the engine did not list
121
+ * or a malformed number never reaches the log. The provider error is kept only through sanitizeProviderError;
122
+ * errorMessage and other free text are never read.
123
+ */
124
+ export function compactionFailureLog(failureKind: CompactionFailureKind, failure: unknown, knownModels: ReadonlySet<string>,
125
+ sanitize: (text: unknown) => string | null = () => null): Record<string, unknown> {
126
+ const fields = typeof failure === "object" && failure !== null ? failure as Record<string, unknown> : {};
127
+ const provider = typeof fields.provider === "string" ? fields.provider : "";
128
+ const model = typeof fields.model === "string" ? fields.model : "";
129
+ const listed = knownModels.has(`${provider}/${model}`);
130
+ const tokens = fields.inputTokens;
131
+ const retryAt = fields.retryAt;
132
+ return {
133
+ failureKind,
134
+ provider: listed ? provider : "unknown",
135
+ model: listed ? model : "unknown",
136
+ error: sanitize(fields.error) ?? "unknown",
137
+ inputTokens: typeof tokens === "number" && Number.isSafeInteger(tokens) && tokens >= 0 ? tokens : "unknown",
138
+ inputTokensSource: fields.inputTokensSource === "estimate" || fields.inputTokensSource === "provider" ? fields.inputTokensSource : "unknown",
139
+ requestSent: typeof fields.requestSent === "boolean" ? fields.requestSent : "unknown",
140
+ ...(typeof retryAt === "number" && Number.isSafeInteger(retryAt) && retryAt > 0 && retryAt < 8.64e15
141
+ ? { retryAt: new Date(retryAt).toISOString() } : {}),
142
+ };
143
+ }
144
+
145
+ export type RotationReason = "manual" | "age" | "compaction_failed" | ForcedRotationReason;
93
146
  /** Bug 72 proposal 4: limits that rotate a session even with works, updates, decisions or questions open. */
94
147
  export type ForcedRotationReason = "size" | "compactions";
95
148
 
@@ -106,6 +159,10 @@ export const ROTATION_HELD_LOG_MS = 6 * 60 * 60 * 1000;
106
159
  export const ROTATION_RETRY_MS = 30 * 60 * 1000;
107
160
  /** Bug 72: completed turns that must carry an unconsumed user input before an idle native releases it from the turn. */
108
161
  export const UNCONSUMED_INPUT_RELEASE_TURNS = 2;
162
+ /** Bug 224: the engine's exact refusal of a prompt before accepting it while compaction is unfinished (RequiredCompactionError). */
163
+ export const REQUIRED_COMPACTION_REFUSAL = "Context remains above the compaction threshold because compaction did not complete";
164
+ /** Bug 224 (user decision 2026-10-29): failed compactions in a row of one session that open a fresh session. */
165
+ export const FRESH_SESSION_COMPACTION_FAILURES = 2;
109
166
 
110
167
  /** Bug 72: one condition that now holds a conversation's session rotation. */
111
168
  export interface RotationBlocker {
@@ -147,6 +204,7 @@ function describeBlockers(blockers: readonly RotationBlocker[]): string {
147
204
  const HANDOFF_MAX_CHARS = 8000;
148
205
  const HANDOFF_LINE_CHARS = 600;
149
206
  const CONTEXT_OVERFLOW = /prompt is too long|context overflow/i;
207
+ const COMPACTION_REQUIRED_MESSAGE = "대화 요약이 끝나지 않아 엔진이 이 메시지를 받지 않았습니다. 메시지는 기록에 남아 있고 자동으로 다시 보내지 않습니다. 요약 실패 원인을 해결하거나 '새 세션'을 눌러 주세요.";
150
208
 
151
209
  interface OpenResult {
152
210
  sessionId: string;
@@ -189,6 +247,7 @@ interface ConvRuntime {
189
247
  run: number;
190
248
  rotating: boolean;
191
249
  admitting: number;
250
+ promptAdmissions: Set<() => void>;
192
251
  clientInputIdentity: boolean;
193
252
  atomicQueuedResume: boolean;
194
253
  /** `open_session` state.sessionId: the session file's own identity, which survives reconnects. */
@@ -659,6 +718,8 @@ export class Hub {
659
718
 
660
719
  /** Whether the last model answer named a held switch; the next settled main turn re-reads it. */
661
720
  private modelPending = false;
721
+ private readonly knownModels = new Set<string>();
722
+ private providerErrorChecker: SecurityChecker | null = null;
662
723
 
663
724
  /** The models omo can use right now (its logged-in providers) and the one a conversation's session is on. */
664
725
  async models(convId = "main"): Promise<ModelInfo> {
@@ -671,6 +732,7 @@ export class Hub {
671
732
  omo.command<{ model?: OmoModel | null; thinkingLevel?: string; pendingModelSwitch?: { provider: string; id: string } | null }>("get_state", { sessionId }),
672
733
  ]);
673
734
  const options = modelOptions(available.models ?? []);
735
+ for (const option of options) this.knownModels.add(`${option.provider}/${option.id}`);
674
736
  const held = state.pendingModelSwitch;
675
737
  const pending = held
676
738
  ? options.find((o) => o.provider === held.provider && o.id === held.id) ?? { provider: held.provider, id: held.id, name: held.id }
@@ -1185,7 +1247,8 @@ export class Hub {
1185
1247
  if (!conv) throw new Error(`unknown_conversation:${convId}`);
1186
1248
  const rt = this.rt(convId);
1187
1249
  const forced = reason === "size" || reason === "compactions";
1188
- if (rt.rotating || this.rotationHeldFor(convId, forced)) throw new Error("turn_running");
1250
+ const residue = this.residualTurn(convId, reason);
1251
+ if (rt.rotating || (this.rotationHeldFor(convId, forced) && residue === null)) throw new Error("turn_running");
1189
1252
  if (this.omoStatus !== "ready" || this.closeUncertain(convId)) throw new Error("session_ownership_unknown");
1190
1253
  const generation = this.generation;
1191
1254
  const from = conv.threadId;
@@ -1243,7 +1306,7 @@ export class Hub {
1243
1306
  rt.sessionId !== sessionId ||
1244
1307
  from !== source ||
1245
1308
  this.store.lastSeq(convId, "message.user") !== admittedUser ||
1246
- this.rotationHeldFor(convId, forced)
1309
+ (this.rotationHeldFor(convId, forced) && (residue === null || this.residualTurn(convId, reason) !== residue))
1247
1310
  ) {
1248
1311
  throw new Error("turn_running");
1249
1312
  }
@@ -1288,6 +1351,92 @@ export class Hub {
1288
1351
  }
1289
1352
  }
1290
1353
 
1354
+ /**
1355
+ * Bug 224: a manual rotation may retire a session whose main turn left only residue: inputs nothing runs any more and
1356
+ * a work wake whose send already ended. Returns that residue's identity, or null when anything else holds. rotateSession
1357
+ * still proves the engine idle (no streaming, compaction, retry, queue or question) before it closes the session.
1358
+ */
1359
+ private residualTurn(conv: string, reason: RotationReason): string | null {
1360
+ if (reason !== "manual") return null;
1361
+ const rt = this.rt(conv);
1362
+ const holds = this.rotationHoldsFor(conv, false);
1363
+ if (holds.length === 0 || this.store.pendingMainUsers(conv).length > 0) return null;
1364
+ const residual = holds.every((hold) => hold.kind === "main_turn" &&
1365
+ (hold.state === "unfinished_turn" || (hold.state === "waking" && rt.waking === null)));
1366
+ if (!residual) return null;
1367
+ const turn = this.store.mainTurn(conv);
1368
+ return JSON.stringify({ turn: turn?.seq ?? null, inputs: turn?.inputs.map((input) => input.seq) ?? [], wake: rt.wake?.input.seq ?? null });
1369
+ }
1370
+
1371
+ /**
1372
+ * Bug 224 (user decision 2026-10-29): FRESH_SESSION_COMPACTION_FAILURES failed compactions in a row open a fresh session
1373
+ * without asking the old one for anything. The old session is detached first, so its late events never reach this
1374
+ * conversation; stopping and closing it are best effort. Its file stays; the handoff names it and carries only the
1375
+ * recent exchange. Works, decisions and never-sent user input carry over as in any rotation; nothing is replayed.
1376
+ */
1377
+ private openFreshSession(convId: string): void {
1378
+ if (this.maintenanceId || this.omoStatus !== "ready") return;
1379
+ const conv = this.store.getConversation(convId);
1380
+ const rt = this.rt(convId);
1381
+ if (!conv?.threadId || rt.rotating) return;
1382
+ const from = conv.threadId;
1383
+ const sessionId = rt.sessionId;
1384
+ // Synchronous from the check above to the new thread, so no prompt or rotation interleaves; no latch is needed.
1385
+ this.emit(convId, "session.transition", { active: true, label: "세션 교체 중" }, false);
1386
+ try {
1387
+ if (sessionId) this.retireScopedQuestions(convId, sessionId);
1388
+ rt.running = false;
1389
+ rt.durableId = null;
1390
+ rt.run++;
1391
+ this.finishRetirement(convId, {
1392
+ sessionId: sessionId ?? "", sessionPath: from, from, reason: "compaction_failed",
1393
+ handoff: this.buildHandoff(convId, "compaction_failed", from),
1394
+ });
1395
+ this.publishActivity(convId);
1396
+ } finally {
1397
+ this.emit(convId, "session.transition", { active: false, label: null }, false);
1398
+ }
1399
+ if (sessionId) void this.stopAbandonedSession(sessionId);
1400
+ if (this.store.pendingMainUsers(convId).length > 0) void this.recover();
1401
+ void this.notifyWorkUpdates(convId);
1402
+ }
1403
+
1404
+ /** A detached session gets one abort and one close; it no longer routes to any conversation, so failures only log. */
1405
+ private async stopAbandonedSession(sessionId: string): Promise<void> {
1406
+ for (const command of ["abort", "close_session"] as const) {
1407
+ try {
1408
+ await this.requireOmo().command(command, { sessionId });
1409
+ } catch (error) {
1410
+ console.error(`abandoned session ${command} failed (${sessionId}): ${errorMessage(error)}`);
1411
+ }
1412
+ }
1413
+ }
1414
+
1415
+ /** Bug 223: log with cached model identities; an unhealthy engine must never hold its own failure diagnostic. */
1416
+ private async logCompactionFailure(failureKind: CompactionFailureKind, failure: unknown): Promise<void> {
1417
+ // Loaded on first use: the security rules ship beside src/, and only a failure needs them.
1418
+ if (!this.providerErrorChecker) this.providerErrorChecker = (await import("./security-check.ts")).defaultChecker();
1419
+ const checker = this.providerErrorChecker;
1420
+ console.error("compaction_failed", JSON.stringify(compactionFailureLog(failureKind, failure, this.knownModels,
1421
+ (text) => sanitizeProviderError(text, checker))));
1422
+ }
1423
+
1424
+ private freshSessionCompactionFailures(): number {
1425
+ return this.options.rotation?.freshSessionCompactionFailures ?? FRESH_SESSION_COMPACTION_FAILURES;
1426
+ }
1427
+
1428
+ /** Bug 224: failed blocking compactions since the last completed one in this session, one per native request id. */
1429
+ private compactionFailuresInRow(conv: string): number {
1430
+ const failed = new Set<string>();
1431
+ for (const event of this.store.eventsAfter(conv, this.store.lastSeq(conv, "session.rotated")).reverse()) {
1432
+ if (event.type !== "session.compaction") continue;
1433
+ const data = event.data as { status?: string; requestId?: unknown };
1434
+ if (data.status === "completed") break;
1435
+ if (data.status === "failed" && typeof data.requestId === "string") failed.add(data.requestId);
1436
+ }
1437
+ return failed.size;
1438
+ }
1439
+
1291
1440
  private closeUncertain(conv: string): boolean {
1292
1441
  return this.store.lastSeq(conv, "session.close.unknown") >
1293
1442
  Math.max(this.store.lastSeq(conv, "session.rotated"), this.store.lastSeq(conv, "session.close.resolved"));
@@ -1304,9 +1453,16 @@ export class Hub {
1304
1453
 
1305
1454
  private finishRetirement(convId: string, retirement: SessionRetirement): void {
1306
1455
  const rt = this.rt(convId);
1307
- const owner = this.ownerForConversation(convId, retirement.sessionId);
1308
- this.sessionToConv.delete(retirement.sessionId);
1309
- if (owner) this.releaseScopedUiOwner(owner);
1456
+ for (const release of rt.promptAdmissions) release();
1457
+ if (retirement.sessionId) {
1458
+ const owner = this.ownerForConversation(convId, retirement.sessionId);
1459
+ this.sessionToConv.delete(retirement.sessionId);
1460
+ if (owner) this.releaseScopedUiOwner(owner);
1461
+ }
1462
+ // Bug 224: nothing the retired session left unfinished can run any more; close it so recovery never replays it.
1463
+ if (this.store.mainTurn(convId)) this.completeTurn(convId, "interrupted");
1464
+ rt.wake?.release?.();
1465
+ rt.wake = null;
1310
1466
  rt.sessionId = null;
1311
1467
  rt.itemId = null;
1312
1468
  rt.itemText = "";
@@ -1318,7 +1474,8 @@ export class Hub {
1318
1474
  this.emit(convId, "conversation.thread", { threadId: to });
1319
1475
  this.emit(convId, "session.rotated", { reason: retirement.reason, from: retirement.from, to });
1320
1476
  this.emit(convId, "notice", { kind: "session-rotated", level: "info",
1321
- message: `새 엔진 세션으로 교체했습니다 (${this.reasonLabel(retirement.reason)}). 화면의 대화 기록은 그대로이고, 다음 메시지부터 새 세션에서 이어집니다.` });
1477
+ message: `새 엔진 세션으로 교체했습니다 (${this.reasonLabel(retirement.reason)}). 화면의 대화 기록은 그대로이고, 다음 메시지부터 새 세션에서 이어집니다.${
1478
+ retirement.reason === "compaction_failed" ? " 이전 세션 파일은 그대로 남겨 두었고, 받지 못한 메시지는 자동으로 다시 보내지 않습니다." : ""}` });
1322
1479
  }
1323
1480
 
1324
1481
  private closeTimer(callback: () => void, ms: number): ReturnType<typeof setTimeout> {
@@ -1595,6 +1752,7 @@ export class Hub {
1595
1752
  const hours = Math.round((this.options.rotation?.maxAgeMs ?? ROTATE_MAX_AGE_MS) / 3_600_000);
1596
1753
  if (reason === "size") return `세션 파일이 ${Math.round((this.options.rotation?.forceMaxBytes ?? FORCE_ROTATE_MAX_BYTES) / 1024 / 1024)}MB에 닿음`;
1597
1754
  if (reason === "compactions") return `압축이 ${this.options.rotation?.forceUpdateCompactions ?? FORCE_ROTATE_UPDATE_COMPACTIONS}번 더 이어짐`;
1755
+ if (reason === "compaction_failed") return `대화 요약이 ${this.freshSessionCompactionFailures()}번 연속 실패함`;
1598
1756
  return reason === "manual" ? "직접 요청" : `세션이 ${hours}시간을 넘겨 쉬는 중`;
1599
1757
  }
1600
1758
 
@@ -1617,7 +1775,7 @@ export class Hub {
1617
1775
  }
1618
1776
  return [
1619
1777
  `[게이트웨이 알림] 이 대화는 새 엔진 세션으로 교체됐습니다 (${this.reasonLabel(reason)}).`,
1620
- from ? `이전 세션의 전체 기록은 ${from} 에 남아 있습니다. 파일이 크니 필요할 때 필요한 부분만 검색해서 보세요.` : "",
1778
+ from ? `이전 세션의 전체 기록은 ${from} 에 남아 있습니다. 파일이 크니 필요할 때 끝부분이나 필요한 부분만 검색해서 보고, 전체를 읽지 마세요.` : "",
1621
1779
  ...this.handoffOpenItems(conv),
1622
1780
  "아래는 직전 대화의 최근 부분입니다. 맥락으로만 참고하세요.",
1623
1781
  "---",
@@ -1635,14 +1793,23 @@ export class Hub {
1635
1793
  const rt = this.rt(convId);
1636
1794
  if (rt.rotating) return; // Accepted user intent remains definitely unsent until rotation finishes.
1637
1795
  const generation = this.generation;
1796
+ let input: StoredEvent | null = null;
1797
+ let nativeCommand = false;
1798
+ let sessionId: string | null = null;
1799
+ const releaseAdmission = () => {
1800
+ if (rt.promptAdmissions.delete(releaseAdmission)) rt.admitting -= 1;
1801
+ };
1802
+ rt.promptAdmissions.add(releaseAdmission);
1803
+ // Hold user admission through RPC settlement, including the idle-before-agent_start interval.
1804
+ rt.admitting += 1;
1638
1805
  try {
1639
- const { sessionId } = await this.ensureSession(conv);
1806
+ ({ sessionId } = await this.ensureSession(conv));
1640
1807
  if (generation !== this.generation || this.omoStatus !== "ready") return;
1641
1808
  const reference = messageSeq ?? this.store.lastSeq(convId, "message.user");
1642
1809
  // sendText and reconnect recovery can share admission; only one may cross the write boundary.
1643
1810
  if (admitted && this.store.mainUserDispatched(convId, reference)) return;
1644
1811
  const commandName = body.slice(1).split(" ", 1)[0];
1645
- const nativeCommand = (body === "." && images.length === 0) ||
1812
+ nativeCommand = (body === "." && images.length === 0) ||
1646
1813
  (body.startsWith("/") && (await this.commands(convId)).some((command) => command.name === commandName));
1647
1814
  if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId) return;
1648
1815
  if (admitted && this.store.mainUserDispatched(convId, reference)) return;
@@ -1660,7 +1827,7 @@ export class Hub {
1660
1827
  ...(rt.clientInputIdentity ? { clientInputId: inputId } : {}),
1661
1828
  };
1662
1829
  if (attached.images.length) params.images = attached.images;
1663
- const input = this.emit(convId, "main.input", {
1830
+ input = this.emit(convId, "main.input", {
1664
1831
  origin: "user", input_ref: { message_seq: reference },
1665
1832
  session_path: this.store.getConversation(convId)?.threadId, handoff,
1666
1833
  ...(!nativeCommand || rt.clientInputIdentity ? {
@@ -1677,9 +1844,19 @@ export class Hub {
1677
1844
  this.decisions?.refresh();
1678
1845
  } catch (error) {
1679
1846
  if (generation !== this.generation || this.omoStatus !== "ready") return;
1847
+ // A retired session's late answer: retirement already closed its input, so it must not close or report it again.
1848
+ const retired = sessionId !== null && rt.sessionId !== sessionId;
1849
+ // Bug 224: the engine refused this prompt before accepting it, so it never runs and must not hold the turn.
1850
+ // Native commands ("." continues through a queued custom message) and unknown delivery stay open.
1851
+ const refused = !retired && input !== null && !nativeCommand && error instanceof OmoCommandError &&
1852
+ error.command === "prompt" && error.code === REQUIRED_COMPACTION_REFUSAL && !this.store.mainInputConsumed(input);
1853
+ if (refused && input) this.emit(convId, "main.input.handled", { input_seq: input.seq, reason: "prompt_rejected" });
1680
1854
  if (protectedPayload) throw new SecurityRuntimeError("dispatch_unknown");
1855
+ if (retired) return;
1681
1856
  const message = errorMessage(error);
1682
- this.emit(convId, "error", this.continuityError(message));
1857
+ this.emit(convId, "error", refused ? { kind: "compaction-required", message: COMPACTION_REQUIRED_MESSAGE } : this.continuityError(message));
1858
+ } finally {
1859
+ releaseAdmission();
1683
1860
  }
1684
1861
  }
1685
1862
 
@@ -1908,6 +2085,9 @@ export class Hub {
1908
2085
  const recovery = COMPACTION_RECOVERY[outcome.failureKind];
1909
2086
  this.emit(conv, "error", { kind: "compaction-failed", requestId, failureKind: outcome.failureKind,
1910
2087
  action: recovery.action, message: `대화 요약에 실패했습니다. 이전 내용은 유지했습니다. ${recovery.message}` });
2088
+ void this.logCompactionFailure(outcome.failureKind, event.failure)
2089
+ .catch((error) => console.error(`compaction failure log: ${errorMessage(error)}`));
2090
+ if (this.compactionFailuresInRow(conv) >= this.freshSessionCompactionFailures()) this.openFreshSession(conv);
1911
2091
  break;
1912
2092
  }
1913
2093
  case "completed":
@@ -2577,7 +2757,7 @@ export class Hub {
2577
2757
  if (!rt) {
2578
2758
  rt = {
2579
2759
  sessionId: null, opening: null, running: false, run: 0, rotating: false, admitting: 0, clientInputIdentity: false, atomicQueuedResume: false, durableId: null,
2580
- waking: null, wake: null, notified: new Set(),
2760
+ waking: null, wake: null, notified: new Set(), promptAdmissions: new Set(),
2581
2761
  itemId: null, itemText: "", statsTurnId: null, statsMessageIndex: 0,
2582
2762
  rotationDone: null, finishRotation: null,
2583
2763
  };
package/src/omo/client.ts CHANGED
@@ -1,9 +1,20 @@
1
+ import { FrameSocket } from "./frame-socket.ts";
1
2
  import { NativeLifecycleLog, type NativeLifecycleRecord } from "./lifecycle.ts";
2
3
  import { RecoveryReceiptLog } from "./recovery-receipt.ts";
3
4
  import type { RecoveryOpenContext, RecoveryReceiptRecord } from "./recovery-receipt-types.ts";
4
5
 
5
6
  export type OmoStatus = "connecting" | "ready" | "disconnected";
6
7
 
8
+ interface OmoSocket {
9
+ readonly readyState: number;
10
+ onopen: (() => void) | null;
11
+ onmessage: ((event: { readonly data: string }) => void) | null;
12
+ onclose: ((event: { readonly code: number; readonly reason: string; readonly wasClean: boolean }) => void) | null;
13
+ onerror: ((event: { readonly message?: string }) => void) | null;
14
+ send(text: string): void;
15
+ close(): void;
16
+ }
17
+
7
18
  export interface OmoEvent {
8
19
  type: string;
9
20
  sessionId?: string;
@@ -14,6 +25,8 @@ export interface OmoEvent {
14
25
  export const COMPACTION_FAILURE_KINDS = [
15
26
  "invalid_model", "auth_missing", "auth_expired", "quota", "org_denied", "model_unavailable",
16
27
  "refused", "timeout", "incomplete", "empty", "context_budget", "stale", "provider",
28
+ // Bug 223: the provider's local account pool refused to send; quota versus auth is not known.
29
+ "account_blocked",
17
30
  ] as const;
18
31
  export type CompactionFailureKind = typeof COMPACTION_FAILURE_KINDS[number];
19
32
  export type CompactionOutcome =
@@ -79,7 +92,7 @@ type Pending = {
79
92
  const STABLE_READY_MS = 10_000;
80
93
 
81
94
  export class OmoClient {
82
- private ws: WebSocket | null = null;
95
+ private ws: OmoSocket | null = null;
83
96
  private nextId = 1;
84
97
  private readonly instanceId = crypto.randomUUID();
85
98
  private readonly pending = new Map<string, Pending>();
@@ -157,10 +170,12 @@ export class OmoClient {
157
170
  this.setStatus("connecting");
158
171
  this.recovery?.connecting();
159
172
  if (this.stopped) return;
160
- const ws = new WebSocket(this.url, { headers: {
173
+ const headers: Record<string, string> = {
161
174
  Authorization: `Bearer ${this.token}`,
162
175
  ...(this.connectionId ? { "x-zgw-connection-id": this.connectionId } : {}),
163
- } } as unknown as string[]);
176
+ };
177
+ const ws: OmoSocket = this.url.startsWith("ws://") ? new FrameSocket(this.url, headers)
178
+ : new WebSocket(this.url, { headers } as unknown as string[]) as unknown as OmoSocket;
164
179
  this.ws = ws;
165
180
  ws.onopen = () => {
166
181
  let continuityLoss: string | undefined;