zigmini 0.8.0-beta.5 → 0.8.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.ko.md +78 -19
  2. package/README.md +79 -19
  3. package/assets/agent/gateway-request-rules.md +1 -0
  4. package/assets/agent/gateway-rules.md +6 -0
  5. package/assets/android/zigmini.apk +0 -0
  6. package/assets/android/zigmini.apk.json +3 -3
  7. package/assets/build.json +1 -1
  8. package/docs/manual.ko.md +25 -2
  9. package/docs/manual.md +25 -2
  10. package/host/omo-bridge/task-control.ts +4 -0
  11. package/host/omo-check/patches/omo-5.1.35-compaction-idle.patch +77 -0
  12. package/host/omo-check/patches/omo-5.1.35-compaction-intent.patch +212 -0
  13. package/host/omo-check/patches/omo-5.1.35-compaction-model.patch +299 -0
  14. package/host/omo-check/patches/omo-5.1.35-compaction-preservation.patch +1063 -0
  15. package/host/omo-check/patches/omo-5.1.35-compaction-refusal.patch +83 -0
  16. package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +171 -8
  17. package/host/omo-check/patches/omo-5.1.35-phantom-attach.patch +31 -0
  18. package/host/omo-check/patches/omo-5.1.35-request-rules.patch +109 -0
  19. package/host/omo-check/patches/omo-5.1.35-turn-stats.patch +76 -0
  20. package/package.json +4 -1
  21. package/scripts/secret-rules.ts +101 -0
  22. package/scripts/secret-scan.allow.json +155 -0
  23. package/scripts/secret-scan.rules.json +30 -0
  24. package/src/agent-request-rules.ts +82 -0
  25. package/src/cli/access.ts +31 -34
  26. package/src/cli/launchd.ts +30 -4
  27. package/src/cli/main.ts +13 -39
  28. package/src/cli/messages.ts +160 -0
  29. package/src/cli/pair.ts +32 -25
  30. package/src/cli/rotate-main.ts +411 -0
  31. package/src/cli/setup.ts +164 -75
  32. package/src/decisions.ts +20 -1
  33. package/src/hub.ts +140 -30
  34. package/src/main.ts +45 -4
  35. package/src/omo/client.ts +28 -0
  36. package/src/protocol.ts +33 -1
  37. package/src/reply-notifications.ts +144 -0
  38. package/src/runtime/paths.ts +2 -0
  39. package/src/security-check.ts +241 -0
  40. package/src/store.ts +71 -1
  41. package/src/turn-stats.ts +49 -0
  42. package/src/work-watch.ts +36 -10
  43. package/src/work.ts +29 -4
  44. package/web/app.js +197 -10
  45. package/web/decisions.js +3 -0
  46. package/web/gc.d.ts +2 -0
  47. package/web/gc.js +11 -0
  48. package/web/index.html +26 -10
  49. package/web/notifications.d.ts +25 -0
  50. package/web/notifications.js +118 -0
  51. package/web/style.css +16 -0
  52. package/web/tooltip.d.ts +0 -6
  53. package/web/tooltip.js +8 -11
  54. package/web/turn-stats.d.ts +26 -0
  55. package/web/turn-stats.js +58 -0
  56. package/web/update.d.ts +1 -1
  57. package/web/update.js +10 -3
package/src/cli/pair.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { QRCodeFactory } from "../../web/vendor/qrcode.js";
2
+ import { type Language, message } from "./messages.ts";
2
3
 
3
4
  /**
4
- * `zigmini pair` and the last step of `zigmini setup` (plan Todo 15): the phone enrollment QR drawn in the terminal with
5
+ * `zigmini pair` (plan Todo 15): the phone enrollment QR drawn in the terminal with
5
6
  * the same vendored qrcode-generator the web settings use. The token stays inside the QR; no text here carries it.
6
7
  * The vendored library is loaded on demand so that `src/` keeps importing without `web/` (the mutation runs copy only `src/`).
7
8
  */
@@ -9,12 +10,12 @@ import type { QRCodeFactory } from "../../web/vendor/qrcode.js";
9
10
  export const PAIR_CREATED_BY = "cli";
10
11
  export const QR_MARGIN = 4;
11
12
  export const PAIR_ERRORS = {
12
- gateway_down: "게이트웨이에 연결하지 못했습니다. `zigmini start`로 시작한 뒤 다시 하세요.",
13
- access_url_unknown: "접속 주소를 알 수 없습니다. 맥이 Wi-Fi에 연결되어 있는지 확인하세요.",
14
- bad_response: "게이트웨이가 알 수 없는 응답을 보냈습니다.",
15
- apk_unavailable: "이 설치에는 내려받을 앱(APK)이 없습니다.",
13
+ gateway_down: message("ko", "gatewayDown"),
14
+ access_url_unknown: message("ko", "accessGuidance"),
15
+ bad_response: message("ko", "badResponse"),
16
+ apk_unavailable: message("ko", "apkUnavailable"),
16
17
  } as const;
17
- export const APK_INSTALL_HINT = "출처를 알 수 없는 앱 설치 허가가 필요합니다";
18
+ export const APK_INSTALL_HINT = message("ko", "apkPermission");
18
19
  export type PairErrorReason = keyof typeof PAIR_ERRORS | `http_${number}`;
19
20
 
20
21
  export class PairError extends Error {
@@ -32,6 +33,7 @@ export interface Enrollment {
32
33
  export type FetchLike = (url: string, init: RequestInit) => Promise<{ readonly status: number; json(): Promise<unknown> }>;
33
34
 
34
35
  export interface PairDeps {
36
+ readonly lang?: Language;
35
37
  readonly fetch: FetchLike;
36
38
  readonly webPort: number;
37
39
  readonly now: () => number;
@@ -78,22 +80,27 @@ export function minutesLeft(expiresAt: number, now: number): number {
78
80
  }
79
81
 
80
82
  /** The terminal text: caption, QR and the address. It never contains the token or the payload. */
81
- export function enrollmentText(qrcode: QRCodeFactory, enrollment: Enrollment, now: number): string {
82
- return `폰 등록 QR (${minutesLeft(enrollment.expiresAt, now)}분 안에 한 번만 쓸 수 있음)\n${qrAnsi(qrcode, enrollment.payload)}`
83
- + `접속 주소: ${enrollment.url}\n폰의 지그미니 앱에서 QR을 찍으세요. 만료되면 \`zigmini pair\`로 새 QR을 만듭니다.\n`;
83
+ export function enrollmentText(qrcode: QRCodeFactory, enrollment: Enrollment, now: number, lang: Language = "ko"): string {
84
+ return message(lang, "enrollmentText", { minutes: minutesLeft(enrollment.expiresAt, now), qr: qrAnsi(qrcode, enrollment.payload), url: enrollment.url });
84
85
  }
85
86
 
86
- function failureReason(error: unknown): string {
87
+ function failureReason(error: unknown, lang: Language): string {
87
88
  const reason = error instanceof PairError ? error.reason : error instanceof Error ? error.message : String(error);
88
- return (PAIR_ERRORS as Record<string, string>)[reason] ?? `게이트웨이 응답 ${reason}`;
89
+ switch (reason) {
90
+ case "gateway_down": return message(lang, "gatewayDown");
91
+ case "access_url_unknown": return message(lang, "accessGuidance");
92
+ case "bad_response": return message(lang, "badResponse");
93
+ case "apk_unavailable": return message(lang, "apkUnavailable");
94
+ default: return message(lang, "gatewayResponse", { reason });
95
+ }
89
96
  }
90
97
 
91
- export function pairFailureLine(error: unknown): string {
92
- return `폰 등록 QR을 만들지 못했습니다: ${failureReason(error)}`;
98
+ export function pairFailureLine(error: unknown, lang: Language = "ko"): string {
99
+ return message(lang, "pairFailed", { reason: failureReason(error, lang) });
93
100
  }
94
101
 
95
- export function apkFailureLine(error: unknown): string {
96
- return `앱 내려받기 QR을 만들지 못했습니다: ${failureReason(error)}`;
102
+ export function apkFailureLine(error: unknown, lang: Language = "ko"): string {
103
+ return message(lang, "apkFailed", { reason: failureReason(error, lang) });
97
104
  }
98
105
 
99
106
  export interface ApkLink {
@@ -122,33 +129,33 @@ export async function mintApkLink(fetchFn: FetchLike, webPort: number): Promise<
122
129
  }
123
130
 
124
131
  /** The terminal text for the APK QR: caption, QR, the address without the link token, and the install permission hint. */
125
- export function apkLinkText(qrcode: QRCodeFactory, link: ApkLink, now: number): string {
126
- return `앱 내려받기 QR (지그미니 ${link.versionName}, ${minutesLeft(link.expiresAt, now)}분 동안 쓸 수 있음)\n${qrAnsi(qrcode, link.url)}`
127
- + `접속 주소: ${new URL(link.url).origin}\n폰 카메라로 QR을 찍어 APK를 받으세요. ${APK_INSTALL_HINT}. 만료되면 \`zigmini pair --apk\`로 새 QR을 만듭니다.\n`;
132
+ export function apkLinkText(qrcode: QRCodeFactory, link: ApkLink, now: number, lang: Language = "ko"): string {
133
+ return message(lang, "apkText", { version: link.versionName, minutes: minutesLeft(link.expiresAt, now), qr: qrAnsi(qrcode, link.url), url: new URL(link.url).origin, permission: message(lang, "apkPermission") });
128
134
  }
129
135
 
130
- export async function apkQr(fetchFn: FetchLike, webPort: number, now: () => number): Promise<string> {
136
+ export async function apkQr(fetchFn: FetchLike, webPort: number, now: () => number, lang: Language = "ko"): Promise<string> {
131
137
  const link = await mintApkLink(fetchFn, webPort);
132
- return apkLinkText(await loadQrcode(), link, now());
138
+ return apkLinkText(await loadQrcode(), link, now(), lang);
133
139
  }
134
140
 
135
141
  async function loadQrcode(): Promise<QRCodeFactory> {
136
142
  return (await import("../../web/vendor/qrcode.js")).default;
137
143
  }
138
144
 
139
- /** The QR text for setup's last step and `zigmini pair`; throws PairError when the gateway cannot give a token. */
140
- export async function enrollmentQr(fetchFn: FetchLike, webPort: number, now: () => number): Promise<string> {
145
+ /** The QR text for `zigmini pair`; throws PairError when the gateway cannot give a token. */
146
+ export async function enrollmentQr(fetchFn: FetchLike, webPort: number, now: () => number, lang: Language = "ko"): Promise<string> {
141
147
  const enrollment = await mintEnrollment(fetchFn, webPort);
142
- return enrollmentText(await loadQrcode(), enrollment, now());
148
+ return enrollmentText(await loadQrcode(), enrollment, now(), lang);
143
149
  }
144
150
 
145
151
  /** `zigmini pair` draws the enrollment QR; `zigmini pair --apk` draws the APK download QR instead. */
146
152
  export async function pairCommand(deps: PairDeps, apk = false): Promise<number> {
153
+ const lang = deps.lang ?? "ko";
147
154
  try {
148
- deps.out(apk ? await apkQr(deps.fetch, deps.webPort, deps.now) : await enrollmentQr(deps.fetch, deps.webPort, deps.now));
155
+ deps.out(apk ? await apkQr(deps.fetch, deps.webPort, deps.now, lang) : await enrollmentQr(deps.fetch, deps.webPort, deps.now, lang));
149
156
  return 0;
150
157
  } catch (error) {
151
- deps.err(`${apk ? apkFailureLine(error) : pairFailureLine(error)}\n`);
158
+ deps.err(`${apk ? apkFailureLine(error, lang) : pairFailureLine(error, lang)}\n`);
152
159
  return 1;
153
160
  }
154
161
  }
@@ -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
+ }