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.
@@ -0,0 +1,411 @@
1
+ import { spawn } from "node:child_process";
2
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { agentDir } from "../model-accounts.ts";
5
+ import { ROTATION_HELD_MESSAGE } from "../protocol.ts";
6
+ import { type LaunchdContext, type LaunchdDeps, ownProcess, parseConfigEnv, startCommand, stopCommand } from "./launchd.ts";
7
+
8
+ /**
9
+ * Improvement 162: `zigmini rotate-main` moves the main conversation to a fresh engine session the same way the web
10
+ * "새 세션" button does (conversation.rotate), from a command that outlives the engine that asked for it.
11
+ *
12
+ * The command starts a detached runner (its own session, so stopping the engine does not kill it) and returns. The
13
+ * runner waits until no main turn holds the session, stops on any other holder, sends the rotation and checks the
14
+ * session.rotated event. With --restart it first stops the gateway, checks that every process is gone and that no
15
+ * main session is parked for the next engine, starts the gateway again and only then rotates. Every step is a line in
16
+ * <home>/logs/rotate-main.log.
17
+ */
18
+
19
+ export const ROTATE_COMMAND = "rotate-main";
20
+ export const ROTATE_RUN_COMMAND = "_rotate-main-run";
21
+ export const LOG_FILE = "rotate-main.log";
22
+ export const DEFAULT_WAIT_S = 900;
23
+ export const MAX_WAIT_S = 86_400;
24
+ export const POLL_MS = 5_000;
25
+ export const ROTATED_WAIT_MS = 120_000;
26
+ export const PROCESSES_GONE_MS = 60_000;
27
+ export const CONV = "main";
28
+
29
+ export const EXIT_OK = 0;
30
+ export const EXIT_FAILED = 1;
31
+ export const EXIT_USAGE = 2;
32
+ export const EXIT_BLOCKED = 3;
33
+ export const EXIT_TIMEOUT = 4;
34
+
35
+ export interface RotateOptions {
36
+ readonly restart: boolean;
37
+ readonly waitMs: number;
38
+ readonly foreground: boolean;
39
+ }
40
+
41
+ export const ROTATE_HELP = `사용법: zigmini rotate-main [--restart] [--wait <초>] [--foreground]
42
+ main 대화를 새 엔진 세션으로 교체합니다(웹의 "새 세션" 버튼과 같은 요청).
43
+ main 차례가 진행 중이면 끝날 때까지 기다립니다(--wait, 기본 ${DEFAULT_WAIT_S}초).
44
+ 다른 원인이 교체를 막으면 그 원인을 로그에 쓰고 끝납니다.
45
+ --restart 먼저 게이트웨이를 멈추고(zigmini stop) 다시 켠 뒤(zigmini start) 교체합니다
46
+ --foreground 분리하지 않고 이 터미널에서 실행합니다(기본: 분리 실행, 결과는 로그에)
47
+ 로그: <홈>/logs/${LOG_FILE}`;
48
+
49
+ export function parseRotateArgs(args: readonly string[]): RotateOptions | string {
50
+ let restart = false;
51
+ let foreground = false;
52
+ let waitS = DEFAULT_WAIT_S;
53
+ for (let i = 0; i < args.length; i++) {
54
+ const arg = args[i];
55
+ if (arg === "--restart" && !restart) restart = true;
56
+ else if (arg === "--foreground" && !foreground) foreground = true;
57
+ else if (arg === "--wait" && i + 1 < args.length) {
58
+ const value = args[++i]!;
59
+ if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > MAX_WAIT_S) return `--wait 값은 1~${MAX_WAIT_S} 사이의 초입니다: ${value}`;
60
+ waitS = Number(value);
61
+ } else return `잘못된 인자: ${arg}`;
62
+ }
63
+ return { restart, waitMs: waitS * 1000, foreground };
64
+ }
65
+
66
+ /** The arguments the detached runner gets: the parsed options again, always in the foreground. */
67
+ export function runnerArgs(options: RotateOptions): string[] {
68
+ return [ROTATE_RUN_COMMAND, ...(options.restart ? ["--restart"] : []), "--wait", String(options.waitMs / 1000)];
69
+ }
70
+
71
+ export interface Blocker {
72
+ readonly kind: string;
73
+ readonly id?: string;
74
+ readonly workId?: string;
75
+ readonly state?: string;
76
+ readonly reason?: string;
77
+ }
78
+
79
+ export interface HealthView {
80
+ readonly ready: boolean;
81
+ readonly omo: string | null;
82
+ readonly maintenanceHeld: boolean;
83
+ /** main's rotation entry; null when main has no engine session yet. */
84
+ readonly blockers: readonly Blocker[] | null;
85
+ readonly active: string | null;
86
+ }
87
+
88
+ export function readHealth(body: unknown): HealthView | null {
89
+ if (typeof body !== "object" || body === null) return null;
90
+ const health = body as { ok?: unknown; omo?: unknown; maintenanceHeld?: unknown; rotation?: unknown; runtime?: unknown };
91
+ const omo = typeof health.omo === "string" ? health.omo : null;
92
+ const rotation = typeof health.rotation === "object" && health.rotation !== null ? (health.rotation as Record<string, unknown>)[CONV] : undefined;
93
+ const raw = typeof rotation === "object" && rotation !== null ? (rotation as { blockers?: unknown }).blockers : undefined;
94
+ const blockers = Array.isArray(raw)
95
+ ? raw.filter((entry): entry is Blocker => typeof entry === "object" && entry !== null && typeof (entry as Blocker).kind === "string")
96
+ : null;
97
+ const runtime = health.runtime as { active?: unknown } | undefined;
98
+ return {
99
+ ready: health.ok === true && omo === "ready",
100
+ omo,
101
+ maintenanceHeld: health.maintenanceHeld === true,
102
+ blockers,
103
+ active: typeof runtime?.active === "string" ? runtime.active : null,
104
+ };
105
+ }
106
+
107
+ export function describeBlockers(blockers: readonly Blocker[]): string {
108
+ return blockers.map((b) => [b.kind, b.id, b.workId, b.state, b.reason].filter(Boolean).join(":")).join(", ");
109
+ }
110
+
111
+ export type Gate =
112
+ | { readonly kind: "ready" }
113
+ | { readonly kind: "wait"; readonly why: string }
114
+ | { readonly kind: "blocked"; readonly why: string };
115
+
116
+ /** What the health says about rotating main now: a main turn, an update or a not-ready engine is waited for; anything else stops. */
117
+ export function gate(health: HealthView | null): Gate {
118
+ if (health === null) return { kind: "wait", why: "health 응답 없음" };
119
+ if (!health.ready) return { kind: "wait", why: `엔진 준비 안 됨(omo=${health.omo ?? "?"})` };
120
+ if (health.maintenanceHeld) return { kind: "wait", why: "업데이트·유지보수 진행 중" };
121
+ if (health.blockers === null) return { kind: "blocked", why: "main 대화에 엔진 세션이 없습니다(교체할 세션 없음)" };
122
+ const other = health.blockers.filter((blocker) => blocker.kind !== "main_turn");
123
+ if (other.length > 0) return { kind: "blocked", why: `교체를 막는 원인: ${describeBlockers(health.blockers)}` };
124
+ if (health.blockers.length > 0) return { kind: "wait", why: `main 차례 진행 중: ${describeBlockers(health.blockers)}` };
125
+ return { kind: "ready" };
126
+ }
127
+
128
+ export type RotateOutcome =
129
+ | { readonly kind: "rotated"; readonly before: string | null; readonly from: string; readonly to: string }
130
+ | { readonly kind: "held"; readonly message: string }
131
+ | { readonly kind: "error"; readonly message: string }
132
+ | { readonly kind: "timeout" };
133
+
134
+ /** What is wrong with a reported rotation: it must leave the session main had, for a new file in the same folder. */
135
+ export function rotationProblems(outcome: { before: string | null; from: string; to: string }, exists: (path: string) => boolean): string[] {
136
+ const problems: string[] = [];
137
+ if (outcome.before !== null && outcome.from !== outcome.before) problems.push(`이전 세션이 다릅니다(교체 전 ${outcome.before})`);
138
+ if (outcome.to === outcome.from) problems.push("새 세션 파일이 이전과 같습니다");
139
+ if (dirname(outcome.to) !== dirname(outcome.from)) problems.push("새 세션 파일이 다른 폴더에 있습니다");
140
+ if (!basename(outcome.to).startsWith(`zgw-${CONV}-`)) problems.push("새 세션 파일 이름이 main 세션이 아닙니다");
141
+ if (!exists(outcome.from)) problems.push("이전 세션 파일이 디스크에 없습니다");
142
+ return problems;
143
+ }
144
+
145
+ export interface RotateDeps {
146
+ readonly health: () => Promise<unknown>;
147
+ readonly rotate: (timeoutMs: number) => Promise<RotateOutcome>;
148
+ readonly exists: (path: string) => boolean;
149
+ readonly now: () => number;
150
+ readonly sleep: (ms: number) => Promise<void>;
151
+ readonly log: (step: string, result: string) => void;
152
+ readonly out: (text: string) => void;
153
+ readonly stop: () => Promise<number>;
154
+ readonly start: () => Promise<number>;
155
+ /** This installation's gateway, bridge and engine processes still running ("pid command"). */
156
+ readonly processes: () => readonly string[];
157
+ /** Parked engine records of main sessions the next engine would reopen. */
158
+ readonly parkedMain: () => readonly string[];
159
+ }
160
+
161
+ type Waited = { readonly kind: "ready"; readonly health: HealthView } | { readonly kind: "blocked"; readonly why: string } | { readonly kind: "timeout"; readonly why: string };
162
+
163
+ async function waitIdle(deps: RotateDeps, deadline: number): Promise<Waited> {
164
+ let last = "";
165
+ for (;;) {
166
+ const health = readHealth(await deps.health());
167
+ const state = gate(health);
168
+ if (state.kind === "ready") return { kind: "ready", health: health! };
169
+ if (state.kind === "blocked") return state;
170
+ if (state.why !== last) deps.log("wait", state.why);
171
+ last = state.why;
172
+ if (deps.now() >= deadline) return { kind: "timeout", why: state.why };
173
+ await deps.sleep(POLL_MS);
174
+ }
175
+ }
176
+
177
+ function finish(deps: RotateDeps, code: number, result: string): number {
178
+ deps.log("result", result);
179
+ deps.out(`${result}\n`);
180
+ return code;
181
+ }
182
+
183
+ function stopped(deps: RotateDeps, waited: Exclude<Waited, { kind: "ready" }>, waitMs: number): number {
184
+ if (waited.kind === "blocked") return finish(deps, EXIT_BLOCKED, `BLOCKED 교체하지 않았습니다. ${waited.why}`);
185
+ return finish(deps, EXIT_TIMEOUT, `TIMEOUT ${waitMs / 1000}초 안에 main 차례가 끝나지 않아 교체하지 않았습니다. 마지막 상태: ${waited.why}`);
186
+ }
187
+
188
+ /** Waits for main to be free and rotates it; a rotation refused because a turn started meanwhile waits again. */
189
+ async function rotateWhenIdle(deps: RotateDeps, options: RotateOptions, deadline: number): Promise<number> {
190
+ for (;;) {
191
+ const waited = await waitIdle(deps, deadline);
192
+ if (waited.kind !== "ready") return stopped(deps, waited, options.waitMs);
193
+ deps.log("rotate", "conversation.rotate 보냄");
194
+ const outcome = await deps.rotate(ROTATED_WAIT_MS);
195
+ if (outcome.kind === "held") {
196
+ deps.log("rotate", `거절됨, 다시 기다림: ${outcome.message}`);
197
+ if (deps.now() >= deadline) return finish(deps, EXIT_TIMEOUT, `TIMEOUT 교체 요청이 계속 거절되었습니다: ${outcome.message}`);
198
+ await deps.sleep(POLL_MS);
199
+ continue;
200
+ }
201
+ if (outcome.kind === "timeout") return finish(deps, EXIT_FAILED, `FAILED ${ROTATED_WAIT_MS / 1000}초 안에 session.rotated 이벤트가 오지 않았습니다`);
202
+ if (outcome.kind === "error") return finish(deps, EXIT_FAILED, `FAILED 게이트웨이가 교체를 거절했습니다: ${outcome.message}`);
203
+ deps.log("rotated", `이전 ${outcome.from} → 새 ${outcome.to}`);
204
+ const problems = rotationProblems(outcome, deps.exists);
205
+ if (problems.length > 0) return finish(deps, EXIT_FAILED, `FAILED 교체 이벤트 확인 실패: ${problems.join("; ")}`);
206
+ return finish(deps, EXIT_OK, `ROTATED 이전 ${outcome.from} → 새 ${outcome.to}`);
207
+ }
208
+ }
209
+
210
+ async function waitProcessesGone(deps: RotateDeps): Promise<readonly string[]> {
211
+ const deadline = deps.now() + PROCESSES_GONE_MS;
212
+ for (;;) {
213
+ const left = deps.processes();
214
+ if (left.length === 0 || deps.now() >= deadline) return left;
215
+ await deps.sleep(1000);
216
+ }
217
+ }
218
+
219
+ /** After a failed step: start the gateway again so the Mac is not left without it, and never rotate. */
220
+ async function recover(deps: RotateDeps, why: string): Promise<number> {
221
+ deps.log("recover", `${why}; 게이트웨이를 다시 켭니다`);
222
+ let code = await deps.start();
223
+ if (code !== 0) {
224
+ deps.log("recover", `zigmini start exit=${code}; 한 번 더 시도합니다`);
225
+ code = await deps.start();
226
+ }
227
+ return finish(deps, EXIT_FAILED, `FAILED ${why}. 교체 요청은 보내지 않았습니다. 다시 켜기 ${code === 0 ? "성공" : `실패(exit=${code}) - zigmini start를 직접 실행하세요`}`);
228
+ }
229
+
230
+ export async function runRotate(options: RotateOptions, deps: RotateDeps): Promise<number> {
231
+ deps.log("start", `mode=${options.restart ? "restart" : "rotate"} wait=${options.waitMs / 1000}s`);
232
+ const deadline = deps.now() + options.waitMs;
233
+ if (!options.restart) return await rotateWhenIdle(deps, options, deadline);
234
+
235
+ const waited = await waitIdle(deps, deadline);
236
+ if (waited.kind !== "ready") return stopped(deps, waited, options.waitMs);
237
+ const parkedBefore = deps.parkedMain();
238
+ if (parkedBefore.length > 0) {
239
+ return finish(deps, EXIT_BLOCKED, `BLOCKED 멈추기 전 확인: 다음 엔진이 다시 열 main 세션 기록이 있습니다(${parkedBefore.join(", ")}). 게이트웨이는 그대로 둡니다`);
240
+ }
241
+ deps.log("precheck", `active=${waited.health.active ?? "?"} parked-main=0`);
242
+ const stop = await deps.stop();
243
+ deps.log("stop", `zigmini stop exit=${stop}`);
244
+ if (stop !== 0) return await recover(deps, `zigmini stop 실패(exit=${stop})`);
245
+ const left = await waitProcessesGone(deps);
246
+ if (left.length > 0) return await recover(deps, `${PROCESSES_GONE_MS / 1000}초 뒤에도 남은 프로세스: ${left.join(" | ")}`);
247
+ deps.log("stop", "게이트웨이·브리지·엔진 프로세스 모두 꺼짐");
248
+ const parked = deps.parkedMain();
249
+ if (parked.length > 0) {
250
+ return finish(deps, EXIT_BLOCKED, `BLOCKED 멈춘 뒤 확인: 다음 엔진이 다시 열 main 세션 기록이 있습니다(${parked.join(", ")}). 게이트웨이를 다시 켜지 않았습니다. 확인 뒤 zigmini start를 실행하세요`);
251
+ }
252
+ deps.log("precheck", "멈춘 뒤 parked-main=0");
253
+ const start = await deps.start();
254
+ deps.log("start", `zigmini start exit=${start}`);
255
+ if (start !== 0) return await recover(deps, `zigmini start 실패(exit=${start})`);
256
+ const after = readHealth(await deps.health());
257
+ deps.log("ready", `omo=${after?.omo ?? "?"} active=${after?.active ?? "?"}`);
258
+ return await rotateWhenIdle(deps, options, Math.max(deadline, deps.now() + options.waitMs));
259
+ }
260
+
261
+ interface SnapshotConversation { readonly id?: unknown; readonly threadId?: unknown }
262
+
263
+ /** One conversation.rotate over the gateway WebSocket, read like the web page: hello, the snapshot, then the request. */
264
+ export function socketRotate(url: string, timeoutMs: number): Promise<RotateOutcome> {
265
+ return new Promise((resolve) => {
266
+ const ws = new WebSocket(url);
267
+ let before: string | null = null;
268
+ let sent = false;
269
+ let done = false;
270
+ const end = (outcome: RotateOutcome): void => {
271
+ if (done) return;
272
+ done = true;
273
+ clearTimeout(timer);
274
+ try { ws.close(); } catch { /* already closed */ }
275
+ resolve(outcome);
276
+ };
277
+ const timer = setTimeout(() => end({ kind: "timeout" }), timeoutMs);
278
+ ws.onopen = () => ws.send(JSON.stringify({ type: "hello", data: { client: "web-dev", version: "0.1.0" } }));
279
+ ws.onmessage = (event) => {
280
+ let message: { type?: unknown; conv?: unknown; data?: unknown };
281
+ try {
282
+ message = JSON.parse(String(event.data));
283
+ } catch {
284
+ return;
285
+ }
286
+ if (message.type === "state.snapshot" && !sent) {
287
+ const conversations = (message.data as { conversations?: unknown } | null)?.conversations;
288
+ const main = Array.isArray(conversations) ? (conversations as SnapshotConversation[]).find((conv) => conv?.id === CONV) : undefined;
289
+ before = typeof main?.threadId === "string" ? main.threadId : null;
290
+ sent = true;
291
+ ws.send(JSON.stringify({ type: "conversation.rotate", conv: CONV }));
292
+ return;
293
+ }
294
+ if (!sent || message.conv !== CONV) return;
295
+ const data = (message.data ?? {}) as { from?: unknown; to?: unknown; message?: unknown };
296
+ if (message.type === "session.rotated" && typeof data.from === "string" && typeof data.to === "string") {
297
+ end({ kind: "rotated", before, from: data.from, to: data.to });
298
+ } else if (message.type === "error") {
299
+ const text = typeof data.message === "string" ? data.message : JSON.stringify(data);
300
+ end(text === ROTATION_HELD_MESSAGE ? { kind: "held", message: text } : { kind: "error", message: text });
301
+ }
302
+ };
303
+ ws.onerror = () => end({ kind: "error", message: "WebSocket 연결 오류" });
304
+ ws.onclose = () => end({ kind: "error", message: "WebSocket 연결이 닫혔습니다" });
305
+ });
306
+ }
307
+
308
+ /** Main session records the engine parked for its successor: <agent dir>/rpc-host-daemon/<host>/handover-parked/*.json. */
309
+ export function parkedMainSessions(dir: string): string[] {
310
+ const root = join(dir, "rpc-host-daemon");
311
+ if (!existsSync(root)) return [];
312
+ const found: string[] = [];
313
+ for (const host of readdirSync(root).sort()) {
314
+ const parked = join(root, host, "handover-parked");
315
+ if (!existsSync(parked)) continue;
316
+ for (const name of readdirSync(parked).sort()) {
317
+ if (!name.endsWith(".json")) continue;
318
+ try {
319
+ const entry = JSON.parse(readFileSync(join(parked, name), "utf8")) as { sessionPath?: unknown };
320
+ if (typeof entry.sessionPath === "string" && basename(entry.sessionPath).startsWith(`zgw-${CONV}-`)) found.push(entry.sessionPath);
321
+ } catch {
322
+ found.push(`${join(parked, name)} (읽을 수 없음)`);
323
+ }
324
+ }
325
+ }
326
+ return found;
327
+ }
328
+
329
+ /** This installation's gateway, bridge and engine processes: the package's two services, or anything run from the home folder. */
330
+ export function leftoverProcesses(psOutput: string, ctx: LaunchdContext, self: readonly number[]): string[] {
331
+ const services = [join(ctx.packageRoot, "src/main.ts"), join(ctx.packageRoot, "host/omo-bridge/bridge.ts")];
332
+ const left: string[] = [];
333
+ for (const line of psOutput.split("\n")) {
334
+ const match = /^\s*(\d+)\s+(.*)$/.exec(line);
335
+ if (!match) continue;
336
+ const pid = Number(match[1]);
337
+ const command = match[2]!;
338
+ if (self.includes(pid) || command.includes("/src/cli/main.ts") || !ownProcess(command, ctx)) continue;
339
+ if (!command.includes(`${ctx.paths.home}/`) && !services.some((service) => command.includes(service))) continue;
340
+ left.push(`${pid} ${command}`);
341
+ }
342
+ return left;
343
+ }
344
+
345
+ export function logLine(at: Date, pid: number, step: string, result: string): string {
346
+ return `${at.toISOString()} [${pid}] ${step}: ${result.replace(/\s+/g, " ")}\n`;
347
+ }
348
+
349
+ export function systemRotateDeps(launchd: LaunchdDeps, ctx: LaunchdContext): RotateDeps {
350
+ const logFile = join(ctx.paths.logs, LOG_FILE);
351
+ const config = (): Record<string, string> => {
352
+ const text = launchd.readText(join(ctx.paths.home, "config.env"));
353
+ return text === null ? {} : parseConfigEnv(text);
354
+ };
355
+ const quiet: LaunchdDeps = { ...launchd, out: () => {}, err: (text) => appendFileSync(logFile, logLine(new Date(), process.pid, "launchd", text)) };
356
+ return {
357
+ health: async () => {
358
+ try {
359
+ const response = await fetch(`http://127.0.0.1:${ctx.paths.ports.web}/api/health`, { signal: AbortSignal.timeout(15_000) });
360
+ return await response.json();
361
+ } catch {
362
+ return null;
363
+ }
364
+ },
365
+ rotate: (timeoutMs) => socketRotate(`ws://127.0.0.1:${ctx.paths.ports.web}/ws`, timeoutMs),
366
+ exists: existsSync,
367
+ now: Date.now,
368
+ sleep: (ms) => Bun.sleep(ms),
369
+ log: (step, result) => {
370
+ mkdirSync(ctx.paths.logs, { recursive: true });
371
+ appendFileSync(logFile, logLine(new Date(), process.pid, step, result));
372
+ },
373
+ out: (text) => { process.stdout.write(text); },
374
+ stop: () => stopCommand(quiet, ctx, false),
375
+ start: () => startCommand(quiet, ctx),
376
+ processes: () => {
377
+ const ps = Bun.spawnSync(["ps", "-axww", "-o", "pid=,command="]);
378
+ return leftoverProcesses(ps.stdout.toString(), ctx, [process.pid, process.ppid]);
379
+ },
380
+ parkedMain: () => parkedMainSessions(agentDir({ ...ctx.env, ...config() })),
381
+ };
382
+ }
383
+
384
+ /** Starts the runner in its own session (setsid) with no terminal, so it survives the engine or shell that called it. */
385
+ export function launchDetached(ctx: LaunchdContext, options: RotateOptions): number {
386
+ mkdirSync(ctx.paths.logs, { recursive: true });
387
+ const child = spawn(ctx.bunPath, [join(ctx.packageRoot, "src/cli/main.ts"), ...runnerArgs(options)], {
388
+ detached: true,
389
+ stdio: "ignore",
390
+ env: { ...ctx.env },
391
+ });
392
+ child.unref();
393
+ if (child.pid === undefined) throw new Error("rotate_runner_spawn_failed");
394
+ return child.pid;
395
+ }
396
+
397
+ export async function rotateCommand(args: readonly string[], launchd: LaunchdDeps, ctx: LaunchdContext,
398
+ detach: (ctx: LaunchdContext, options: RotateOptions) => number = launchDetached): Promise<number> {
399
+ const options = parseRotateArgs(args);
400
+ if (typeof options === "string") {
401
+ launchd.err(`${options}\n\n${ROTATE_HELP}\n`);
402
+ return EXIT_USAGE;
403
+ }
404
+ const logFile = join(ctx.paths.logs, LOG_FILE);
405
+ if (!options.foreground) {
406
+ const pid = detach(ctx, options);
407
+ launchd.out(`분리 실행을 시작했습니다: pid ${pid}, 모드 ${options.restart ? "재시작 후 교체" : "교체"}. 결과: ${logFile}\n`);
408
+ return EXIT_OK;
409
+ }
410
+ return await runRotate(options, systemRotateDeps(launchd, ctx));
411
+ }
@@ -17,7 +17,7 @@ export const STORE = join("gateway", "gateway.sqlite");
17
17
  export const GUARD_FILE = "schema-guard.json";
18
18
  export const GLOBAL_ENGINE_PACKAGE = join("install", "global", "node_modules", "omo-ai", "package.json");
19
19
  export const SCHEMA_TOO_NEW = "schema_too_new";
20
- export const NOTICE_TITLE = "OMO 스키마 충돌: 브리지를 멈췄습니다";
20
+ export const NOTICE_TITLE = "엔진 스키마 충돌: 브리지를 멈췄습니다";
21
21
 
22
22
  export type StoreReading =
23
23
  | { readonly state: "ok"; readonly version: number }