zigmini 0.8.0-beta.2 → 0.8.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hub.ts CHANGED
@@ -186,6 +186,7 @@ interface QuestionItem {
186
186
  export interface ScopedQuestionFrame {
187
187
  id: string;
188
188
  toolCallId?: string;
189
+ waitForAnswer?: boolean;
189
190
  questions: QuestionItem[];
190
191
  askedAtMs?: number;
191
192
  deadlineAtMs?: number;
@@ -1126,7 +1127,8 @@ export class Hub {
1126
1127
  ) {
1127
1128
  throw new Error("turn_running");
1128
1129
  }
1129
- if (forced) this.retireScopedQuestions(convId, sessionId);
1130
+ // Any rotation hands main's open waits of the closed session to the next one (inbox resume); bug 72.
1131
+ this.retireScopedQuestions(convId, sessionId);
1130
1132
  try {
1131
1133
  await this.requireOmo().command("close_session", { sessionId });
1132
1134
  const listed = await this.requireOmo().command<{
@@ -1227,9 +1229,9 @@ export class Hub {
1227
1229
  return owned;
1228
1230
  }
1229
1231
 
1230
- /** What holds automatic rotation: the rotation blockers plus an unconfirmed session close. */
1232
+ /** What holds automatic rotation: the age rotation holds plus an unconfirmed session close. */
1231
1233
  private rotationHolds(conv: string): RotationBlocker[] {
1232
- const holds = this.rotationBlockers(conv);
1234
+ const holds = this.rotationHoldsFor(conv, false);
1233
1235
  return this.closeUncertain(conv) ? [{ kind: "session_close_unknown" }, ...holds] : holds;
1234
1236
  }
1235
1237
 
@@ -1294,9 +1296,18 @@ export class Hub {
1294
1296
  }
1295
1297
  }
1296
1298
 
1297
- /** What holds a rotation now: every rotation blocker, or for a forced rotation only an unfinished main turn. */
1298
1299
  private rotationHeldFor(conv: string, forced: boolean): boolean {
1299
- return forced ? this.rotationBlockers(conv, false).some((blocker) => blocker.kind === "main_turn") : this.rotationBlocked(conv);
1300
+ return this.rotationHoldsFor(conv, forced).length > 0;
1301
+ }
1302
+
1303
+ /**
1304
+ * Bugs 72 and 86: what holds a rotation now. A main turn always holds; an age or manual rotation also waits for an open
1305
+ * approval dialog or native question. Works, their unreported reports and inbox decisions belong to the conversation,
1306
+ * not the session file, so they carry over to the next session (handoff, ZGW_WORK_STATE, work update wake) and never hold.
1307
+ */
1308
+ private rotationHoldsFor(conv: string, forced: boolean): RotationBlocker[] {
1309
+ return this.rotationBlockers(conv, false).filter((blocker) =>
1310
+ blocker.kind === "main_turn" || (!forced && (blocker.kind === "dialog" || blocker.kind === "question")));
1300
1311
  }
1301
1312
 
1302
1313
  /** Bug 72 proposal 4: the user-confirmed limit this conversation's session reached, if any. */
@@ -1334,10 +1345,10 @@ export class Hub {
1334
1345
  }
1335
1346
 
1336
1347
  /** Bug 72 proposal 4: what stays open across the rotation, so the next session owns it. */
1337
- private handoffOpenItems(conv: string, reason: RotationReason): string[] {
1348
+ private handoffOpenItems(conv: string): string[] {
1338
1349
  const open = this.ownedOpenDecisions(conv);
1339
1350
  return [
1340
- reason === "size" || reason === "compactions"
1351
+ this.workSummary(conv).length > 0
1341
1352
  ? "끝나지 않은 작업은 그대로 이어집니다. 목록은 ZGW_WORK_STATE와 zgw_work_list로 보고, 보고하지 않은 결과는 작업 알림으로 다시 받습니다."
1342
1353
  : "",
1343
1354
  ...(open.length > 0
@@ -1347,7 +1358,7 @@ export class Hub {
1347
1358
  }
1348
1359
 
1349
1360
  private rotationEligible(conv: ConversationInfo): boolean {
1350
- if (!conv.threadId?.startsWith("/") || this.closeUncertain(conv.id) || this.rotationBlocked(conv.id)) return false;
1361
+ if (!conv.threadId?.startsWith("/") || this.closeUncertain(conv.id) || this.rotationHeldFor(conv.id, false)) return false;
1351
1362
  const started = Date.parse(this.store.getSessionMeta(conv.id).startedAt ?? conv.createdAt);
1352
1363
  const used = this.store.lastSeq(conv.id, "message.user") > this.store.lastSeq(conv.id, "session.rotated");
1353
1364
  if (!used) return false;
@@ -1387,7 +1398,7 @@ export class Hub {
1387
1398
  return [
1388
1399
  `[게이트웨이 알림] 이 대화는 새 omo 세션으로 교체됐습니다 (${this.reasonLabel(reason)}).`,
1389
1400
  from ? `이전 세션의 전체 기록은 ${from} 에 남아 있습니다. 파일이 크니 필요할 때 필요한 부분만 검색해서 보세요.` : "",
1390
- ...this.handoffOpenItems(conv, reason),
1401
+ ...this.handoffOpenItems(conv),
1391
1402
  "아래는 직전 대화의 최근 부분입니다. 맥락으로만 참고하세요.",
1392
1403
  "---",
1393
1404
  ...lines,
@@ -1786,7 +1797,8 @@ export class Hub {
1786
1797
  const decision = this.decisions?.observe(owner, {
1787
1798
  actionId, sourceRequestId: frame.id, method: "question", permission: false,
1788
1799
  payload: { title: card.title, detail: card.detail, options: [], questions: frame.questions,
1789
- ...(frame.toolCallId ? { toolCallId: frame.toolCallId } : {}) },
1800
+ ...(frame.toolCallId ? { toolCallId: frame.toolCallId } : {}),
1801
+ ...(frame.waitForAnswer === false ? { waitForAnswer: false } : {}) },
1790
1802
  deadlineAt: frame.deadlineAtMs ?? null,
1791
1803
  });
1792
1804
  const open = this.openQuestionCards(owner.conv, owner.scope);
package/src/main.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  SYSTEM_NETWORK,
30
30
  } from "./enroll.ts";
31
31
  import { ACCESS_PATH, handleAccessApi, pingAccess, systemTailscale } from "./cli/access.ts";
32
+ import { CHAT_TOPIC, showsThinking, THINKING_TOPIC, topicOf } from "./client-topics.ts";
32
33
  import { APK_DIR, APK_PATH, APP_DOWNLOAD_LINK_PATH, APP_VERSION_PATH, ApkSource, appVersion, createDownloadLink, DownloadLinks, serveApk } from "./app-download.ts";
33
34
  import { loadConfig } from "./config.ts";
34
35
  import { memoUpdatedData, parseMemoPatch } from "./memo-images.ts";
@@ -38,7 +39,7 @@ import { Hub, type ScopedQuestionFrame } from "./hub.ts";
38
39
  import { parseSummaryModels, SpeechSummarizer } from "./speech-summary.ts";
39
40
  import { MEDIA_MAX_BYTES, saveUpload, serveMedia } from "./media.ts";
40
41
  import { OmoClient } from "./omo/client.ts";
41
- import { bridgeLiveness, GenerationRouter, type GenerationEndpoint } from "./omo/generations.ts";
42
+ import { bridgeLiveness, GenerationRouter, startupGenerations } from "./omo/generations.ts";
42
43
  import { activeRecord, readRegistry, slotUrl } from "./runtime/slots.ts";
43
44
  import { NATIVE_LIFECYCLE_PREFIX } from "./omo/lifecycle.ts";
44
45
  import { RECOVERY_RECEIPT_PREFIX } from "./omo/recovery-receipt.ts";
@@ -65,6 +66,7 @@ import { acceptUpdateConsent, requestUpdate, type UpdateApiDeps } from "./update
65
66
  import { PHASE_LABELS, readState, UPDATE_CONSENT_PATH, UPDATE_PATH, UPDATE_STATE_PATH, updateDir, UpdateWatcher } from "./update/state.ts";
66
67
  import { resolveTarget, tarEntry } from "./update/target.ts";
67
68
  import { registryUrl, UPDATE_CHECK_PATH, UPDATE_STATUS_PATH, UpdateChecker, versionAvailableEnvelope } from "./update/check.ts";
69
+ import { MANUAL_PATH, serveManual } from "./manual.ts";
68
70
  import { clean as gcClean, GC_CLEAN_PATH, GC_PATH, type GcRoots, parseSelection as parseGcSelection, report as gcReport, systemGcDeps } from "./gc.ts";
69
71
 
70
72
  // Exit 0 so launchd (KeepAlive SuccessfulExit=false) does not restart a gateway that has no consent.
@@ -142,7 +144,6 @@ const updateApi: UpdateApiDeps = {
142
144
  now: () => new Date(),
143
145
  };
144
146
 
145
- const TOPIC = "clients";
146
147
  let publish: (envelope: Envelope) => void = () => {};
147
148
  const hub = new Hub(
148
149
  store,
@@ -189,14 +190,11 @@ function requesterId(slot: RuntimeSlot): string {
189
190
  store.setSetting(key, id);
190
191
  return id;
191
192
  }
192
- // Improvement 77: with run/runtime-slots.json the gateway connects to every listed generation; without it (or with an
193
- // explicit ZGW_OMO_URL, as in Docker and the tests) it has one generation, as before.
194
- const slotRegistry = process.env.ZGW_OMO_URL ? null : readRegistry(runtime.slotsFile);
195
- const endpoints: GenerationEndpoint[] = slotRegistry === null
196
- ? [{ slot: "a", url: config.omoUrl }]
197
- : slotRegistry.slots.map((record) => ({ slot: record.slot, url: slotUrl(record) }));
193
+ // Improvement 77: with run/runtime-slots.json the gateway connects to every listed generation; without it (or with a
194
+ // ZGW_OMO_URL that names another OMO, as in Docker and the tests) it has one generation, as before (bug 134).
195
+ const startup = startupGenerations(process.env.ZGW_OMO_URL, `ws://127.0.0.1:${runtime.ports.bridge}`, () => readRegistry(runtime.slotsFile));
198
196
  let connectionGeneration = 0;
199
- const omo = new GenerationRouter(endpoints, slotRegistry === null ? "a" : activeRecord(slotRegistry).slot, {
197
+ const omo = new GenerationRouter(startup.endpoints, startup.active, {
200
198
  connect: (endpoint, handlers) => new OmoClient(endpoint.url, omoToken, handlers, requesterId(endpoint.slot)),
201
199
  liveness: (endpoint) => bridgeLiveness(endpoint.url, omoToken),
202
200
  log: (line) => console.log(line),
@@ -397,6 +395,7 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
397
395
  // question cannot race into a phone notification between those adjacent frames.
398
396
  questionNotifications.setWebPresence(ws, true);
399
397
  }
398
+ if (!showsThinking(msg.data?.client)) ws.unsubscribe(THINKING_TOPIC);
400
399
  const state = hub.snapshot();
401
400
  const snapshot = { ...state, approvals: omoCheckNotifications.filterApprovals(state.approvals) };
402
401
  // While omo is reconnecting, Hub intentionally clears its in-memory question cards until
@@ -628,6 +627,7 @@ async function handle(req: Request, srv: Server<Conn>, trusted: boolean): Promis
628
627
  const response = await handleProfileIcon(req, url, trusted, { icon: profileIcon, profile: () => readProfile(store), publish: (e) => publish(e) });
629
628
  if (response) return response;
630
629
  }
630
+ if (url.pathname === MANUAL_PATH) return serveManual(req, url);
631
631
  // Plan Todo 23: the update button. The app port may ask too, but never for an update that needs a new consent.
632
632
  if (url.pathname === UPDATE_PATH && req.method === "POST") {
633
633
  const body = await req.json().catch(() => null) as { origin?: unknown } | null;
@@ -806,7 +806,10 @@ async function routeApi(req: Request, url: URL): Promise<Response> {
806
806
 
807
807
  const websocket: WebSocketHandler<Conn> = {
808
808
  open(ws) {
809
- if (!ws.data.notifyOnly) ws.subscribe(TOPIC);
809
+ if (!ws.data.notifyOnly) {
810
+ ws.subscribe(CHAT_TOPIC);
811
+ ws.subscribe(THINKING_TOPIC);
812
+ }
810
813
  if (ws.data.trusted && !ws.data.notifyOnly) ws.subscribe(TRUSTED_TOPIC);
811
814
  const id = ws.data.deviceId;
812
815
  if (id) {
@@ -837,8 +840,9 @@ publish = (e) => {
837
840
  if (!omoCheckNotifications.allow(e)) return;
838
841
  questionNotifications.onEnvelope(e);
839
842
  const text = JSON.stringify(e);
840
- trustedServer.publish(TOPIC, text);
841
- lanServer?.publish(TOPIC, text);
843
+ const topic = topicOf(e.type);
844
+ trustedServer.publish(topic, text);
845
+ lanServer?.publish(topic, text);
842
846
  // A decision change can change a work's 입력 대기 state (its open decisions, or its status after a delivered answer).
843
847
  if (e.type === "activity.updated" || e.type === "conversation.created" || e.type === "decision.updated") workStatus.schedule();
844
848
  };
package/src/manual.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { join } from "node:path";
2
+
3
+ /**
4
+ * The user manual (docs/manual.md, docs/manual.ko.md) for the web 설정 "매뉴얼" button and the app's work status screen.
5
+ * Read only, on the web port and, behind the app device gate, on the app port. Korean is the default language.
6
+ */
7
+ export const MANUAL_PATH = "/api/manual";
8
+ export const MANUAL_DIR = join(import.meta.dir, "..", "docs");
9
+ export type ManualLang = "ko" | "en";
10
+
11
+ /** The `lang` query value as a language: missing or empty is Korean; anything but `ko` or `en` is null. */
12
+ export function manualLang(value: string | null): ManualLang | null {
13
+ if (value === null || value === "" || value === "ko") return "ko";
14
+ return value === "en" ? "en" : null;
15
+ }
16
+
17
+ export function manualFile(lang: ManualLang): string {
18
+ return lang === "en" ? "manual.md" : "manual.ko.md";
19
+ }
20
+
21
+ /** `GET /api/manual?lang=ko|en`: the Markdown text; 400 for another language, 404 with a hint when the file is missing. */
22
+ export async function serveManual(req: Request, url: URL, dir: string = MANUAL_DIR): Promise<Response> {
23
+ if (req.method !== "GET") return Response.json({ error: "method_not_allowed" }, { status: 405 });
24
+ const lang = manualLang(url.searchParams.get("lang"));
25
+ if (lang === null) return Response.json({ error: "unknown_lang", message: "lang은 ko 또는 en입니다." }, { status: 400 });
26
+ const file = Bun.file(join(dir, manualFile(lang)));
27
+ if (!(await file.exists())) {
28
+ return Response.json({ error: "manual_missing", message: "이 설치에는 매뉴얼 파일이 없습니다. 게이트웨이를 업데이트하세요." }, { status: 404 });
29
+ }
30
+ return new Response(file, { headers: { "content-type": "text/markdown; charset=utf-8", "cache-control": "no-cache", "content-language": lang } });
31
+ }
@@ -1,4 +1,5 @@
1
1
  import type { RuntimeSlot } from "../runtime/paths.ts";
2
+ import { activeRecord, type SlotRegistry, slotUrl } from "../runtime/slots.ts";
2
3
  import type { OmoClientHandlers, OmoEvent, OmoStatus } from "./client.ts";
3
4
 
4
5
  /**
@@ -55,18 +56,43 @@ type Listed = { sessionId?: unknown; sessionPath?: unknown };
55
56
 
56
57
  const LIVENESS_TIMEOUT_MS = 5000;
57
58
 
59
+ /**
60
+ * Bug 134: an installed gateway gets ZGW_OMO_URL from config.env, and it names this Mac's own slot a bridge. That value
61
+ * must not hide run/runtime-slots.json: when the update handoff restarts the gateway, the generation that still holds the
62
+ * sessions can be slot b. The registry is used unless ZGW_OMO_URL names another OMO (Docker, tests).
63
+ */
64
+ export function startupGenerations(explicitUrl: string | undefined, localUrl: string, registry: () => SlotRegistry | null): { endpoints: GenerationEndpoint[]; active: RuntimeSlot } {
65
+ const url = explicitUrl || localUrl;
66
+ const listed = isLocalBridge(url, localUrl) ? registry() : null;
67
+ if (listed === null) return { endpoints: [{ slot: "a", url }], active: "a" };
68
+ return { endpoints: listed.slots.map((record) => ({ slot: record.slot, url: slotUrl(record) })), active: activeRecord(listed).slot };
69
+ }
70
+
71
+ function isLocalBridge(url: string, localUrl: string): boolean {
72
+ if (!URL.canParse(url)) return false;
73
+ const given = new URL(url);
74
+ const local = new URL(localUrl);
75
+ return given.protocol === local.protocol && given.port === local.port && given.pathname === "/" && given.search === "" &&
76
+ (given.hostname === "127.0.0.1" || given.hostname === "localhost");
77
+ }
78
+
58
79
  /**
59
80
  * Bug 118: after a bridge restart the previous OMO host can still hold its claim on a session file for a moment (the
60
81
  * bridge's SIGTERM ends the host without waiting for it). The new host answers open_session with `session_path_in_use`
61
82
  * and `errorData.retry_after_ms` until that claim is gone; OMO documents the refusal as retryable. The open waits it out
62
83
  * for at most SESSION_PATH_WAIT_MS and never takes the path from the other host.
84
+ * Bug 134: `session_held` (a live holder outside this host's daemon family, e.g. a draining older generation) is waited
85
+ * out the same way; it carries no retry hint, so the open waits SESSION_PATH_RETRY_MS between tries.
63
86
  */
64
87
  export const SESSION_PATH_WAIT_MS = 60_000;
65
88
  export const SESSION_PATH_RETRY_MS = 2000;
66
89
 
67
- /** Milliseconds to wait before reopening after this error, or null when the error is not a held session path. */
90
+ /** Milliseconds to wait before reopening after this error, or null when the error is not a held session. */
68
91
  export function sessionPathRetryMs(error: unknown): number | null {
69
- if (typeof error !== "object" || error === null || (error as { code?: unknown }).code !== "session_path_in_use") return null;
92
+ if (typeof error !== "object" || error === null) return null;
93
+ const code = (error as { code?: unknown }).code;
94
+ if (code === "session_held") return SESSION_PATH_RETRY_MS;
95
+ if (code !== "session_path_in_use") return null;
70
96
  const data = (error as { errorData?: unknown }).errorData;
71
97
  const hint = typeof data === "object" && data !== null ? (data as { retry_after_ms?: unknown }).retry_after_ms : undefined;
72
98
  return typeof hint === "number" && hint > 0 ? hint : SESSION_PATH_RETRY_MS;
package/src/store.ts CHANGED
@@ -215,7 +215,7 @@ export interface WorkStartRequest extends WorkCommandRequest {
215
215
  export type WorkReport = Pick<WorkUpdate, "sourceKey" | "kind" | "commandSeq" | "summary" | "details">;
216
216
 
217
217
  export class WorkStoreError extends Error {
218
- constructor(readonly code: "work_not_found" | "command_not_found" | "request_key_conflict" | "report_conflict" | "invalid_report_command" | "invalid_delivery_transition" | "invalid_publish" | "publish_conflict" | "unresolved_work_commands" | "required_decisions_pending" | "question_without_decision", readonly ids: readonly string[] = []) {
218
+ constructor(readonly code: "work_not_found" | "command_not_found" | "request_key_conflict" | "report_conflict" | "invalid_report_command" | "invalid_delivery_transition" | "invalid_publish" | "publish_conflict" | "unresolved_work_commands" | "required_decisions_pending" | "question_without_decision" | "invalid_replacement_details", readonly ids: readonly string[] = []) {
219
219
  super(code);
220
220
  }
221
221
  }
@@ -420,8 +420,12 @@ export function replacementQuestionPayload(payload: Record<string, unknown>): Re
420
420
  options: Array.isArray(withIds.options) ? withIds.options : [] };
421
421
  }
422
422
 
423
- /** Closed bindings whose question still takes an answer, delivered to the owner as a follow-up command. */
424
- export const RESUMABLE_CLOSED: ReadonlySet<string> = new Set(["report_only", "main_released"]);
423
+ /**
424
+ * Closed bindings whose question still takes an answer, delivered to the owner as a follow-up command. Bug 127:
425
+ * async_wait_closed (a waitForAnswer false question whose native wait ended) and recheck_incomplete (a recheck turn
426
+ * ended without a replacement; the question stands as asked). A permission is never resumed (submitDecision, info).
427
+ */
428
+ export const RESUMABLE_CLOSED: ReadonlySet<string> = new Set(["report_only", "main_released", "async_wait_closed", "recheck_incomplete"]);
425
429
 
426
430
  /** Checks structural completeness, not the semantic appropriateness of a historical answer. */
427
431
  function completeDecisionAnswer(method: DecisionMethod, payload: unknown, response: unknown): boolean {
@@ -1374,7 +1378,14 @@ export class Store {
1374
1378
  if (command && report.kind !== "observation" && (command.delivery === "pending" || command.delivery === "sending" || command.delivery === "unknown")) {
1375
1379
  this.setWorkCommandDelivery(command.id, "accepted", { disposition: "work_report" });
1376
1380
  }
1377
- if (command && report.kind === "question" && object(report.details) && typeof report.details.replaces_decision_id === "string") {
1381
+ // Bug 127: a question report on a recheck command must carry its replacement as an object; anything else would be
1382
+ // accepted without replacing and leave the old question recheck-only.
1383
+ const replacing = object(report.details) && typeof report.details.replaces_decision_id === "string";
1384
+ if (command && report.kind === "question" && !replacing && object(command.payload) &&
1385
+ (command.payload.decision_operation === "recheck" || command.payload.decision_operation === "request_permission")) {
1386
+ throw new WorkStoreError("invalid_replacement_details", [workId]);
1387
+ }
1388
+ if (command && report.kind === "question" && replacing) {
1378
1389
  this.replaceDecisionFromReport({
1379
1390
  scope: `work:${workId}`, sourceSessionPath: work.sessionPath,
1380
1391
  reportId: report.sourceKey, commandId: command.id, details: report.details,
package/src/work.ts CHANGED
@@ -85,6 +85,8 @@ const REPORT_RULE = "Report completed or failed with the highest command_seq of
85
85
  const QUESTION_WITHOUT_DECISION = "No inbox question is open for this work. Ask the user with ask_user_question first " +
86
86
  "(waitForAnswer true for a required confirmation, which then needs no kind=question report; " +
87
87
  "waitForAnswer false only if the work may continue without the answer and you also report), then send kind=question again. A kind=question report alone creates no inbox question.";
88
+ const INVALID_REPLACEMENT_DETAILS = "This report answers a recheck, so details must be a JSON object, not a string: " +
89
+ "{replaces_decision_id, submission_id, and replacement:{method,payload,required} or replacement_decision_id}. Send the report again with details as an object.";
88
90
  function envelope(command: WorkCommand): string {
89
91
  return PREFIX + JSON.stringify({ version: 1, work_id: command.workId, command_id: command.id, command_seq: command.seq, kind: command.kind, payload: command.payload, report_rule: REPORT_RULE });
90
92
  }
@@ -155,7 +157,9 @@ export class WorkRouter {
155
157
  } catch (error) {
156
158
  if (error instanceof RoutingError) return Response.json({ error: error.code }, { status: error.status });
157
159
  if (error instanceof WorkStoreError) return Response.json({ error: error.code, ids: error.ids,
158
- ...(error.code === "question_without_decision" ? { detail: QUESTION_WITHOUT_DECISION } : {}) }, { status: 409 });
160
+ ...(error.code === "question_without_decision" ? { detail: QUESTION_WITHOUT_DECISION } : {}),
161
+ ...(error.code === "invalid_replacement_details" ? { detail: INVALID_REPLACEMENT_DETAILS } : {}) },
162
+ { status: error.code === "invalid_replacement_details" ? 400 : 409 });
159
163
  if (error instanceof DecisionStoreError) return Response.json({ error: error.code }, {
160
164
  status: error.code === "decision_not_found" ? 404 : error.code.startsWith("invalid_") ? 400 : 409,
161
165
  });
@@ -601,7 +605,7 @@ export class WorkRouter {
601
605
  } catch (error) {
602
606
  // The live report call already showed the worker this refusal; recovery skips the entry.
603
607
  // Unresolved commands retain their receipt state; completion needs a new valid report.
604
- if (error instanceof WorkStoreError && ["question_without_decision", "unresolved_work_commands"].includes(error.code)) continue;
608
+ if (error instanceof WorkStoreError && ["question_without_decision", "unresolved_work_commands", "invalid_replacement_details"].includes(error.code)) continue;
605
609
  if (!(error instanceof WorkStoreError) || error.code !== "required_decisions_pending") throw error;
606
610
  deferred = Math.min(deferred, index);
607
611
  }
package/web/app.js CHANGED
@@ -41,6 +41,7 @@ import { createEnrollment, NO_DEVICES_LINE, REVOKE_ARMED_LABEL, REVOKE_LABEL } f
41
41
  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";
42
42
  import { createAppDownload } from "./app-download.js";
43
43
  import { cleanRequest, cleanResultLine, GC_CLEAN_PATH, GC_PATH, gcView } from "./gc.js";
44
+ import { manualFailureText, manualLinkLang, manualUrl, otherLang } from "./manual.js";
44
45
 
45
46
  const $ = (id) => document.getElementById(id);
46
47
  const state = {
@@ -3689,4 +3690,93 @@ settings.toggle.onclick = () => (settings.open ? closeSettings() : openSettings(
3689
3690
  settings.close.onclick = closeSettings;
3690
3691
  enrollEls.renew.onclick = () => void enrollment.renew();
3691
3692
 
3693
+ // 설정 "매뉴얼": GET /api/manual (Korean first) rendered with the chat Markdown renderer; the first-line language links
3694
+ // and the header button switch to the other language in place.
3695
+ const manualEls = { el: $("manual"), open: $("manual-open"), close: $("manual-close"), title: $("manual-title"), lang: $("manual-lang"), status: $("manual-status"), body: $("manual-body") };
3696
+ const manualView = { open: false, lang: "ko", load: 0 };
3697
+
3698
+ function showManualStatus(text, error = false) {
3699
+ manualEls.status.textContent = text;
3700
+ manualEls.status.classList.toggle("error", error);
3701
+ manualEls.status.hidden = text === "";
3702
+ }
3703
+
3704
+ async function loadManual(lang) {
3705
+ const load = ++manualView.load;
3706
+ manualView.lang = lang;
3707
+ manualEls.lang.textContent = otherLang(lang) === "en" ? "English" : "한국어";
3708
+ manualEls.el.lang = lang;
3709
+ showManualStatus("매뉴얼을 불러오는 중입니다.");
3710
+ let status = 0;
3711
+ let text = "";
3712
+ try {
3713
+ const res = await fetch(manualUrl(lang));
3714
+ status = res.status;
3715
+ if (res.ok) text = await res.text();
3716
+ } catch (error) {
3717
+ console.warn("manual:", error);
3718
+ }
3719
+ if (load !== manualView.load) return;
3720
+ if (text === "") {
3721
+ manualEls.body.innerHTML = "";
3722
+ showManualStatus(manualFailureText(status), true);
3723
+ return;
3724
+ }
3725
+ showManualStatus("");
3726
+ renderMarkdown(manualEls.body, text);
3727
+ manualEls.body.scrollTop = 0;
3728
+ }
3729
+
3730
+ function openManual() {
3731
+ if (manualView.open) return;
3732
+ manualView.open = true;
3733
+ manualEls.el.hidden = false;
3734
+ manualEls.open.setAttribute("aria-expanded", "true");
3735
+ manualEls.title.focus();
3736
+ void loadManual(manualView.lang);
3737
+ }
3738
+
3739
+ function closeManual() {
3740
+ if (!manualView.open) return;
3741
+ manualView.open = false;
3742
+ manualView.load++;
3743
+ manualEls.el.hidden = true;
3744
+ manualEls.open.setAttribute("aria-expanded", "false");
3745
+ manualEls.open.focus();
3746
+ }
3747
+
3748
+ manualEls.open.onclick = openManual;
3749
+ manualEls.close.onclick = closeManual;
3750
+ manualEls.lang.onclick = () => void loadManual(otherLang(manualView.lang));
3751
+ manualEls.body.addEventListener("click", (ev) => {
3752
+ const link = ev.target instanceof Element ? ev.target.closest("a") : null;
3753
+ const lang = manualLinkLang(link?.getAttribute("href"));
3754
+ if (lang === null) return;
3755
+ ev.preventDefault();
3756
+ void loadManual(lang);
3757
+ });
3758
+ manualEls.el.addEventListener("click", (ev) => {
3759
+ if (ev.target === manualEls.el) closeManual();
3760
+ });
3761
+ manualEls.el.addEventListener("keydown", (ev) => {
3762
+ if (ev.isComposing) return;
3763
+ if (ev.key === "Escape") {
3764
+ ev.preventDefault();
3765
+ closeManual();
3766
+ return;
3767
+ }
3768
+ if (ev.key !== "Tab") return;
3769
+ const focusable = [...manualEls.el.querySelectorAll(FOCUSABLE)].filter((node) => node.offsetParent !== null);
3770
+ if (focusable.length === 0) return;
3771
+ const first = focusable[0];
3772
+ const last = focusable.at(-1);
3773
+ if (ev.shiftKey && (document.activeElement === first || document.activeElement === manualEls.title)) {
3774
+ ev.preventDefault();
3775
+ last.focus();
3776
+ } else if (!ev.shiftKey && document.activeElement === last) {
3777
+ ev.preventDefault();
3778
+ first.focus();
3779
+ }
3780
+ });
3781
+
3692
3782
  connect();
package/web/index.html CHANGED
@@ -122,6 +122,7 @@
122
122
  <div class="settings-card">
123
123
  <header class="settings-head">
124
124
  <h2 class="settings-title" id="settings-title" tabindex="-1">설정</h2>
125
+ <button type="button" class="text-btn" id="manual-open" title="사용자 매뉴얼: 화면, 단축키, 기능" aria-haspopup="dialog" aria-expanded="false" aria-controls="manual">매뉴얼</button>
125
126
  <button type="button" class="icon-btn settings-close" id="settings-close" aria-label="설정 닫기" title="닫기 (Esc)">×</button>
126
127
  </header>
127
128
  <div class="settings-body">
@@ -185,6 +186,17 @@
185
186
  </div>
186
187
  </div>
187
188
  </aside>
189
+ <aside class="settings manual" id="manual" role="dialog" aria-labelledby="manual-title" hidden>
190
+ <div class="settings-card manual-card">
191
+ <header class="settings-head">
192
+ <h2 class="settings-title" id="manual-title" tabindex="-1">매뉴얼</h2>
193
+ <button type="button" class="text-btn" id="manual-lang">English</button>
194
+ <button type="button" class="icon-btn settings-close" id="manual-close" aria-label="매뉴얼 닫기" title="닫기 (Esc)">×</button>
195
+ </header>
196
+ <p class="manual-status" id="manual-status" role="status" aria-live="polite" hidden></p>
197
+ <div class="manual-body msg-text" id="manual-body"></div>
198
+ </div>
199
+ </aside>
188
200
  <aside class="dev" id="dev">
189
201
  <header class="dev-head">
190
202
  <span>이벤트</span>
@@ -0,0 +1,7 @@
1
+ export type ManualLang = "ko" | "en";
2
+ export declare const MANUAL_PATH: string;
3
+ export declare const MANUAL_MISSING_TEXT: string;
4
+ export declare function manualUrl(lang: string): string;
5
+ export declare function otherLang(lang: string): ManualLang;
6
+ export declare function manualLinkLang(href: string | null | undefined): ManualLang | null;
7
+ export declare function manualFailureText(status: number): string;
package/web/manual.js ADDED
@@ -0,0 +1,29 @@
1
+ // The user manual view of 설정 "매뉴얼": decisions only, with no DOM, so test/unit/manual.test.ts and the mutation run
2
+ // drive them directly; app.js fetches GET /api/manual and renders the Markdown with the chat renderer.
3
+
4
+ export const MANUAL_PATH = "/api/manual";
5
+ export const MANUAL_MISSING_TEXT = "이 게이트웨이 버전에는 매뉴얼이 없습니다. 게이트웨이를 업데이트하면 볼 수 있습니다.";
6
+
7
+ export function manualUrl(lang) {
8
+ return `${MANUAL_PATH}?lang=${lang === "en" ? "en" : "ko"}`;
9
+ }
10
+
11
+ export function otherLang(lang) {
12
+ return lang === "en" ? "ko" : "en";
13
+ }
14
+
15
+ /** The language a rendered link switches to: the manuals' first line links manual.md (English) and manual.ko.md (Korean). */
16
+ export function manualLinkLang(href) {
17
+ if (typeof href !== "string") return null;
18
+ const file = href.split(/[?#]/)[0].split("/").pop();
19
+ if (file === "manual.md") return "en";
20
+ if (file === "manual.ko.md") return "ko";
21
+ return null;
22
+ }
23
+
24
+ /** The line for a failed load: [status] is the HTTP status, or 0 when the request itself failed. */
25
+ export function manualFailureText(status) {
26
+ if (status === 404) return MANUAL_MISSING_TEXT;
27
+ if (status === 0) return "매뉴얼을 불러오지 못했습니다. 게이트웨이 연결을 확인하세요.";
28
+ return `매뉴얼을 불러오지 못했습니다 (HTTP ${status}).`;
29
+ }
package/web/style.css CHANGED
@@ -423,6 +423,11 @@ button { font: inherit; color: inherit; cursor: pointer; }
423
423
  .gc-message:empty { display: none; }
424
424
  .gc-message.error { color: #f38b8d; }
425
425
  .gc-actions { display: flex; justify-content: flex-end; }
426
+ .settings.manual { z-index: 31; }
427
+ .manual-card { width: min(100%, 760px); height: 100%; }
428
+ .manual-status { margin: 0; padding: 12px 16px 0; font-size: 13px; color: var(--muted); }
429
+ .manual-status.error { color: #f38b8d; }
430
+ .manual-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 16px; }
426
431
  @media (max-width: 640px) {
427
432
  .settings { padding: 0; }
428
433
  .settings-card { width: 100%; height: 100dvh; max-height: none; border: 0; border-radius: 0; }