zigmini 0.8.0-beta.5 → 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/README.ko.md +1 -0
- package/README.md +1 -0
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +2 -1
- package/docs/manual.md +2 -1
- package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +4 -3
- package/host/omo-check/patches/omo-5.1.35-phantom-attach.patch +31 -0
- package/package.json +1 -1
- package/src/cli/main.ts +14 -0
- package/src/cli/rotate-main.ts +411 -0
- package/src/main.ts +2 -2
- package/src/protocol.ts +3 -0
- package/web/app.js +39 -10
- package/web/gc.d.ts +2 -0
- package/web/gc.js +11 -0
- package/web/index.html +10 -10
- package/web/style.css +4 -0
- package/web/tooltip.d.ts +0 -6
- package/web/tooltip.js +8 -11
- package/web/update.d.ts +1 -1
- package/web/update.js +10 -3
package/README.ko.md
CHANGED
|
@@ -208,6 +208,7 @@ zigmini update <버전> # 지정한 버전으로 업데이트
|
|
|
208
208
|
| `zigmini pair [--apk]` | 폰 등록 QR 또는 앱 내려받기 QR을 그립니다 |
|
|
209
209
|
| `zigmini consent` | 권한 안내를 보이고 동의를 기록합니다 |
|
|
210
210
|
| `zigmini update [버전] [--force]` | 업데이트합니다 |
|
|
211
|
+
| `zigmini rotate-main [--restart] [--wait <초>] [--foreground]` | main 대화를 새 엔진 세션으로 교체합니다. 웹의 "새 세션"과 같은 요청입니다. 분리 실행하고 결과는 `~/.zigmini/logs/rotate-main.log`에 남깁니다. `--restart`는 게이트웨이를 먼저 멈추고 다시 켭니다 |
|
|
211
212
|
| `zigmini migrate` | 옛 Docker 설치(`~/.zigmini-gateway`)를 새 구조로 복사합니다. 옛 폴더는 바꾸지 않습니다 |
|
|
212
213
|
| `zigmini migrate-run` | 유휴를 기다리고, 옛 실행을 멈추고, 백업하고, migrate와 setup을 실행한 뒤 확인합니다. 실패하면 옛 실행으로 되돌립니다 |
|
|
213
214
|
| `zigmini --version` | 설치된 버전을 출력합니다 |
|
package/README.md
CHANGED
|
@@ -208,6 +208,7 @@ An update to a version that does not support the zero-downtime switch (including
|
|
|
208
208
|
| `zigmini pair [--apk]` | Draws the phone registration QR code or the app download QR code |
|
|
209
209
|
| `zigmini consent` | Shows the permission notice and records the consent |
|
|
210
210
|
| `zigmini update [버전] [--force]` | Updates (`버전`: version) |
|
|
211
|
+
| `zigmini rotate-main [--restart] [--wait <초>] [--foreground]` | Moves the main conversation to a fresh engine session, like "새 세션" (New session) in the web. Runs detached and logs to `~/.zigmini/logs/rotate-main.log`. `--restart` stops and starts the gateway first (`<초>`: seconds) |
|
|
211
212
|
| `zigmini migrate` | Copies an old Docker installation (`~/.zigmini-gateway`) to the new layout. The old folder is not changed |
|
|
212
213
|
| `zigmini migrate-run` | Waits until idle, stops the old runtime, backs up, runs migrate and setup, then checks. If this fails, it goes back to the old runtime |
|
|
213
214
|
| `zigmini --version` | Prints the installed version |
|
|
Binary file
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"versionCode":
|
|
3
|
-
"versionName": "0.8.0-beta.
|
|
4
|
-
"sha256": "
|
|
2
|
+
"versionCode": 25,
|
|
3
|
+
"versionName": "0.8.0-beta.6",
|
|
4
|
+
"sha256": "4fc49f125efaa2ae1118d4724b7a2370c7d1c80227f9228858266eea64678155"
|
|
5
5
|
}
|
package/assets/build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"commit":"
|
|
1
|
+
{"commit":"46f57dd257c4451fbc4f2d9f4022501e20798032"}
|
package/docs/manual.ko.md
CHANGED
|
@@ -208,7 +208,7 @@ Android 앱(APK)을 받는 QR이 보입니다. 폰 카메라로 QR을 찍어 APK
|
|
|
208
208
|
|
|
209
209
|
#### 정리
|
|
210
210
|
|
|
211
|
-
게이트웨이가 지워도 되는 후보를 보여 줍니다. 후보는 DB 백업, 엔진 버전 폴더, 업데이트 남은 파일, 임시 폴더, 이전 RPC 데몬입니다. 지울 항목을 고르고 "선택한 항목 정리"를 누릅니다.
|
|
211
|
+
게이트웨이가 지워도 되는 후보를 보여 줍니다. 후보는 DB 백업, 엔진 버전 폴더, 업데이트 남은 파일, 임시 폴더, 이전 RPC 데몬입니다. 지울 항목을 고르고 "선택한 항목 정리"를 누릅니다. 후보 수 옆의 "전체 선택"을 누르면 모든 후보를 한 번에 고르거나 모두 풉니다.
|
|
212
212
|
|
|
213
213
|
#### 음성 설정
|
|
214
214
|
|
|
@@ -378,5 +378,6 @@ Ctrl 단축키는 Cmd, Alt, Shift와 함께 누르면 동작하지 않습니다.
|
|
|
378
378
|
- `zigmini pair [--apk]`: 폰 등록 QR이나 앱 내려받기 QR을 보여 줍니다.
|
|
379
379
|
- `zigmini consent`: 권한 안내를 보이고 동의를 기록합니다.
|
|
380
380
|
- `zigmini update [버전]`: 업데이트합니다.
|
|
381
|
+
- `zigmini rotate-main [--restart]`: main 대화를 새 세션으로 교체합니다. "새 세션"과 같습니다. 먼저 운영자 허가를 받습니다. 자세한 내용은 운영 문서를 보세요.
|
|
381
382
|
|
|
382
383
|
전체 목록은 README의 '명령' 절을 보세요.
|
package/docs/manual.md
CHANGED
|
@@ -208,7 +208,7 @@ A QR for the Android app (APK) shows. Scan it with the phone camera, get the APK
|
|
|
208
208
|
|
|
209
209
|
#### Cleanup (정리)
|
|
210
210
|
|
|
211
|
-
The gateway lists things that are safe to delete: DB backups, engine version folders, leftover update files, temp folders and old RPC daemons. Tick the ones to remove and click 선택한 항목 정리 (clean selected).
|
|
211
|
+
The gateway lists things that are safe to delete: DB backups, engine version folders, leftover update files, temp folders and old RPC daemons. Tick the ones to remove and click 선택한 항목 정리 (clean selected). The 전체 선택 (select all) box beside the count ticks every candidate at once, or clears them all.
|
|
212
212
|
|
|
213
213
|
#### Voice settings
|
|
214
214
|
|
|
@@ -378,5 +378,6 @@ Main commands for the Mac terminal:
|
|
|
378
378
|
- `zigmini pair [--apk]`: shows the phone registration QR or the app download QR.
|
|
379
379
|
- `zigmini consent`: shows the permission notice and records consent.
|
|
380
380
|
- `zigmini update [version]`: updates.
|
|
381
|
+
- `zigmini rotate-main [--restart]`: moves the main conversation to a new session, like "새 세션" (new session). Ask the operator first. See the operator guide.
|
|
381
382
|
|
|
382
383
|
For the full list, see the README's Commands section.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
|
-
"revision":
|
|
3
|
+
"revision": 4,
|
|
4
4
|
"upstream": {
|
|
5
5
|
"omo": "5.1.35",
|
|
6
6
|
"senpi": "2026.10.16"
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"patches": [
|
|
13
13
|
"omo-5.1.35-gateway-port.patch",
|
|
14
14
|
"omo-5.1.35-image-replay.patch",
|
|
15
|
-
"omo-5.1.35-question-capability.patch"
|
|
15
|
+
"omo-5.1.35-question-capability.patch",
|
|
16
|
+
"omo-5.1.35-phantom-attach.patch"
|
|
16
17
|
],
|
|
17
18
|
"files": [
|
|
18
19
|
{
|
|
@@ -148,7 +149,7 @@
|
|
|
148
149
|
{
|
|
149
150
|
"path": "node_modules/@code-yeongyu/senpi/dist/modes/rpc/session-command-router.js",
|
|
150
151
|
"originalSha256": "3f952631024884c893a6e37549c2861e25b0bddc2387222006b77dcbaad78c9b",
|
|
151
|
-
"patchedSha256": "
|
|
152
|
+
"patchedSha256": "a2ea46300a8d7b4cb46413afd5389ad8df37707c2852f3f4b826011a28eea6ef"
|
|
152
153
|
},
|
|
153
154
|
{
|
|
154
155
|
"path": "node_modules/@earendil-works/pi-agent-core/dist/agent.d.ts",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
diff --git a/@code-yeongyu/senpi/dist/modes/rpc/session-command-router.js b/@code-yeongyu/senpi/dist/modes/rpc/session-command-router.js
|
|
2
|
+
--- a/@code-yeongyu/senpi/dist/modes/rpc/session-command-router.js
|
|
3
|
+
+++ b/@code-yeongyu/senpi/dist/modes/rpc/session-command-router.js
|
|
4
|
+
@@ -584,7 +584,17 @@
|
|
5
|
+
* the failure response otherwise, exactly as an open does.
|
|
6
|
+
*/
|
|
7
|
+
reopenParked(entry) {
|
|
8
|
+
- return this.open(parkedOpenCommand(entry), undefined, { reply: false, defaults: false });
|
|
9
|
+
+ return this.open(parkedOpenCommand(entry), undefined, { reply: false, defaults: false, releaseUnowned: true });
|
|
10
|
+
+ }
|
|
11
|
+
+ /**
|
|
12
|
+
+ * zgw (rotation after a handoff): the registry counts one attachment for every open, and an owner-less reopen has no
|
|
13
|
+
+ * connection that could ever release it, so a later close by the real owner left the session open. Release it as a
|
|
14
|
+
+ * detach: a retained entry stays open at zero attachments, exactly as when its last client leaves.
|
|
15
|
+
+ */
|
|
16
|
+
+ releaseUnownedReopen(sessionId) {
|
|
17
|
+
+ if (this.draining || this.registry.peek(sessionId)?.retainOnDisconnect !== true)
|
|
18
|
+
+ return false;
|
|
19
|
+
+ return this.tryClaimClose(sessionId, { drainAttachments: false, detach: true }) !== undefined;
|
|
20
|
+
}
|
|
21
|
+
async open(command, owner, options) {
|
|
22
|
+
// A parked session's reopen replays what its park recorded; a field it left empty stays empty
|
|
23
|
+
@@ -729,6 +739,8 @@
|
|
24
|
+
this.releaseOwnerAttachment(owner, openedSession.sessionId);
|
|
25
|
+
await this.releaseOwnedSession(openedSession.sessionId);
|
|
26
|
+
}
|
|
27
|
+
+ if (owner === undefined && options?.releaseUnowned === true)
|
|
28
|
+
+ this.releaseUnownedReopen(openedSession.sessionId);
|
|
29
|
+
const replyState = entry.worker?.snapshot?.state ?? (entry.runtime ? buildRpcSessionState(entry.runtime.session) : undefined);
|
|
30
|
+
if (!replyState)
|
|
31
|
+
throw new Error("Session state was not created");
|
package/package.json
CHANGED
package/src/cli/main.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { setupHint } from "./setup-hint.ts";
|
|
|
11
11
|
import { helperCommand, updateCommand } from "../update/cli.ts";
|
|
12
12
|
import { handoffCommand } from "../update/handoff-system.ts";
|
|
13
13
|
import { HANDOFF_COMMAND } from "../update/strategy.ts";
|
|
14
|
+
import { ROTATE_COMMAND, ROTATE_RUN_COMMAND, rotateCommand } from "./rotate-main.ts";
|
|
14
15
|
import { EXIT_USAGE, parseMigrateArgs, parseMigrateRunArgs, runMigrate, runMigrateRun, systemMigrateDeps, systemMigrateRunDeps } from "./migrate.ts";
|
|
15
16
|
|
|
16
17
|
export const HELP = `zigmini - 개인 AI 에이전트 게이트웨이
|
|
@@ -29,6 +30,10 @@ export const HELP = `zigmini - 개인 AI 에이전트 게이트웨이
|
|
|
29
30
|
doctor [--json] [--fix] 전체 상태를 점검합니다(--fix: 이상 있는 launchd 에이전트만 다시 띄움)
|
|
30
31
|
pair 폰 등록 QR(유효 10분, 한 번 사용)을 터미널에 그립니다. 게이트웨이가 실행 중이어야 합니다
|
|
31
32
|
pair --apk 앱(APK) 내려받기 QR(유효 10분)을 터미널에 그립니다
|
|
33
|
+
rotate-main [--restart] [--wait <초>] [--foreground]
|
|
34
|
+
main 대화를 새 엔진 세션으로 교체합니다. main 차례가 끝나길 기다린 뒤 보냅니다(기본 900초).
|
|
35
|
+
기본은 분리 실행이고 결과는 <홈>/logs/rotate-main.log에 남깁니다
|
|
36
|
+
(--restart: 먼저 게이트웨이를 멈추고 다시 켭니다. --foreground: 이 터미널에서 실행)
|
|
32
37
|
consent 풀 액세스 권한 안내를 보이고 동의를 기록합니다(거부하면 멈춤)
|
|
33
38
|
update [버전] [--force] 새 버전으로 업데이트합니다(비우면 latest). DB 백업 뒤 설치·재시작하고, 실패하면 자동으로 되돌립니다
|
|
34
39
|
(--force: 진행 중인 턴이 있어도 합니다. 게이트웨이가 실행 중이어야 합니다)
|
|
@@ -127,6 +132,15 @@ if (import.meta.main) {
|
|
|
127
132
|
}
|
|
128
133
|
if (argv[0] === UPDATER_COMMAND) process.exit(await helperCommand(deps, ctx));
|
|
129
134
|
if (argv[0] === HANDOFF_COMMAND) process.exit(await handoffCommand(deps, ctx, argv.slice(1)));
|
|
135
|
+
if (argv[0] === ROTATE_COMMAND || argv[0] === ROTATE_RUN_COMMAND) {
|
|
136
|
+
const refusal = argv.includes("--restart") ? cliGate("restart", ctx.paths.home, kind, notice) : null;
|
|
137
|
+
if (refusal !== null) {
|
|
138
|
+
deps.err(`${refusal}\n`);
|
|
139
|
+
process.exit(EXIT_DECLINED);
|
|
140
|
+
}
|
|
141
|
+
const args = argv[0] === ROTATE_RUN_COMMAND ? [...argv.slice(1), "--foreground"] : argv.slice(1);
|
|
142
|
+
process.exit(await rotateCommand(args, deps, ctx));
|
|
143
|
+
}
|
|
130
144
|
if (argv[0] === "update") {
|
|
131
145
|
process.exit(await updateCommand(argv.slice(1), { webPort: ctx.paths.ports.web, fetch: (url, init) => fetch(url, init), prompt: stdinPrompt(),
|
|
132
146
|
out: deps.out, err: deps.err, sleep: (ms) => Bun.sleep(ms), now: Date.now }));
|
|
@@ -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
|
+
}
|
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" ?
|
|
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/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/web/app.js
CHANGED
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
import { createEnrollment, NO_DEVICES_LINE, REVOKE_ARMED_LABEL, REVOKE_LABEL } from "./enroll.js";
|
|
43
43
|
import { canUpdate, checkFailedLine, noticeLine, parseNotice, progressLine, questionGate, resultLine, UPDATE_CHECK_PATH, UPDATE_CONSENT_PATH, UPDATE_PATH, UPDATE_STATE_PATH, UPDATE_STATUS_PATH, updateStep, updateView } from "./update.js";
|
|
44
44
|
import { createAppDownload } from "./app-download.js";
|
|
45
|
-
import { cleanRequest, cleanResultLine, GC_CLEAN_PATH, GC_PATH, gcView } from "./gc.js";
|
|
45
|
+
import { cleanRequest, cleanResultLine, GC_CLEAN_PATH, GC_PATH, gcView, selectAllState, selectAllTarget } from "./gc.js";
|
|
46
46
|
import { manualFailureText, manualLinkLang, manualUrl, otherLang } from "./manual.js";
|
|
47
47
|
|
|
48
48
|
installTooltips(document, window);
|
|
@@ -3422,14 +3422,23 @@ async function checkUpdateNow() {
|
|
|
3422
3422
|
else showUpdateError(checkFailedLine(res?.status ?? 0));
|
|
3423
3423
|
}
|
|
3424
3424
|
|
|
3425
|
+
// The version this page first loaded on: a stored ok result that ended there is stale after a reload (live update.result is not filtered).
|
|
3426
|
+
let pageVersion = null;
|
|
3427
|
+
|
|
3425
3428
|
function loadUpdateStatus() {
|
|
3426
3429
|
fetch(UPDATE_STATUS_PATH)
|
|
3427
3430
|
.then((res) => (res.ok ? res.json() : null))
|
|
3428
|
-
.
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
+
.catch((error) => {
|
|
3432
|
+
console.warn("update status load:", error);
|
|
3433
|
+
return null;
|
|
3434
|
+
})
|
|
3435
|
+
.then((status) => {
|
|
3436
|
+
renderUpdate(status);
|
|
3437
|
+
if (pageVersion === null && typeof status?.current === "string") pageVersion = status.current;
|
|
3438
|
+
return fetch(UPDATE_STATE_PATH);
|
|
3439
|
+
})
|
|
3431
3440
|
.then((res) => (res.ok ? res.json() : null))
|
|
3432
|
-
.then((state) => showUpdateProgress(progressLine(state)))
|
|
3441
|
+
.then((state) => showUpdateProgress(progressLine(state, pageVersion)))
|
|
3433
3442
|
.catch((error) => console.warn("update state load:", error));
|
|
3434
3443
|
}
|
|
3435
3444
|
|
|
@@ -3583,7 +3592,28 @@ const appDownload = createAppDownload({
|
|
|
3583
3592
|
apkEls.renew.onclick = () => void appDownload.renew();
|
|
3584
3593
|
|
|
3585
3594
|
// 정리 (improvement 66): nothing is cleaned until the user ticks rows and presses the button.
|
|
3586
|
-
const gcEls = { summary: $("gc-summary"), list: $("gc-list"), message: $("gc-message"), clean: $("gc-clean") };
|
|
3595
|
+
const gcEls = { summary: $("gc-summary"), list: $("gc-list"), message: $("gc-message"), clean: $("gc-clean"), all: $("gc-all"), allLabel: $("gc-all-label") };
|
|
3596
|
+
|
|
3597
|
+
function gcBoxes() {
|
|
3598
|
+
return [...gcEls.list.querySelectorAll("input[type=checkbox]")];
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
// Improvement 142: the 전체 선택 box and the clean button follow the ticked rows.
|
|
3602
|
+
function gcSync() {
|
|
3603
|
+
const ticked = gcTicked().length;
|
|
3604
|
+
const state = selectAllState(ticked, gcBoxes().length);
|
|
3605
|
+
gcEls.allLabel.hidden = state.hidden;
|
|
3606
|
+
gcEls.all.checked = state.checked;
|
|
3607
|
+
gcEls.all.indeterminate = state.indeterminate;
|
|
3608
|
+
gcEls.clean.disabled = ticked === 0;
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
gcEls.all.onchange = () => {
|
|
3612
|
+
const boxes = gcBoxes();
|
|
3613
|
+
const tick = selectAllTarget(gcTicked().length, boxes.length);
|
|
3614
|
+
for (const box of boxes) box.checked = tick;
|
|
3615
|
+
gcSync();
|
|
3616
|
+
};
|
|
3587
3617
|
|
|
3588
3618
|
function gcTicked() {
|
|
3589
3619
|
return [...gcEls.list.querySelectorAll("input[type=checkbox]:checked")].map((box) => box.value);
|
|
@@ -3598,16 +3628,14 @@ function gcRow(row) {
|
|
|
3598
3628
|
const box = document.createElement("input");
|
|
3599
3629
|
box.type = "checkbox";
|
|
3600
3630
|
box.value = row.id;
|
|
3601
|
-
box.onchange =
|
|
3631
|
+
box.onchange = gcSync;
|
|
3602
3632
|
const name = document.createElement("span");
|
|
3603
3633
|
name.className = "gc-name";
|
|
3604
3634
|
name.textContent = row.label;
|
|
3605
|
-
name.
|
|
3606
|
-
name.dataset.tipOverflow = "";
|
|
3635
|
+
name.title = row.title;
|
|
3607
3636
|
const sub = document.createElement("span");
|
|
3608
3637
|
sub.className = "gc-sub";
|
|
3609
3638
|
sub.textContent = row.sub;
|
|
3610
|
-
sub.dataset.tipOverflow = "";
|
|
3611
3639
|
main.append(name, sub);
|
|
3612
3640
|
label.append(box, main);
|
|
3613
3641
|
li.append(label);
|
|
@@ -3627,6 +3655,7 @@ async function loadGc() {
|
|
|
3627
3655
|
gcEls.summary.textContent = view.summary;
|
|
3628
3656
|
gcEls.summary.classList.toggle("error", view.error);
|
|
3629
3657
|
gcEls.list.replaceChildren(...view.rows.map(gcRow));
|
|
3658
|
+
gcSync();
|
|
3630
3659
|
}
|
|
3631
3660
|
|
|
3632
3661
|
gcEls.clean.onclick = async () => {
|
package/web/gc.d.ts
CHANGED
|
@@ -9,5 +9,7 @@ export const GC_FAILED_LINE: string;
|
|
|
9
9
|
export function formatBytes(bytes: unknown): string;
|
|
10
10
|
export function formatAge(ms: number): string;
|
|
11
11
|
export function gcView(report: unknown): { summary: string; rows: GcRow[]; error: boolean };
|
|
12
|
+
export function selectAllState(ticked: number, total: number): { hidden: boolean; checked: boolean; indeterminate: boolean };
|
|
13
|
+
export function selectAllTarget(ticked: number, total: number): boolean;
|
|
12
14
|
export function cleanRequest(ids: Iterable<string>): { method: "POST"; headers: { "content-type": "application/json" }; body: string };
|
|
13
15
|
export function cleanResultLine(result: unknown): string;
|
package/web/gc.js
CHANGED
|
@@ -39,6 +39,17 @@ export function gcView(report) {
|
|
|
39
39
|
return { summary, rows, error: false };
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Improvement 142: the 전체 선택 box beside the summary. It is hidden with no rows, checked when every row is ticked and
|
|
43
|
+
// indeterminate when only some are.
|
|
44
|
+
export function selectAllState(ticked, total) {
|
|
45
|
+
return { hidden: total === 0, checked: total > 0 && ticked === total, indeterminate: ticked > 0 && ticked < total };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A click on 전체 선택 ticks every row unless every row was already ticked; then it clears them all.
|
|
49
|
+
export function selectAllTarget(ticked, total) {
|
|
50
|
+
return ticked < total;
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
export function cleanRequest(ids) {
|
|
43
54
|
return { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ids: [...ids] }) };
|
|
44
55
|
}
|
package/web/index.html
CHANGED
|
@@ -62,14 +62,14 @@
|
|
|
62
62
|
<div class="chat-actions">
|
|
63
63
|
<select class="model-select" id="model-select" title="엔진이 쓸 모델" disabled><option>모델 불러오는 중</option></select>
|
|
64
64
|
<span class="model-pending" id="model-pending" hidden></span>
|
|
65
|
-
<button class="text-btn" id="rotate-session" title="이 대화의 엔진 세션을 새로 시작합니다. 화면의 기록은 그대로 남습니다.">새 세션</button>
|
|
66
|
-
<button class="text-btn" id="toggle-voice" title="이 게이트웨이의 음성 서버(음성 인식·음성 합성)를 켜고 끄기. 끄면 마이크·호출어·박수 감지·음성 답변을 쓸 수 없습니다." aria-pressed="true">음성 켜짐</button>
|
|
67
|
-
<button class="text-btn" id="voice-install" title="음성 서버(uv, Python 환경, 음성 모델)를 이 맥의 zigmini 폴더에 내려받아 설치합니다" hidden>음성 설치</button>
|
|
68
|
-
<button class="text-btn" id="toggle-clap" title="박수를 두 번 치면 호출어 대기를 켜고 끄기" aria-pressed="false">박수 감지 꺼짐</button>
|
|
69
|
-
<button class="text-btn" id="toggle-wake" title=""헤이 지그"라고 부르면 받아 적어 입력창에 채우기" aria-pressed="false">호출어 꺼짐</button>
|
|
70
|
-
<button class="text-btn" id="toggle-tts" title="답변을 음성으로 읽기" aria-pressed="false">음성 답변 꺼짐</button>
|
|
71
|
-
<button type="button" class="text-btn inbox-toggle" id="toggle-inbox" title="답을 기다리는 질문과 승인 요청 (Ctrl+C)" aria-haspopup="dialog" aria-expanded="false" aria-controls="inbox" aria-keyshortcuts="Control+C">확인함<span class="inbox-badge" id="inbox-badge" hidden>0</span></button>
|
|
72
|
-
<button type="button" class="text-btn" id="toggle-dev" title="이벤트 패널 (Ctrl+E)" aria-expanded="false" aria-controls="dev" aria-keyshortcuts="Control+E">이벤트</button>
|
|
65
|
+
<button class="text-btn" id="rotate-session" data-tip-keep title="이 대화의 엔진 세션을 새로 시작합니다. 화면의 기록은 그대로 남습니다.">새 세션</button>
|
|
66
|
+
<button class="text-btn" id="toggle-voice" data-tip-keep title="이 게이트웨이의 음성 서버(음성 인식·음성 합성)를 켜고 끄기. 끄면 마이크·호출어·박수 감지·음성 답변을 쓸 수 없습니다." aria-pressed="true">음성 켜짐</button>
|
|
67
|
+
<button class="text-btn" id="voice-install" data-tip-keep title="음성 서버(uv, Python 환경, 음성 모델)를 이 맥의 zigmini 폴더에 내려받아 설치합니다" hidden>음성 설치</button>
|
|
68
|
+
<button class="text-btn" id="toggle-clap" data-tip-keep title="박수를 두 번 치면 호출어 대기를 켜고 끄기" aria-pressed="false">박수 감지 꺼짐</button>
|
|
69
|
+
<button class="text-btn" id="toggle-wake" data-tip-keep title=""헤이 지그"라고 부르면 받아 적어 입력창에 채우기" aria-pressed="false">호출어 꺼짐</button>
|
|
70
|
+
<button class="text-btn" id="toggle-tts" data-tip-keep title="답변을 음성으로 읽기" aria-pressed="false">음성 답변 꺼짐</button>
|
|
71
|
+
<button type="button" class="text-btn inbox-toggle" id="toggle-inbox" data-tip-keep title="답을 기다리는 질문과 승인 요청 (Ctrl+C)" aria-haspopup="dialog" aria-expanded="false" aria-controls="inbox" aria-keyshortcuts="Control+C">확인함<span class="inbox-badge" id="inbox-badge" hidden>0</span></button>
|
|
72
|
+
<button type="button" class="text-btn" id="toggle-dev" data-tip-keep title="이벤트 패널 (Ctrl+E)" aria-expanded="false" aria-controls="dev" aria-keyshortcuts="Control+E">이벤트</button>
|
|
73
73
|
<button type="button" class="text-btn" id="toggle-settings" title="설정: 폰 기기 등록 QR과 등록된 기기" aria-haspopup="dialog" aria-expanded="false" aria-controls="settings">설정</button>
|
|
74
74
|
</div>
|
|
75
75
|
</header>
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
<button type="button" class="mic" id="toggle-memo" title="쪽지 열기/닫기" aria-pressed="false">쪽지</button>
|
|
101
101
|
<button type="button" class="mic" id="attach" title="이미지 첨부 (최대 4장 · 붙여넣기나 끌어놓기도 됩니다)">이미지</button>
|
|
102
102
|
<input type="file" id="attach-input" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden>
|
|
103
|
-
<button type="button" class="mic" id="mic" title="누르고 말하기 (다시 누르면 전송)">마이크</button>
|
|
103
|
+
<button type="button" class="mic" id="mic" data-tip-keep title="누르고 말하기 (다시 누르면 전송)">마이크</button>
|
|
104
104
|
<textarea id="input" rows="1" placeholder="메시지 보내기" title="Enter 전송, Shift+Enter 줄바꿈"></textarea>
|
|
105
105
|
<button type="submit" class="send" id="send">전송</button>
|
|
106
106
|
<button type="button" class="stop" id="interrupt" title="진행 중인 작업 중단" hidden>중단</button>
|
|
@@ -178,7 +178,7 @@
|
|
|
178
178
|
<div class="enroll-actions"><button type="button" class="text-btn" id="apk-renew" hidden>새로 만들기</button></div>
|
|
179
179
|
</section>
|
|
180
180
|
<section class="settings-section gc" id="gc" aria-labelledby="gc-title">
|
|
181
|
-
<div class="section-label"><span id="gc-title">정리</span><span class="gc-summary" id="gc-summary" role="status" aria-live="polite"></span></div>
|
|
181
|
+
<div class="section-label"><span id="gc-title">정리</span><span class="gc-head"><span class="gc-summary" id="gc-summary" role="status" aria-live="polite"></span><label class="gc-all" id="gc-all-label" hidden><input type="checkbox" id="gc-all">전체 선택</label></span></div>
|
|
182
182
|
<ul class="gc-list" id="gc-list" aria-label="정리 후보"></ul>
|
|
183
183
|
<p class="gc-message" id="gc-message" role="status" aria-live="polite"></p>
|
|
184
184
|
<div class="gc-actions"><button type="button" class="text-btn" id="gc-clean" disabled>선택한 항목 정리</button></div>
|
package/web/style.css
CHANGED
|
@@ -412,6 +412,10 @@ button { font: inherit; color: inherit; cursor: pointer; }
|
|
|
412
412
|
/* 정리 (improvement 66): inside .settings-body after 앱 내려받기; contain keeps its nowrap rows from widening the card on phones. */
|
|
413
413
|
.settings-section.gc { contain: inline-size; }
|
|
414
414
|
.gc-summary { text-transform: none; color: var(--text); }
|
|
415
|
+
.gc-head { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
|
416
|
+
.gc-all { display: inline-flex; align-items: center; gap: 6px; text-transform: none; color: var(--text); font-weight: 400; cursor: pointer; white-space: nowrap; }
|
|
417
|
+
.gc-all[hidden] { display: none; }
|
|
418
|
+
.gc-all input { width: 16px; height: 16px; margin: 0; }
|
|
415
419
|
.gc-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; }
|
|
416
420
|
.gc-list:empty { display: none; }
|
|
417
421
|
.gc-row { display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: var(--bg-card); border: 1px solid #4e5058; border-radius: 6px; font-size: 13px; }
|
package/web/tooltip.d.ts
CHANGED
|
@@ -4,17 +4,11 @@ export type TipElement = {
|
|
|
4
4
|
setAttribute(name: string, value: string): void;
|
|
5
5
|
removeAttribute(name: string): void;
|
|
6
6
|
hasAttribute(name: string): boolean;
|
|
7
|
-
textContent: string | null;
|
|
8
|
-
scrollWidth: number;
|
|
9
|
-
clientWidth: number;
|
|
10
|
-
scrollHeight: number;
|
|
11
|
-
clientHeight: number;
|
|
12
7
|
};
|
|
13
8
|
export const TIP_GAP: number;
|
|
14
9
|
export const TIP_MARGIN: number;
|
|
15
10
|
export const TIP_ID: string;
|
|
16
11
|
export function placeTip(anchor: Box, tip: { width: number; height: number }, view: { width: number; height: number }): { left: number; top: number; side: "above" | "below" };
|
|
17
|
-
export function isTruncated(el: { scrollWidth: number; clientWidth: number; scrollHeight: number; clientHeight: number }): boolean;
|
|
18
12
|
export function adoptTitle(el: Pick<TipElement, "getAttribute" | "setAttribute" | "removeAttribute" | "hasAttribute">): void;
|
|
19
13
|
export function tipText(el: TipElement): string;
|
|
20
14
|
export function addId(list: string | null, id: string): string;
|
package/web/tooltip.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// Bug 90: the page draws its own tooltip. Some browsers (the user's, likely Aside) never show the native `title`
|
|
2
|
-
// tooltip, so
|
|
3
|
-
//
|
|
2
|
+
// tooltip, so a `title` moves to `data-tip` and one shared element shows it on hover and keyboard focus, linked with
|
|
3
|
+
// aria-describedby. Bug 161: only elements marked `data-tip-keep` (새 세션, the voice buttons, 확인함, 이벤트) keep a
|
|
4
|
+
// visible tip; every other `title` moves to `aria-description`, so neither the page nor the browser shows a tip for it.
|
|
4
5
|
|
|
5
6
|
export const TIP_GAP = 6;
|
|
6
7
|
export const TIP_MARGIN = 8;
|
|
7
8
|
export const TIP_ID = "zgw-tip";
|
|
8
9
|
const HOVER_DELAY_MS = 250;
|
|
9
|
-
const SELECTOR = "[data-tip]
|
|
10
|
+
const SELECTOR = "[data-tip]";
|
|
10
11
|
|
|
11
12
|
export function placeTip(anchor, tip, view) {
|
|
12
13
|
const left = Math.max(TIP_MARGIN, Math.min((anchor.left + anchor.right) / 2 - tip.width / 2, view.width - tip.width - TIP_MARGIN));
|
|
@@ -18,21 +19,17 @@ export function placeTip(anchor, tip, view) {
|
|
|
18
19
|
return { left, top, side: "below" };
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
export function isTruncated(el) {
|
|
22
|
-
return el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
22
|
export function adoptTitle(el) {
|
|
26
23
|
if (!el.hasAttribute("title")) return;
|
|
27
24
|
const text = el.getAttribute("title");
|
|
28
25
|
el.removeAttribute("title");
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
const attr = el.hasAttribute("data-tip-keep") ? "data-tip" : "aria-description";
|
|
27
|
+
if (text) el.setAttribute(attr, text);
|
|
28
|
+
else el.removeAttribute(attr);
|
|
31
29
|
}
|
|
32
30
|
|
|
33
31
|
export function tipText(el) {
|
|
34
|
-
|
|
35
|
-
return el.getAttribute("data-tip") || (el.hasAttribute("data-tip-overflow") ? el.textContent : "") || "";
|
|
32
|
+
return el.hasAttribute("data-tip-keep") ? el.getAttribute("data-tip") || "" : "";
|
|
36
33
|
}
|
|
37
34
|
|
|
38
35
|
export function addId(list, id) {
|
package/web/update.d.ts
CHANGED
|
@@ -42,4 +42,4 @@ export function questionGate(counters: { readonly pending: number; readonly need
|
|
|
42
42
|
export function canUpdate(status: unknown): boolean;
|
|
43
43
|
export function updateStep(status: number, body: unknown): UpdateStep;
|
|
44
44
|
export function resultLine(result: unknown): string | null;
|
|
45
|
-
export function progressLine(data: unknown): string | null;
|
|
45
|
+
export function progressLine(data: unknown, loadedVersion?: string | null): string | null;
|
package/web/update.js
CHANGED
|
@@ -89,10 +89,17 @@ export function resultLine(result) {
|
|
|
89
89
|
return `${typeof result.message === "string" ? result.message : "업데이트에 실패했습니다."}${typeof result.logPath === "string" && result.logPath !== "" ? ` (로그: ${result.logPath})` : ""}`;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
92
|
+
/**
|
|
93
|
+
* The line for an `update.progress` payload or a GET /api/update/state body; null shows nothing. The gateway keeps the last
|
|
94
|
+
* result in state.json for good, so `loadedVersion` (the version the page first loaded on) hides an ok result that ended
|
|
95
|
+
* at that version: the page was reloaded after the update and the "reload the page" line is stale.
|
|
96
|
+
*/
|
|
97
|
+
export function progressLine(data, loadedVersion = null) {
|
|
94
98
|
if (data === null || typeof data !== "object") return null;
|
|
95
|
-
if (data.result !== undefined && data.result !== null)
|
|
99
|
+
if (data.result !== undefined && data.result !== null) {
|
|
100
|
+
if (data.result.ok === true && loadedVersion !== null && data.result.to === loadedVersion) return null;
|
|
101
|
+
return resultLine(data.result);
|
|
102
|
+
}
|
|
96
103
|
if (typeof data.label !== "string" || data.phase === "done") return null;
|
|
97
104
|
return `업데이트 중 (${data.from} → ${data.to}): ${data.label}`;
|
|
98
105
|
}
|