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/setup.ts CHANGED
@@ -2,16 +2,16 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
- import { engine } from "../engine/index.ts";
5
+ import { engine, ENGINE_PACKAGE } from "../engine/index.ts";
6
6
  import { installEngine } from "../engine/install.ts";
7
7
  import { ensureInstallRules, INSTALL_RULES_FILE } from "../agent-rules.ts";
8
- import { DECLINED_MESSAGE, ensureConsent, EXIT_DECLINED, loadNotice, type PermissionNotice } from "../consent/consent.ts";
8
+ import { ensureConsent, EXIT_DECLINED, loadNotice, type PermissionNotice } from "../consent/consent.ts";
9
9
  import { type InstallKind, installKind } from "../runtime/install-kind.ts";
10
10
  import { agentDir as defaultAgentDir, providerCount } from "../model-accounts.ts";
11
11
  import type { Env, RuntimePaths } from "../runtime/paths.ts";
12
12
  import { writeVoiceSetting } from "../voice-setting.ts";
13
13
  import { parseConfigEnv, startCommand, systemContext, systemDeps } from "./launchd.ts";
14
- import { apkFailureLine, apkQr, enrollmentQr, pairFailureLine } from "./pair.ts";
14
+ import { isLanguage, LANGUAGE_QUESTION, type Language, localize, message, resolveLanguage, saveLanguage } from "./messages.ts";
15
15
  import { SYSTEM_NETWORK, type Network } from "../enroll.ts";
16
16
  import {
17
17
  ACCESS_CHOICES, type AccessChoice, type AccessRecord, appPortEnv, isAccessChoice, parseDomain, pingAccess, type PingResult, proxyGuide, readAccess,
@@ -38,21 +38,24 @@ export interface SetupOptions {
38
38
  readonly domain?: string;
39
39
  readonly notionTokenFile?: string;
40
40
  readonly agentDir?: string;
41
+ readonly lang?: Language;
41
42
  }
42
43
 
43
44
  export interface Platform { readonly platform: string; readonly arch: string; readonly bunVersion: string }
44
45
 
45
46
  export interface SetupDeps {
47
+ readonly lang?: Language;
46
48
  readonly platform: Platform;
47
49
  readonly env: Env;
48
50
  readonly paths: RuntimePaths;
49
51
  readonly bunPath: string;
50
52
  readonly installEngine: (options: { home: string; acceptLicense: boolean; engineOnly: boolean; confirmLicense?: (summary: string, path: string) => Promise<boolean> }) => Promise<string>;
53
+ readonly globalOmoVersion: () => Promise<string | null>;
54
+ readonly installGlobalOmo: (version: string) => Promise<void>;
55
+ /** The executable checked or installed above; injected installers may keep the plain omo command. */
56
+ readonly globalOmoCommand?: () => string;
57
+ readonly providerCount: (agent: string) => number;
51
58
  readonly start: () => Promise<number>;
52
- /** The phone enrollment QR text after the services are up (plan Todo 15); rejects when the gateway gives no token. */
53
- readonly enrollQr: () => Promise<string>;
54
- /** The app download QR shown before the enrollment QR (plan Todo 20); rejects when the gateway serves no APK. */
55
- readonly apkQr: () => Promise<string>;
56
59
  readonly prompt: (question: string) => Promise<string | null>;
57
60
  readonly network: Network;
58
61
  readonly tailscale: () => TailscaleRun;
@@ -67,10 +70,10 @@ export interface SetupDeps {
67
70
  readonly err: (text: string) => void;
68
71
  }
69
72
 
70
- const VALUE_FLAGS = { "--access": "access", "--domain": "domain", "--notion-token-file": "notionTokenFile", "--agent-dir": "agentDir" } as const;
73
+ const VALUE_FLAGS = { "--access": "access", "--domain": "domain", "--notion-token-file": "notionTokenFile", "--agent-dir": "agentDir", "--lang": "lang" } as const;
71
74
  const BOOLEAN_FLAGS = { "--non-interactive": "nonInteractive", "--accept-license": "acceptLicense", "--engine-only": "engineOnly" } as const;
72
75
 
73
- export function parseSetupArgs(argv: readonly string[]): SetupOptions | string {
76
+ export function parseSetupArgs(argv: readonly string[], lang: Language = "en"): SetupOptions | string {
74
77
  const options: Record<string, string | boolean> = { nonInteractive: false, acceptLicense: false, engineOnly: false };
75
78
  for (let i = 0; i < argv.length; i++) {
76
79
  const flag = argv[i]!;
@@ -78,18 +81,19 @@ export function parseSetupArgs(argv: readonly string[]): SetupOptions | string {
78
81
  options[BOOLEAN_FLAGS[flag as keyof typeof BOOLEAN_FLAGS]] = true;
79
82
  continue;
80
83
  }
81
- if (!(flag in VALUE_FLAGS)) return `알 수 없는 옵션: ${flag}`;
84
+ if (!(flag in VALUE_FLAGS)) return message(lang, "unknownOption", { flag });
82
85
  const value = argv[i + 1];
83
- if (value === undefined || value === "" || value.startsWith("--")) return `${flag} 에 값이 필요합니다`;
84
- if (/[\0\n\r]/.test(value)) return `${flag} 값에 줄바꿈이나 NUL 문자를 쓸 수 없습니다`;
86
+ if (value === undefined || value === "" || value.startsWith("--")) return message(lang, "missingValue", { flag });
87
+ if (/[\0\n\r]/.test(value)) return message(lang, "badValue", { flag });
85
88
  options[VALUE_FLAGS[flag as keyof typeof VALUE_FLAGS]] = value;
86
89
  i++;
87
90
  }
88
- if (options.access !== undefined && !(ACCESS_CHOICES as readonly unknown[]).includes(options.access)) return `--access 는 ${ACCESS_CHOICES.join(", ")} 중 하나입니다`;
89
- if (options.domain !== undefined && options.access !== "domain") return "--domain 은 --access domain 과 함께 씁니다";
90
- if (options.access === "domain" && options.domain === undefined) return "--access domain 에는 --domain <https://주소> 가 필요합니다";
91
+ if (options.lang !== undefined && !isLanguage(options.lang)) return message(lang, "badLanguage");
92
+ if (options.access !== undefined && !(ACCESS_CHOICES as readonly unknown[]).includes(options.access)) return message(lang, "badAccess", { choices: ACCESS_CHOICES.join(", ") });
93
+ if (options.domain !== undefined && options.access !== "domain") return message(lang, "domainTogether");
94
+ if (options.access === "domain" && options.domain === undefined) return message(lang, "domainRequired");
91
95
  if (typeof options.domain === "string") {
92
- const domain = parseDomain(options.domain);
96
+ const domain = parseDomain(options.domain, lang);
93
97
  if ("error" in domain) return domain.error;
94
98
  }
95
99
  return options as unknown as SetupOptions;
@@ -103,11 +107,11 @@ export function bunAtLeast(version: string, min: readonly [number, number] = MIN
103
107
  return major > min[0] || (major === min[0] && minor >= min[1]);
104
108
  }
105
109
 
106
- export function platformProblem(platform: Platform): string | null {
110
+ export function platformProblem(platform: Platform, lang: Language = "en"): string | null {
107
111
  if (platform.platform !== "darwin" || platform.arch !== "arm64") {
108
- return `zigmini는 Apple Silicon(arm64) 맥에서만 동작합니다. (이 기기: ${platform.platform} ${platform.arch})`;
112
+ return message(lang, "platform", { ...platform });
109
113
  }
110
- if (!bunAtLeast(platform.bunVersion)) return `zigmini에는 Bun ${MIN_BUN.join(".")} 이상이 필요합니다. (지금: ${platform.bunVersion}) \`bun upgrade\`로 올리세요.`;
114
+ if (!bunAtLeast(platform.bunVersion)) return message(lang, "bunVersion", { min: MIN_BUN.join("."), version: platform.bunVersion });
111
115
  return null;
112
116
  }
113
117
 
@@ -142,10 +146,61 @@ function quote(text: string): string {
142
146
  }
143
147
 
144
148
  /** Never throws: the engine's pinned launcher path (or the ZGW_OMO_BIN test candidate), whether or not it exists yet. */
145
- export function loginHint(agent: string, home: string, bunPath: string, env: Env): string {
149
+ export function loginHint(agent: string, home: string, bunPath: string, env: Env, lang: Language = "en"): string {
146
150
  const assignments = Object.entries(engine.env(agent)).map(([key, value]) => `${key}=${quote(value)}`).join(" ");
147
151
  const bin = env.ZGW_OMO_BIN || join(home, "omo", "current", "node_modules", ".bin", "omo");
148
- return `모델 계정에 로그인하세요: 터미널에서 \`env ${assignments} ${quote(bunPath)} ${quote(bin)}\`를 실행한 뒤 \`/login\`을 입력합니다.`;
152
+ return message(lang, "loginHint", { command: `env ${assignments} ${quote(bunPath)} ${quote(bin)}` });
153
+ }
154
+
155
+ /** Login uses stock OMO; the gateway continues to use its separate, patched engine. */
156
+ export function stockOmoVersion(version = engine.pinnedVersion()): string {
157
+ return version.replace(/-zgw(?:\.\d+)?$/, "");
158
+ }
159
+
160
+ async function loginStep(options: SetupOptions, deps: SetupDeps, agent: string, dedicated: boolean): Promise<void> {
161
+ const lang = deps.lang ?? "en";
162
+ const version = stockOmoVersion();
163
+ let global = true;
164
+ try {
165
+ const current = await deps.globalOmoVersion();
166
+ let install = current === null;
167
+ if (current !== null && current !== version) {
168
+ if (!options.nonInteractive) {
169
+ const answer = await deps.prompt(message(lang, "globalOverwrite", { current, version }));
170
+ install = answer?.trim().toLowerCase() === "y";
171
+ }
172
+ if (!install) deps.out(message(lang, "globalKeep", { current, version }));
173
+ }
174
+ if (install) await deps.installGlobalOmo(version);
175
+ if (current === version || install) deps.out(message(lang, "globalDone", { version }));
176
+ } catch (error) {
177
+ global = false;
178
+ deps.err(message(lang, "globalFailed", { error: error instanceof Error ? error.message : String(error) }));
179
+ }
180
+ let providers = deps.providerCount(agent);
181
+ if (providers >= 1) {
182
+ deps.out(message(lang, "providers", { count: providers }));
183
+ return;
184
+ }
185
+ const command = quote(deps.globalOmoCommand?.() ?? "omo");
186
+ const hint = global
187
+ ? message(lang, "loginHint", { command: dedicated ? `env OMO_CODING_AGENT_DIR=${quote(agent)} ${command}` : command })
188
+ : loginHint(agent, deps.paths.home, deps.bunPath, deps.env, lang);
189
+ deps.out(message(lang, "loginBlock", { hint }));
190
+ if (options.nonInteractive) return;
191
+ for (;;) {
192
+ const answer = await deps.prompt(message(lang, "loginWait"));
193
+ if (answer === null || answer.trim().toLowerCase() === "s") {
194
+ deps.out(message(lang, "loginWarning"));
195
+ return;
196
+ }
197
+ providers = deps.providerCount(agent);
198
+ if (providers >= 1) {
199
+ deps.out(message(lang, "providers", { count: providers }));
200
+ return;
201
+ }
202
+ deps.out(message(lang, "loginPending"));
203
+ }
149
204
  }
150
205
 
151
206
  function ensureDir(path: string, mode?: number): void {
@@ -159,116 +214,134 @@ function writeSecret(path: string, value: string): void {
159
214
  }
160
215
 
161
216
  async function engineStep(options: SetupOptions, deps: SetupDeps): Promise<number> {
217
+ const lang = deps.lang ?? "en";
162
218
  const confirmLicense = options.nonInteractive ? undefined : async (summary: string) => {
163
- deps.out(`${summary}\n`);
164
- const answer = await deps.prompt("엔진 라이선스에 동의하면 y를 입력하세요: ");
219
+ deps.out(`${localize(summary, lang)}\n`);
220
+ const answer = await deps.prompt(message(lang, "engineLicense"));
165
221
  return answer?.trim().toLowerCase() === "y";
166
222
  };
167
223
  try {
168
224
  const target = await deps.installEngine({ home: deps.paths.home, acceptLicense: options.acceptLicense, engineOnly: options.engineOnly, confirmLicense });
169
- deps.out(`엔진: ${target} 설치와 기본 동작 확인을 마쳤습니다.\n`);
225
+ deps.out(message(lang, "engineDone", { target }));
170
226
  return 0;
171
227
  } catch (error) {
172
- const message = error instanceof Error ? error.message : String(error);
173
- deps.err(message === "engine_license_declined"
174
- ? "엔진 라이선스에 동의하지 않아 설치를 멈췄습니다. 비대화 설치는 --accept-license 가 필요합니다.\n"
175
- : `엔진 설치 실패: ${message}\n`);
228
+ const detail = localize(error instanceof Error ? error.message : String(error), lang);
229
+ deps.err(detail === "engine_license_declined"
230
+ ? message(lang, "engineDeclined")
231
+ : message(lang, "engineFailed", { error: detail }));
176
232
  return EXIT_FAILED;
177
233
  }
178
234
  }
179
235
 
180
236
  async function notionStep(options: SetupOptions, deps: SetupDeps): Promise<number> {
237
+ const lang = deps.lang ?? "en";
181
238
  const file = deps.paths.notionTokenFile;
182
239
  let token: string | null = null;
183
240
  if (options.notionTokenFile !== undefined) {
184
241
  try {
185
242
  token = readFileSync(options.notionTokenFile, "utf8").trim();
186
243
  } catch {
187
- deps.err(`Notion 토큰 파일을 읽지 못했습니다: ${options.notionTokenFile}\n`);
244
+ deps.err(message(lang, "notionFile", { path: options.notionTokenFile }));
188
245
  return EXIT_FAILED;
189
246
  }
190
247
  } else if (!options.nonInteractive && !existsSync(file)) {
191
- token = (await deps.prompt("Notion 토큰(비우면 일정 기능 꺼짐): "))?.trim() ?? "";
248
+ token = (await deps.prompt(message(lang, "notionPrompt")))?.trim() ?? "";
192
249
  }
193
250
  if (token) {
194
251
  ensureDir(dirname(file), 0o700);
195
252
  writeSecret(file, token);
196
253
  }
197
- deps.out(`Notion 일정: ${existsSync(file) ? "켜짐(토큰 있음)" : "꺼짐(토큰 없음)"}\n`);
254
+ deps.out(message(lang, "notionState", { state: message(lang, existsSync(file) ? "tokenOn" : "tokenOff") }));
198
255
  return 0;
199
256
  }
200
257
 
201
258
  /** The "외부 접속" step: a new choice is resolved and saved; without one, the saved choice is kept (or asked for interactively). */
202
259
  async function accessStep(options: SetupOptions, deps: SetupDeps): Promise<AccessRecord | null | number> {
260
+ const lang = deps.lang ?? "en";
203
261
  let choice = options.access;
204
262
  let domain = options.domain;
205
263
  if (choice === undefined) {
206
264
  const saved = readAccess(deps.paths.home);
207
265
  if (saved !== null && saved !== "invalid") {
208
- deps.out(`외부 접속: ${saved.choice} ${saved.url} (바꾸려면 --access)\n`);
266
+ deps.out(message(lang, "accessSaved", { ...saved }));
209
267
  return saved;
210
268
  }
211
269
  if (options.nonInteractive) return null;
212
- const answer = (await deps.prompt(`외부 접속을 고르세요 [${ACCESS_CHOICES.join("/")}, 비우면 맥 안에서만]: `))?.trim() ?? "";
270
+ const answer = (await deps.prompt(message(lang, "accessPrompt", { choices: ACCESS_CHOICES.join("/") })))?.trim() ?? "";
213
271
  if (answer === "") {
214
- deps.out("외부 접속: 고르지 않음(웹은 맥 안에서만 엽니다. 폰은 나중에 `zigmini setup --access`로 엽니다)\n");
272
+ deps.out(message(lang, "accessNone"));
215
273
  return null;
216
274
  }
217
275
  if (!isAccessChoice(answer)) {
218
- deps.err(`외부 접속은 ${ACCESS_CHOICES.join(", ")} 중 하나입니다: ${answer}\n`);
276
+ deps.err(message(lang, "accessInvalid", { choices: ACCESS_CHOICES.join(", "), answer }));
219
277
  return EXIT_USAGE;
220
278
  }
221
279
  choice = answer;
222
- if (choice === "domain") domain = (await deps.prompt("자기 도메인 주소(https://...): "))?.trim() ?? "";
280
+ if (choice === "domain") domain = (await deps.prompt(message(lang, "domainPrompt")))?.trim() ?? "";
223
281
  }
224
- const resolved = resolveAccess(choice, domain, deps.paths.ports.app, deps);
282
+ const resolved = resolveAccess(choice, domain, deps.paths.ports.app, deps, lang);
225
283
  if (!resolved.ok) {
226
- deps.err(`외부 접속을 정하지 못했습니다.\n${resolved.message}\n`);
284
+ deps.err(message(lang, "accessFailed", { error: resolved.message }));
227
285
  return EXIT_FAILED;
228
286
  }
229
287
  writeAccess(deps.paths.home, resolved.record);
230
- deps.out(`외부 접속: ${resolved.record.choice} ${resolved.record.url} (앱 포트 ${deps.paths.ports.app}, 폰 등록 QR이 이 주소를 씁니다)\n`);
288
+ deps.out(message(lang, "accessDone", { ...resolved.record, port: deps.paths.ports.app }));
231
289
  return resolved.record;
232
290
  }
233
291
 
234
292
  /** A domain is checked only once the services are up; a failure keeps the install and points at the proxy and doctor. */
235
293
  async function domainCheckStep(access: AccessRecord | null, deps: SetupDeps): Promise<void> {
294
+ const lang = deps.lang ?? "en";
236
295
  if (access?.choice !== "domain") return;
237
296
  const result = await deps.ping(access.url);
238
297
  if (result.ok) {
239
- deps.out(`외부 접속 확인: ${access.url}/api/enroll/ping 응답 정상\n`);
298
+ deps.out(message(lang, "domainOk", { url: access.url }));
240
299
  return;
241
300
  }
242
- deps.err(`경고: ${access.url} 에서 게이트웨이에 닿지 못했습니다(${result.detail}). 설치는 그대로 두었습니다.\n${proxyGuide(access.url, deps.paths.ports.app)}\n`);
301
+ deps.err(message(lang, "domainWarning", { url: access.url, detail: result.detail, guide: proxyGuide(access.url, deps.paths.ports.app, lang) }));
243
302
  }
244
303
 
245
304
  export async function runSetup(options: SetupOptions, deps: SetupDeps): Promise<number> {
246
- const problem = platformProblem(deps.platform);
305
+ let lang = options.lang ?? resolveLanguage(deps.paths.home, deps.env);
306
+ if (!options.nonInteractive && options.lang === undefined) {
307
+ for (;;) {
308
+ const answer = await deps.prompt(LANGUAGE_QUESTION);
309
+ if (answer === null || answer.trim() === "") break;
310
+ if (answer.trim() === "1" || answer.trim() === "en") { lang = "en"; break; }
311
+ if (answer.trim() === "2" || answer.trim() === "ko") { lang = "ko"; break; }
312
+ deps.err(message(lang, "chooseLanguage"));
313
+ }
314
+ }
315
+ deps = { ...deps, lang };
316
+ const problem = platformProblem(deps.platform, lang);
247
317
  if (problem !== null) {
248
318
  deps.err(`${problem}\n`);
249
319
  return EXIT_USAGE;
250
320
  }
251
321
  const { paths } = deps;
252
322
  const consent = { home: paths.home, kind: deps.installKind, notice: deps.notice, zigminiVersion: deps.zigminiVersion, now: deps.now };
253
- if (!await ensureConsent(consent, deps)) {
254
- deps.err(`${DECLINED_MESSAGE}\n`);
323
+ if (!await ensureConsent(consent, { ...deps, out: (text) => deps.out(localize(text, lang)), prompt: (question) => deps.prompt(localize(question, lang)) })) {
324
+ deps.err(`${message(lang, "consentDeclined")}\n`);
255
325
  return EXIT_DECLINED;
256
326
  }
257
327
  if (options.engineOnly) {
258
328
  ensureDir(paths.omo);
259
- return await engineStep(options, deps);
329
+ const code = await engineStep(options, deps);
330
+ if (code === 0) saveLanguage(paths.home, lang);
331
+ return code;
260
332
  }
261
333
 
262
334
  for (const dir of [paths.home, paths.data, paths.logs, paths.omo, paths.sessions]) ensureDir(dir);
263
335
  for (const dir of [paths.secrets, paths.run]) ensureDir(dir, 0o700);
264
- deps.out(`홈 폴더: ${paths.home}\n`);
265
- if (ensureInstallRules(paths.home)) deps.out(`이 설치의 규칙 파일: ${join(paths.home, INSTALL_RULES_FILE)} (비어 있음)\n`);
336
+ deps.out(message(lang, "home", { path: paths.home }));
337
+ if (ensureInstallRules(paths.home)) deps.out(message(lang, "rules", { path: join(paths.home, INSTALL_RULES_FILE) }));
266
338
 
267
339
  if (!existsSync(paths.omoTokenFile)) writeSecret(paths.omoTokenFile, deps.randomToken());
268
340
  chmodSync(paths.omoTokenFile, 0o600);
269
341
 
270
342
  const engineCode = await engineStep(options, deps);
271
343
  if (engineCode !== 0) return engineCode;
344
+ saveLanguage(paths.home, lang);
272
345
 
273
346
  const configFile = join(paths.home, "config.env");
274
347
  const text = existsSync(configFile) ? readFileSync(configFile, "utf8") : "";
@@ -276,16 +349,15 @@ export async function runSetup(options: SetupOptions, deps: SetupDeps): Promise<
276
349
  try {
277
350
  config = parseConfigEnv(text);
278
351
  } catch (error) {
279
- deps.err(`${(error as Error).message}\n`);
352
+ deps.err(`${localize((error as Error).message, lang)}\n`);
280
353
  return EXIT_FAILED;
281
354
  }
282
355
 
283
356
  const dedicated = options.agentDir ?? config.ZGW_OMO_AGENT_DIR;
284
357
  const agent = dedicated ? resolve(dedicated) : defaultAgentDir(deps.env);
285
358
  if (options.agentDir !== undefined) ensureDir(agent, 0o700);
286
- deps.out(`에이전트 폴더: ${agent}${dedicated ? " (전용)" : " (공유)"}\n`);
287
- const providers = providerCount(agent);
288
- deps.out(providers > 0 ? `모델 계정: 로그인됨(제공자 ${providers}개)\n` : `${loginHint(agent, paths.home, deps.bunPath, deps.env)}\n`);
359
+ deps.out(message(lang, "agent", { path: agent, kind: message(lang, dedicated ? "dedicated" : "shared") }));
360
+ await loginStep(options, deps, agent, Boolean(dedicated));
289
361
 
290
362
  const notionCode = await notionStep(options, deps);
291
363
  if (notionCode !== 0) return notionCode;
@@ -302,34 +374,26 @@ export async function runSetup(options: SetupOptions, deps: SetupDeps): Promise<
302
374
  chmodSync(configFile, 0o600);
303
375
 
304
376
  if (!existsSync(paths.voiceSettingFile)) writeVoiceSetting(paths.voiceSettingFile, false);
305
- deps.out(`음성: ${readFileSync(paths.voiceSettingFile, "utf8").trim() === "off" ? "꺼짐" : "켜짐"} (웹·앱 설정에서 바꿉니다)\n`);
377
+ deps.out(message(lang, "voice", { state: message(lang, readFileSync(paths.voiceSettingFile, "utf8").trim() === "off" ? "off" : "on") }));
306
378
 
307
379
  const started = await deps.start();
308
380
  if (started !== 0) {
309
- deps.err(`서비스 시작에 실패했습니다. \`zigmini status\`와 로그 폴더 ${paths.logs}를 확인하세요.\n`);
381
+ deps.err(message(lang, "startFailed", { path: paths.logs }));
310
382
  return started;
311
383
  }
312
- deps.out(`설치를 마쳤습니다.\n 웹: http://127.0.0.1:${paths.ports.web}\n 상태 확인: zigmini status\n 멈추기: zigmini stop\n`);
384
+ deps.out(message(lang, "setupDone", { port: paths.ports.web }));
313
385
  await domainCheckStep(access, deps);
314
- await enrollStep(deps);
386
+ phoneStep(access, deps);
315
387
  return 0;
316
388
  }
317
389
 
318
390
  /**
319
- * The last step: the app download QR, then the phone enrollment QR. The services are already up, so a failure here is a
320
- * hint, not a failed setup.
391
+ * Terminal font metrics can break QR scanning. Direct the user to the real web settings sections instead.
321
392
  */
322
- async function enrollStep(deps: SetupDeps): Promise<void> {
323
- try {
324
- deps.out(`\n${await deps.apkQr()}`);
325
- } catch (error) {
326
- deps.out(`${apkFailureLine(error)} 게이트웨이가 준비되면 \`zigmini pair --apk\`로 다시 만듭니다.\n`);
327
- }
328
- try {
329
- deps.out(`\n${await deps.enrollQr()}`);
330
- } catch (error) {
331
- deps.out(`${pairFailureLine(error)} 게이트웨이가 준비되면 \`zigmini pair\`로 다시 만듭니다.\n`);
332
- }
393
+ function phoneStep(access: AccessRecord | null, deps: SetupDeps): void {
394
+ const lang = deps.lang ?? "en";
395
+ if (access === null) deps.out(`${message(lang, "setupAccessGuidance")}\n`);
396
+ deps.out(message(lang, "webQrGuide", { port: deps.paths.ports.web }));
333
397
  }
334
398
 
335
399
  async function* stdinLines(): AsyncGenerator<string> {
@@ -356,19 +420,40 @@ export function stdinPrompt(): (question: string) => Promise<string | null> {
356
420
 
357
421
  export function systemSetupDeps(zigminiVersion: string, env: Env = process.env): SetupDeps {
358
422
  const ctx = systemContext(env);
423
+ let globalBin: string | null = null;
359
424
  return {
360
425
  platform: { platform: process.platform, arch: process.arch, bunVersion: Bun.version },
361
426
  env,
362
427
  paths: ctx.paths,
363
428
  bunPath: ctx.bunPath,
364
429
  installEngine: (options) => installEngine(options),
365
- start: () => startCommand(systemDeps(), ctx),
366
- enrollQr: () => enrollmentQr((url, init) => fetch(url, init), ctx.paths.ports.web, Date.now),
367
- apkQr: () => apkQr((url, init) => fetch(url, init), ctx.paths.ports.web, Date.now),
430
+ globalOmoVersion: async () => {
431
+ const bin = env.BUN_INSTALL ? join(env.BUN_INSTALL, "bin/omo") : Bun.which("omo", { PATH: env.PATH });
432
+ if (!bin || !existsSync(bin)) return null;
433
+ const proc = Bun.spawn([ctx.bunPath, bin, "--version"], { env: { ...env }, stdout: "pipe", stderr: "pipe", signal: AbortSignal.timeout(10_000) });
434
+ const [code, stdout, stderr] = await Promise.all([proc.exited, new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
435
+ const version = /^omo\s+(\S+)/.exec(stdout.trim())?.[1];
436
+ if (code !== 0 || version === undefined) throw new Error(stderr.trim() || "global_omo_version_unknown");
437
+ globalBin = bin;
438
+ return version;
439
+ },
440
+ installGlobalOmo: async (version) => {
441
+ const proc = Bun.spawn([ctx.bunPath, "add", "-g", `${ENGINE_PACKAGE}@${version}`], { env: { ...env }, stdout: "pipe", stderr: "pipe", signal: AbortSignal.timeout(180_000) });
442
+ const [code, stdout, stderr] = await Promise.all([proc.exited, new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
443
+ if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `bun_add_exit_${code}`);
444
+ globalBin = join(env.BUN_INSTALL || join(env.HOME || homedir(), ".bun"), "bin/omo");
445
+ },
446
+ globalOmoCommand: () => globalBin === null || Bun.which("omo", { PATH: env.PATH }) === globalBin ? "omo" : globalBin,
447
+ providerCount,
448
+ start: () => {
449
+ const deps = systemDeps();
450
+ const lang = resolveLanguage(ctx.paths.home, env);
451
+ return startCommand({ ...deps, out: (text) => deps.out(localize(text, lang)), err: (text) => deps.err(localize(text, lang)) }, ctx);
452
+ },
368
453
  prompt: stdinPrompt(),
369
454
  network: SYSTEM_NETWORK,
370
455
  tailscale: systemTailscale,
371
- ping: (url) => pingAccess((target, init) => fetch(target, init), url),
456
+ ping: (url) => pingAccess((target, init) => fetch(target, init), url, undefined, resolveLanguage(ctx.paths.home, env)),
372
457
  isDir: (path) => {
373
458
  try {
374
459
  return statSync(path).isDirectory();
@@ -387,10 +472,14 @@ export function systemSetupDeps(zigminiVersion: string, env: Env = process.env):
387
472
  }
388
473
 
389
474
  export async function setupCommand(argv: readonly string[], deps: () => SetupDeps, usage: string): Promise<number> {
390
- const options = parseSetupArgs(argv);
475
+ const runtime = deps();
476
+ const at = argv.indexOf("--lang");
477
+ const requested = at < 0 ? undefined : argv[at + 1];
478
+ const lang = isLanguage(requested) ? requested : resolveLanguage(runtime.paths.home, runtime.env);
479
+ const options = parseSetupArgs(argv, lang);
391
480
  if (typeof options === "string") {
392
- process.stderr.write(`${options}\n\n${usage}`);
481
+ process.stderr.write(`${options}\n\n${localize(usage, lang)}`);
393
482
  return EXIT_USAGE;
394
483
  }
395
- return await runSetup(options, deps());
484
+ return await runSetup(options, runtime);
396
485
  }
package/src/decisions.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import { decisionContext } from "./decision-context.ts";
3
+ import { SecurityCheckError, type MessageGuard } from "./security-check.ts";
3
4
  import type { OmoEvent } from "./omo/client.ts";
4
5
  import type { ActionAnswerData, DecisionAnswerRequest, DecisionInfo, DecisionsPageData, DecisionUpdatedData } from "./protocol.ts";
5
6
  import { blockedMainQuestion, blockedQuestion, DecisionStoreError, RESUMABLE_CLOSED, Store, type Decision, type DecisionMethod, type DecisionSubmission, type StoredEvent } from "./store.ts";
@@ -35,6 +36,15 @@ export interface DecisionServiceOptions {
35
36
  readonly receiptTimeoutMs?: number;
36
37
  readonly onHistory?: (event: StoredEvent) => void;
37
38
  readonly onError?: (error: unknown) => void;
39
+ /** Improvement 165: the security check of answer text (value, comment, free text of each question). */
40
+ readonly guard?: Pick<MessageGuard, "holdOrPass">;
41
+ }
42
+
43
+ function answerTexts(request: DecisionAnswerRequest): string[] {
44
+ if (!("response" in request)) return [];
45
+ const response = request.response as { value?: unknown; comment?: unknown; answers?: Record<string, { text?: unknown }> };
46
+ const texts = [response.value, response.comment, ...Object.values(response.answers ?? {}).map((answer) => answer.text)];
47
+ return texts.filter((text): text is string => typeof text === "string");
38
48
  }
39
49
 
40
50
  function record(value: unknown): value is Record<string, unknown> {
@@ -189,8 +199,14 @@ export class DecisionService {
189
199
  return this.info(this.required(id));
190
200
  }
191
201
 
192
- answer(id: string, input: unknown): DecisionInfo {
202
+ answer(id: string, input: unknown, checked = false): DecisionInfo {
193
203
  const request = parseDecisionAnswer(input);
204
+ if (!checked) {
205
+ const owner = this.store.getDecision(id)?.ownerConv ?? "main";
206
+ this.options.guard?.holdOrPass(owner, "answer", answerTexts(request), async () => {
207
+ this.answer(id, input, true);
208
+ });
209
+ }
194
210
  this.refresh();
195
211
  const before = this.required(id);
196
212
  const binding = this.ports.liveBinding(before);
@@ -630,6 +646,9 @@ export class DecisionService {
630
646
  }
631
647
  return Response.json({ error: "method_not_allowed" }, { status: 405 });
632
648
  } catch (error) {
649
+ if (error instanceof SecurityCheckError) {
650
+ return Response.json({ error: error.code, message: error.message, held: error.held }, { status: 422 });
651
+ }
633
652
  if (error instanceof DecisionStoreError) {
634
653
  return Response.json({
635
654
  error: error.code, decision: error.decision ? this.info(error.decision) : null,