zigmini 0.8.0-beta.4 → 0.8.0-beta.6

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
@@ -47,6 +47,14 @@ export interface HubOptions {
47
47
  media?: { dir: string; hostDir?: string };
48
48
  now?: () => number;
49
49
  rotation?: { maxAgeMs?: number; idleMs?: number; forceMaxBytes?: number; forceUpdateCompactions?: number };
50
+ /** Bug 152: bounded native ownership reconciliation; timers are injectable for deterministic tests. */
51
+ closeRecovery?: {
52
+ intervalMs?: number;
53
+ maxAttempts?: number;
54
+ timeoutMs?: number;
55
+ setTimeout?: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
56
+ clearTimeout?: (timer: ReturnType<typeof setTimeout>) => void;
57
+ };
50
58
  /** Answers `/version` as a notice; the command never reaches omo. */
51
59
  versionReport?: () => Promise<string>;
52
60
  /** Answers `/usage` (model account usage) as a notice; the command never reaches omo. */
@@ -89,6 +97,7 @@ export interface RotationStatus {
89
97
  /** First sweep that found it held in this gateway run; null when not held or not yet swept. */
90
98
  readonly blockedSince: string | null;
91
99
  readonly blockers: readonly RotationBlocker[];
100
+ readonly closeRecovery?: { readonly state: "recovering" | "exhausted"; readonly attempts: number; readonly maxAttempts: number };
92
101
  }
93
102
 
94
103
  /** omo writes a new session file with its first entry; until then the session has no size. */
@@ -120,6 +129,23 @@ interface OpenResult {
120
129
  streaming: boolean;
121
130
  }
122
131
 
132
+ interface CloseRecovery {
133
+ attempts: number;
134
+ exhausted: boolean;
135
+ timer?: ReturnType<typeof setTimeout>;
136
+ flight?: Promise<void>;
137
+ cancel?: () => void;
138
+ }
139
+
140
+ interface SessionRetirement {
141
+ sessionId: string;
142
+ sessionPath: string;
143
+ from: string | null;
144
+ reason: RotationReason;
145
+ handoff: string;
146
+ closeAcknowledged?: boolean;
147
+ }
148
+
123
149
  export interface SlashCommand {
124
150
  name: string;
125
151
  description: string;
@@ -230,7 +256,7 @@ export const MAIN_QUESTION_RELEASED =
230
256
  const PERMISSION_PREFIX = "Permission required:";
231
257
  const AUTO_CONTINUE_LIMIT = 2;
232
258
  const CONTINUE_PROMPT =
233
- "[게이트웨이 알림] 게이트웨이나 omo가 재시작되면서 이전 작업이 중간에 끊겼습니다. 세션 기록에서 마지막으로 한 일을 확인하고, 이미 끝난 단계는 반복하지 말고 끊긴 지점부터 이어서 마무리해 주세요.";
259
+ "[게이트웨이 알림] 게이트웨이나 엔진이 재시작되면서 이전 작업이 중간에 끊겼습니다. 세션 기록에서 마지막으로 한 일을 확인하고, 이미 끝난 단계는 반복하지 말고 끊긴 지점부터 이어서 마무리해 주세요.";
234
260
 
235
261
  /** A model as omo reports it (get_available_models / get_state); only the fields the gateway uses. */
236
262
  interface OmoModel {
@@ -274,6 +300,7 @@ export class Hub {
274
300
  private readonly questions = new Map<string, QuestionCard>();
275
301
  private readonly rotationHeld = new Map<string, { since: number; logged: boolean }>();
276
302
  private readonly rotationFailures = new Map<string, { message: string; at: number }>();
303
+ private readonly closeRecoveries = new Map<string, CloseRecovery>();
277
304
  private readonly legacyQuestionHistoryAliases = new Map<string, string>();
278
305
  private readonly uiOwners = new Map<string, ScopedUiOwner>();
279
306
  private readonly uiSessionScopes = new Map<string, string>();
@@ -668,6 +695,11 @@ export class Hub {
668
695
  this.eventCount += 1;
669
696
  if (status !== "ready") {
670
697
  this.generation += 1;
698
+ for (const recovery of this.closeRecoveries.values()) {
699
+ if (recovery.timer !== undefined) this.clearCloseTimer(recovery.timer);
700
+ recovery.cancel?.();
701
+ }
702
+ this.closeRecoveries.clear();
671
703
  this.sessionToConv.clear();
672
704
  for (const owner of this.uiOwners.values()) this.decisions?.release(owner);
673
705
  this.uiOwners.clear();
@@ -681,7 +713,7 @@ export class Hub {
681
713
  rt.wake = null;
682
714
  rt.notified.clear();
683
715
  const scope = this.conversationScope(conv);
684
- this.dialogs.cancelScope(scope, "omo 연결이 끊겼습니다.");
716
+ this.dialogs.cancelScope(scope, "엔진 연결이 끊겼습니다.");
685
717
  for (const [id, card] of this.questions) {
686
718
  if (card.scope !== scope) continue;
687
719
  this.questions.delete(id);
@@ -709,17 +741,22 @@ export class Hub {
709
741
  for (const conv of conversations) {
710
742
  if (this.omoStatus !== "ready" || generation !== this.generation) return;
711
743
  try {
744
+ if (this.closeUncertain(conv.id)) {
745
+ await this.reconcileClose(conv.id);
746
+ if (this.closeUncertain(conv.id) || generation !== this.generation) continue;
747
+ }
748
+ const current = this.store.getConversation(conv.id) ?? conv;
712
749
  const pendingUsers = this.store.pendingMainUsers(conv.id);
713
750
  if (pendingUsers.length > 0) {
714
- await this.ensureSession(conv);
751
+ await this.ensureSession(current);
715
752
  for (const user of pendingUsers) {
716
753
  const data = user.data as { text: string; images?: string[]; references?: MessageReference[] };
717
754
  const input = referencedInput(data.text, data.images ?? [], data.references);
718
755
  await this.prompt(conv.id, input.text, input.images, user.seq, true);
719
756
  }
720
- } else if (this.hasUnfinishedTurn(conv.id)) await this.recoverConversation(conv);
721
- else if (this.openQuestionCards(conv.id, this.conversationScope(conv.id)).size > 0) await this.ensureSession(conv);
722
- else if (this.store.unreportedWorkUpdates(conv.id).length > 0 || this.workSummary(conv.id).length > 0) await this.ensureSession(conv);
757
+ } else if (this.hasUnfinishedTurn(conv.id)) await this.recoverConversation(current);
758
+ else if (this.openQuestionCards(conv.id, this.conversationScope(conv.id)).size > 0) await this.ensureSession(current);
759
+ else if (this.store.unreportedWorkUpdates(conv.id).length > 0 || this.workSummary(conv.id).length > 0) await this.ensureSession(current);
723
760
  // Opening main comes first. A notification ACK or worker reconciliation cannot hold readiness.
724
761
  void this.notifyWorkUpdates(conv.id);
725
762
  } catch (error) {
@@ -763,7 +800,13 @@ export class Hub {
763
800
  this.decisions?.refresh();
764
801
  const rotationDone = this.rt(convId).rotationDone;
765
802
  if (rotationDone) await rotationDone;
766
- if (this.closeUncertain(convId)) throw new Error("session_close_unknown");
803
+ if (this.closeUncertain(convId)) {
804
+ if (this.closeRecoveries.get(convId)?.exhausted) throw new Error("session_close_recovery_exhausted");
805
+ this.scheduleCloseRecovery(convId);
806
+ this.emit(convId, "notice", { kind: "session-close-recovery", level: "info",
807
+ message: "세션 닫기 상태를 확인하고 있습니다. 메시지는 확인 후 전달합니다." });
808
+ return;
809
+ }
767
810
  await this.prompt(convId, prompt.text, prompt.images, input.seq, true);
768
811
  }
769
812
 
@@ -998,7 +1041,7 @@ export class Hub {
998
1041
  && (event.data as { input_seq?: number }).input_seq === input.seq).at(-1);
999
1042
  if ((previous?.data as { kind?: string } | undefined)?.kind !== kind) this.emit(conv, "notice", {
1000
1043
  kind, input_seq: input.seq, level: "warning",
1001
- message: queued ? "메시지가 OMO 대기열에 남아 있습니다. 자동 재전송하지 않습니다."
1044
+ message: queued ? "메시지가 엔진 대기열에 남아 있습니다. 자동 재전송하지 않습니다."
1002
1045
  : "메시지의 실행 소비를 확인하지 못했습니다. 기록을 보존하며 자동 재전송하지 않습니다.",
1003
1046
  });
1004
1047
  if (queued && data.native_identity && rt.atomicQueuedResume && state &&
@@ -1129,49 +1172,31 @@ export class Hub {
1129
1172
  }
1130
1173
  // Any rotation hands main's open waits of the closed session to the next one (inbox resume); bug 72.
1131
1174
  this.retireScopedQuestions(convId, sessionId);
1175
+ const retirement: SessionRetirement = {
1176
+ sessionId, sessionPath: source, from, reason,
1177
+ handoff: this.buildHandoff(convId, reason, source, handoffThrough),
1178
+ };
1179
+ let closeAcknowledged = false;
1132
1180
  try {
1133
1181
  await this.requireOmo().command("close_session", { sessionId });
1134
- const listed = await this.requireOmo().command<{
1135
- sessions?: { sessionId?: string; sessionPath?: string }[];
1136
- }>("list_sessions", { include_all: true, include_workers: true });
1182
+ closeAcknowledged = true;
1183
+ const listed = await this.requireOmo().command("list_sessions", { include_all: true, include_workers: true });
1137
1184
  if (
1138
- generation !== this.generation || this.omoStatus !== "ready" || !Array.isArray(listed.sessions) ||
1139
- listed.sessions.some(
1140
- (entry) =>
1141
- !entry ||
1142
- typeof entry.sessionId !== "string" ||
1143
- typeof entry.sessionPath !== "string" ||
1144
- entry.sessionId === sessionId ||
1145
- entry.sessionPath === source,
1146
- )
1185
+ generation !== this.generation || this.omoStatus !== "ready" ||
1186
+ this.targetSessionPresent(listed, sessionId, source)
1147
1187
  ) {
1148
1188
  throw new Error("session_retirement_unconfirmed");
1149
1189
  }
1150
1190
  } catch (error) {
1151
1191
  this.emit(convId, "session.close.unknown", {
1152
- sessionPath: source,
1153
- sessionId,
1192
+ ...retirement,
1193
+ closeAcknowledged,
1154
1194
  message: errorMessage(error),
1155
1195
  });
1196
+ this.scheduleCloseRecovery(convId);
1156
1197
  throw error;
1157
1198
  }
1158
- const owner = this.ownerForConversation(convId, sessionId);
1159
- this.sessionToConv.delete(sessionId);
1160
- if (owner) this.releaseScopedUiOwner(owner);
1161
- rt.sessionId = null;
1162
- rt.itemId = null;
1163
- rt.itemText = "";
1164
- rt.notified.clear();
1165
- const to = `${this.options.sessionDir.replace(/\/$/, "")}/zgw-${conv.id}-${fileStamp(this.now())}-${crypto.randomUUID().slice(0, 8)}.jsonl`;
1166
- const handoff = this.buildHandoff(convId, reason, source, handoffThrough);
1167
- this.store.rotateThread(convId, to, new Date(this.now()).toISOString(), handoff);
1168
- this.emit(convId, "conversation.thread", { threadId: to });
1169
- this.emit(convId, "session.rotated", { reason, from, to });
1170
- this.emit(convId, "notice", {
1171
- kind: "session-rotated",
1172
- level: "info",
1173
- message: `새 omo 세션으로 교체했습니다 (${this.reasonLabel(reason)}). 화면의 대화 기록은 그대로이고, 다음 메시지부터 새 세션에서 이어집니다.`,
1174
- });
1199
+ this.finishRetirement(convId, retirement);
1175
1200
  return this.store.getConversation(convId) ?? conv;
1176
1201
  } finally {
1177
1202
  rt.rotating = false;
@@ -1187,7 +1212,120 @@ export class Hub {
1187
1212
  }
1188
1213
 
1189
1214
  private closeUncertain(conv: string): boolean {
1190
- return this.store.lastSeq(conv, "session.close.unknown") > this.store.lastSeq(conv, "session.rotated");
1215
+ return this.store.lastSeq(conv, "session.close.unknown") >
1216
+ Math.max(this.store.lastSeq(conv, "session.rotated"), this.store.lastSeq(conv, "session.close.resolved"));
1217
+ }
1218
+
1219
+ /** A valid native inventory proves absence by target identity, not by the shape of unrelated worker entries. */
1220
+ private targetSessionPresent(listed: unknown, sessionId: string, sessionPath: string): boolean {
1221
+ if (typeof listed !== "object" || listed === null || !("sessions" in listed) || !Array.isArray(listed.sessions)) {
1222
+ throw new Error("session_list_invalid");
1223
+ }
1224
+ return listed.sessions.some((entry: unknown) => typeof entry === "object" && entry !== null &&
1225
+ (("sessionId" in entry && entry.sessionId === sessionId) || ("sessionPath" in entry && entry.sessionPath === sessionPath)));
1226
+ }
1227
+
1228
+ private finishRetirement(convId: string, retirement: SessionRetirement): void {
1229
+ const rt = this.rt(convId);
1230
+ const owner = this.ownerForConversation(convId, retirement.sessionId);
1231
+ this.sessionToConv.delete(retirement.sessionId);
1232
+ if (owner) this.releaseScopedUiOwner(owner);
1233
+ rt.sessionId = null;
1234
+ rt.itemId = null;
1235
+ rt.itemText = "";
1236
+ rt.notified.clear();
1237
+ const to = `${this.options.sessionDir.replace(/\/$/, "")}/zgw-${convId}-${fileStamp(this.now())}-${crypto.randomUUID().slice(0, 8)}.jsonl`;
1238
+ this.store.rotateThread(convId, to, new Date(this.now()).toISOString(), retirement.handoff);
1239
+ this.emit(convId, "conversation.thread", { threadId: to });
1240
+ this.emit(convId, "session.rotated", { reason: retirement.reason, from: retirement.from, to });
1241
+ this.emit(convId, "notice", { kind: "session-rotated", level: "info",
1242
+ message: `새 엔진 세션으로 교체했습니다 (${this.reasonLabel(retirement.reason)}). 화면의 대화 기록은 그대로이고, 다음 메시지부터 새 세션에서 이어집니다.` });
1243
+ }
1244
+
1245
+ private closeTimer(callback: () => void, ms: number): ReturnType<typeof setTimeout> {
1246
+ const schedule = this.options.closeRecovery?.setTimeout;
1247
+ return schedule ? schedule(callback, ms) : setTimeout(callback, ms);
1248
+ }
1249
+
1250
+ private clearCloseTimer(timer: ReturnType<typeof setTimeout>): void {
1251
+ (this.options.closeRecovery?.clearTimeout ?? clearTimeout)(timer);
1252
+ }
1253
+
1254
+ private scheduleCloseRecovery(convId: string): void {
1255
+ if (this.omoStatus !== "ready" || !this.closeUncertain(convId)) return;
1256
+ const recovery = this.closeRecoveries.get(convId) ?? { attempts: 0, exhausted: false };
1257
+ this.closeRecoveries.set(convId, recovery);
1258
+ if (recovery.exhausted || recovery.timer !== undefined || recovery.flight) return;
1259
+ recovery.timer = this.closeTimer(() => {
1260
+ recovery.timer = undefined;
1261
+ return this.reconcileClose(convId).then(() => {
1262
+ if (this.omoStatus === "ready" && !this.closeUncertain(convId)) return this.recover();
1263
+ });
1264
+ }, this.options.closeRecovery?.intervalMs ?? 5_000);
1265
+ }
1266
+
1267
+ /** Never re-close an uncertain native or send to it before a fresh inventory establishes ownership. */
1268
+ private reconcileClose(convId: string): Promise<void> {
1269
+ const recovery = this.closeRecoveries.get(convId) ?? { attempts: 0, exhausted: false };
1270
+ this.closeRecoveries.set(convId, recovery);
1271
+ if (recovery.flight) return recovery.flight;
1272
+ if (recovery.exhausted || this.omoStatus !== "ready" || this.rt(convId).rotating) return Promise.resolve();
1273
+ if (recovery.timer !== undefined) this.clearCloseTimer(recovery.timer);
1274
+ recovery.timer = undefined;
1275
+ const generation = this.generation;
1276
+ const event = this.store.getEvent(this.store.lastSeq(convId, "session.close.unknown"));
1277
+ if (!event || !this.closeUncertain(convId)) return Promise.resolve();
1278
+ recovery.attempts += 1;
1279
+ recovery.flight = (async () => {
1280
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1281
+ try {
1282
+ const cancelled = new Promise<never>((_resolve, reject) => {
1283
+ recovery.cancel = () => reject(new Error("session_close_recovery_disconnected"));
1284
+ timeout = this.closeTimer(() => reject(new Error("session_close_verification_timeout")),
1285
+ this.options.closeRecovery?.timeoutMs ?? 10_000);
1286
+ });
1287
+ const listed = await Promise.race([
1288
+ this.requireOmo().command("list_sessions", { include_all: true, include_workers: true }), cancelled,
1289
+ ]);
1290
+ if (generation !== this.generation || this.omoStatus !== "ready") return;
1291
+ const data = event.data as Partial<SessionRetirement>;
1292
+ if (typeof data.sessionId !== "string" || typeof data.sessionPath !== "string") throw new Error("session_close_identity_missing");
1293
+ const present = this.targetSessionPresent(listed, data.sessionId, data.sessionPath);
1294
+ const current = this.store.getConversation(convId);
1295
+ if (current?.threadId === data.sessionPath && !present) {
1296
+ const reason = data.reason ?? "manual";
1297
+ this.finishRetirement(convId, { sessionId: data.sessionId, sessionPath: data.sessionPath, from: data.from ?? data.sessionPath,
1298
+ reason, handoff: data.handoff ?? this.buildHandoff(convId, reason, data.sessionPath, event.seq - 1) });
1299
+ } else {
1300
+ // A successful close releases this connection even when another attachment keeps the native alive.
1301
+ // Reopen that source before sending again; a rejected close must not duplicate a still-owned attachment.
1302
+ if (present && data.closeAcknowledged && current?.threadId === data.sessionPath) {
1303
+ this.sessionToConv.delete(data.sessionId);
1304
+ this.rt(convId).sessionId = null;
1305
+ } else if (present && this.rt(convId).sessionId === data.sessionId && current?.threadId === data.sessionPath) {
1306
+ // The rejected close kept its trusted open_session attachment; restore the UI authority retired above.
1307
+ this.bindScopedUiOwner({ conv: convId, scope: this.conversationScope(convId), sessionId: data.sessionId, sessionPath: data.sessionPath });
1308
+ }
1309
+ this.emit(convId, "session.close.resolved", { sessionId: data.sessionId, sessionPath: data.sessionPath,
1310
+ outcome: present ? "retained" : "superseded", attempts: recovery.attempts });
1311
+ this.emit(convId, "notice", { kind: "session-close-resolved", level: "info",
1312
+ message: "세션 닫기 상태를 확인했습니다. 기존 대화에서 메시지 처리를 계속합니다." });
1313
+ }
1314
+ this.closeRecoveries.delete(convId);
1315
+ } catch (error) {
1316
+ if (generation !== this.generation) return;
1317
+ if (recovery.attempts >= (this.options.closeRecovery?.maxAttempts ?? 3)) {
1318
+ recovery.exhausted = true;
1319
+ this.emit(convId, "error", { message: `session_close_recovery_exhausted: ${errorMessage(error)}. 메시지는 보관 중입니다. 엔진 연결과 세션 목록을 확인한 뒤 게이트웨이를 다시 시작하세요.` });
1320
+ }
1321
+ } finally {
1322
+ if (timeout !== undefined) this.clearCloseTimer(timeout);
1323
+ recovery.cancel = undefined;
1324
+ recovery.flight = undefined;
1325
+ if (generation === this.generation && this.closeUncertain(convId)) this.scheduleCloseRecovery(convId);
1326
+ }
1327
+ })();
1328
+ return recovery.flight;
1191
1329
  }
1192
1330
 
1193
1331
  private rotationBlocked(conv: string, inboxDecisions = true): boolean {
@@ -1255,7 +1393,10 @@ export class Hub {
1255
1393
  if (!conv.threadId?.startsWith("/")) continue;
1256
1394
  const blockers = this.rotationHolds(conv.id);
1257
1395
  const held = blockers.length > 0 ? this.rotationHeld.get(conv.id) : undefined;
1258
- status[conv.id] = { blocked: blockers.length > 0, blockedSince: held ? new Date(held.since).toISOString() : null, blockers };
1396
+ const recovery = this.closeRecoveries.get(conv.id);
1397
+ status[conv.id] = { blocked: blockers.length > 0, blockedSince: held ? new Date(held.since).toISOString() : null, blockers,
1398
+ ...(recovery ? { closeRecovery: { state: recovery.exhausted ? "exhausted" as const : "recovering" as const,
1399
+ attempts: recovery.attempts, maxAttempts: this.options.closeRecovery?.maxAttempts ?? 3 } } : {}) };
1259
1400
  }
1260
1401
  return status;
1261
1402
  }
@@ -1396,7 +1537,7 @@ export class Hub {
1396
1537
  lines.unshift(line);
1397
1538
  }
1398
1539
  return [
1399
- `[게이트웨이 알림] 이 대화는 새 omo 세션으로 교체됐습니다 (${this.reasonLabel(reason)}).`,
1540
+ `[게이트웨이 알림] 이 대화는 새 엔진 세션으로 교체됐습니다 (${this.reasonLabel(reason)}).`,
1400
1541
  from ? `이전 세션의 전체 기록은 ${from} 에 남아 있습니다. 파일이 크니 필요할 때 필요한 부분만 검색해서 보세요.` : "",
1401
1542
  ...this.handoffOpenItems(conv),
1402
1543
  "아래는 직전 대화의 최근 부분입니다. 맥락으로만 참고하세요.",
@@ -1466,7 +1607,7 @@ export class Hub {
1466
1607
  if (!CONTEXT_OVERFLOW.test(message)) return { message };
1467
1608
  return {
1468
1609
  kind: "continuity-protected",
1469
- message: `${message}. 같은 omo 세션을 유지합니다. 원인을 해결한 뒤 계속할 지시를 보내 주세요. 이전 입력은 자동 재전송하지 않습니다.`,
1610
+ message: `${message}. 같은 엔진 세션을 유지합니다. 원인을 해결한 뒤 계속할 지시를 보내 주세요. 이전 입력은 자동 재전송하지 않습니다.`,
1470
1611
  };
1471
1612
  }
1472
1613
 
package/src/main.ts CHANGED
@@ -44,7 +44,7 @@ import { activeRecord, readRegistry, slotUrl } from "./runtime/slots.ts";
44
44
  import { NATIVE_LIFECYCLE_PREFIX } from "./omo/lifecycle.ts";
45
45
  import { RECOVERY_RECEIPT_PREFIX } from "./omo/recovery-receipt.ts";
46
46
  import { fileScheduleSource, notionRoutineSource, ReminderLoop } from "./schedule/reminders.ts";
47
- import { type ClientMessage, type Envelope, PROTOCOL_VERSION, type Source } from "./protocol.ts";
47
+ import { type ClientMessage, type Envelope, PROTOCOL_VERSION, ROTATION_HELD_MESSAGE, type Source } from "./protocol.ts";
48
48
  import { QuestionNotifications } from "./question-notifications.ts";
49
49
  import { DecisionStoreError, Store } from "./store.ts";
50
50
  import { TranscriptLog } from "./transcript.ts";
@@ -475,7 +475,7 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
475
475
  return;
476
476
  case "conversation.rotate":
477
477
  await hub.rotateSession(msg.conv, "manual").catch((e) => {
478
- const message = e instanceof Error && e.message === "turn_running" ? "진행 중이거나 확인되지 않은 작업이 있어 같은 세션을 유지합니다." : String(e);
478
+ const message = e instanceof Error && e.message === "turn_running" ? ROTATION_HELD_MESSAGE : String(e);
479
479
  ws.send(envelope("error", { message }, msg.conv));
480
480
  });
481
481
  return;
package/src/omo-check.ts CHANGED
@@ -45,8 +45,8 @@ export class OmoCheckScheduler {
45
45
  const version = body.version;
46
46
  const result = this.store.createOmoCheck(version, {
47
47
  ownerConv: "main",
48
- title: `OMO ${version} 검증`,
49
- goal: `OMO ${version} 후보 패치와 교체 가능 여부 검증 (운영 교체 금지)`,
48
+ title: `엔진 ${version} 검증`,
49
+ goal: `엔진 ${version} 후보 패치와 교체 가능 여부 검증 (운영 교체 금지)`,
50
50
  cwd: this.options.cwd,
51
51
  sessionPath: join(this.options.sessionDir, `zgw-omo-check-${version}.jsonl`),
52
52
  requestKey: `omo-check:${version}`,
package/src/protocol.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export const PROTOCOL_VERSION = 1;
2
2
 
3
+ /** The `error` message a refused `conversation.rotate` gets when a turn or an open wait holds the session (hub `turn_running`). */
4
+ export const ROTATION_HELD_MESSAGE = "진행 중이거나 확인되지 않은 작업이 있어 같은 세션을 유지합니다.";
5
+
3
6
  export type Channel = "web" | "app" | "home" | "hud" | "discord";
4
7
  export type Trust = "owner" | "owner-voice" | "unknown";
5
8
 
package/src/transcript.ts CHANGED
@@ -62,7 +62,7 @@ export function toRecord(type: string, data: unknown, base: { ts: string; conv:
62
62
  if (type === "message.user" && typeof d.text === "string") return { ...base, role: "user", text: d.text, src: d.src, trust: d.trust };
63
63
  if (type === "message.final" && typeof d.text === "string") return { ...base, role: "assistant", text: d.text };
64
64
  if (type === "session.rotated") {
65
- return { ...base, role: "system", event: "session.rotated", text: `omo 세션 교체 (${String(d.reason)}): ${String(d.from)} -> ${String(d.to)}` };
65
+ return { ...base, role: "system", event: "session.rotated", text: `엔진 세션 교체 (${String(d.reason)}): ${String(d.from)} -> ${String(d.to)}` };
66
66
  }
67
67
  return null;
68
68
  }
@@ -59,7 +59,7 @@ export const POLL_MS = 1000;
59
59
 
60
60
  export type HandoffResult = "promoted" | "untouched_failure" | "failure";
61
61
 
62
- const NOT_UP = `${BRIDGE_START_WAIT_MS / 1000}초 안에 OMO가 뜨지 않았습니다`;
62
+ const NOT_UP = `${BRIDGE_START_WAIT_MS / 1000}초 안에 엔진이 뜨지 않았습니다`;
63
63
 
64
64
  function legacyRegistry(port: number, version: string, now: number): SlotRegistry {
65
65
  return { schema: 1, slots: [{ slot: "a", state: "active", port, version, snapshot: null, engine: null, since: new Date(now).toISOString() }] };
package/src/usage.ts CHANGED
@@ -279,7 +279,7 @@ function stamp(ms: number, timeZone: string | undefined): string {
279
279
  return `${part("month")}/${part("day")} ${part("hour")}:${part("minute")}`;
280
280
  }
281
281
 
282
- const STATUS_TEXT = { expired: "토큰 만료 (OMO가 다음 사용 때 갱신)", unsupported: "사용량 API 없음" } as const;
282
+ const STATUS_TEXT = { expired: "토큰 만료 (엔진이 다음 사용 때 갱신)", unsupported: "사용량 API 없음" } as const;
283
283
 
284
284
  export function formatUsageReport(rows: readonly UsageRow[], timeZone?: string): string {
285
285
  if (rows.length === 0) return "계정 사용량\n로그인한 모델 계정이 없습니다.";
package/src/version.ts CHANGED
@@ -3,7 +3,7 @@ import { CORE_NAME, ENGINE_PACKAGE, PLUGIN, OMO_REGISTRY_URL, readEnginePin, for
3
3
  export { OMO_REGISTRY_URL } from "./engine/index.ts";
4
4
 
5
5
  /** `/version`: the gateway answers it with a notice and never forwards it to omo. */
6
- export const VERSION_COMMAND = { name: "version", description: "게이트웨이·OMO 버전 보기 (게이트웨이가 바로 답함)", source: "extension" };
6
+ export const VERSION_COMMAND = { name: "version", description: "게이트웨이·엔진 버전 보기 (게이트웨이가 바로 답함)", source: "extension" };
7
7
 
8
8
  export function isVersionCommand(text: string): boolean {
9
9
  return /^\/version$/i.test(text.trim());
@@ -104,22 +104,22 @@ export function formatVersionReport(facts: VersionFacts): string {
104
104
  const build = facts.gateway.build ? `빌드 커밋 ${facts.gateway.build}` : "빌드 커밋 알 수 없음";
105
105
  lines.push(`· 게이트웨이: ${facts.gateway.version ?? "알 수 없음"} (${build})`);
106
106
  const pin = facts.pin?.omo
107
- ? ` · 저장소 고정 omo ${formatEnginePin(facts.pin)}`
107
+ ? ` · 저장소 고정 엔진 ${formatEnginePin(facts.pin)}`
108
108
  : " · 저장소 고정 버전 읽기 실패";
109
109
  let running: string | null = null;
110
- if (!facts.omo) lines.push(`· 게이트웨이 OMO: 확인 실패 (OMO 연결 상태 ${facts.omoStatus})${pin}`);
110
+ if (!facts.omo) lines.push(`· 엔진: 확인 실패 (엔진 연결 상태 ${facts.omoStatus})${pin}`);
111
111
  else {
112
112
  running = facts.omo.install;
113
113
  const engine = facts.omo.engine ? ` (${CORE_NAME} ${facts.omo.engine})` : "";
114
- lines.push(`· 게이트웨이 OMO: ${facts.omo.install ?? "설치 경로 확인 실패"}${engine}${pin}`);
114
+ lines.push(`· 엔진: ${facts.omo.install ?? "설치 경로 확인 실패"}${engine}${pin}`);
115
115
  }
116
116
  if ("version" in facts.latest) {
117
- lines.push(`· 최신 OMO: ${facts.latest.version} (npm ${ENGINE_PACKAGE})`);
117
+ lines.push(`· 최신 엔진: ${facts.latest.version} (npm ${ENGINE_PACKAGE})`);
118
118
  const current = running ?? facts.pin?.omo ?? null;
119
119
  if (current && isNewer(facts.latest.version, current)) {
120
120
  lines.push(`업데이트 가능: ${current} → ${facts.latest.version} (자동으로 업데이트하지 않습니다)`);
121
121
  }
122
- } else lines.push(`· 최신 OMO: 확인 실패 (${facts.latest.error})`);
122
+ } else lines.push(`· 최신 엔진: 확인 실패 (${facts.latest.error})`);
123
123
  return lines.join("\n");
124
124
  }
125
125