zigmini 0.9.0-beta.3 → 1.0.0-beta.2

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 (143) hide show
  1. package/README.ko.md +36 -4
  2. package/README.md +36 -4
  3. package/assets/agent/gateway-rules.md +29 -19
  4. package/assets/android/zigmini.apk +0 -0
  5. package/assets/android/zigmini.apk.json +3 -3
  6. package/assets/build.json +1 -1
  7. package/docs/manual.ko.md +60 -11
  8. package/docs/manual.md +61 -12
  9. package/host/omo-bridge/agent-control.ts +274 -0
  10. package/host/omo-bridge/bridge.ts +1 -1
  11. package/host/omo-bridge/continuity-control.ts +1 -1
  12. package/host/omo-bridge/task-control.ts +26 -16
  13. package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
  14. package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
  15. package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
  16. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
  17. package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
  18. package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
  19. package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
  20. package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
  21. package/package.json +3 -2
  22. package/src/agent/api.ts +215 -0
  23. package/src/agent/definition.ts +177 -0
  24. package/src/agent/fields.ts +42 -0
  25. package/src/agent/files.ts +192 -0
  26. package/src/agent/legacy-files.ts +46 -0
  27. package/src/agent/list.ts +21 -0
  28. package/src/agent-rules.ts +97 -13
  29. package/src/approvals.ts +6 -1
  30. package/src/cli/doctor.ts +7 -0
  31. package/src/cli/main.ts +38 -0
  32. package/src/cli/messages.ts +10 -2
  33. package/src/cli/rotate-main.ts +2 -1
  34. package/src/cli/rules.ts +70 -0
  35. package/src/cli/scan.ts +414 -0
  36. package/src/cli/security-import.ts +155 -0
  37. package/src/compact.ts +22 -0
  38. package/src/decisions.ts +6 -4
  39. package/src/engine/compat.json +3 -3
  40. package/src/engine/index.ts +1 -0
  41. package/src/engine/schema-guard.ts +12 -4
  42. package/src/engine/sdk.ts +8 -0
  43. package/src/formal-verification.ts +366 -0
  44. package/src/gc.ts +37 -6
  45. package/src/hub.ts +339 -79
  46. package/src/main.ts +95 -9
  47. package/src/maintenance.ts +1 -1
  48. package/src/manual.ts +9 -2
  49. package/src/profile-prompt.ts +73 -0
  50. package/src/profile.ts +33 -10
  51. package/src/protocol.ts +11 -2
  52. package/src/question-notifications.ts +4 -1
  53. package/src/reply-notifications.ts +7 -1
  54. package/src/repo-security-setting.ts +33 -0
  55. package/src/runtime/paths.ts +16 -0
  56. package/src/security/baseline.ts +172 -0
  57. package/src/security/dex-strings.ts +242 -0
  58. package/src/security/repo-scan.ts +369 -0
  59. package/src/security/revision-probe.ts +144 -0
  60. package/src/security/terms.ts +97 -0
  61. package/src/security-allow.ts +1 -0
  62. package/src/security-baseline.ts +113 -0
  63. package/src/security-check.ts +91 -16
  64. package/src/server-text.ts +38 -0
  65. package/src/store.ts +142 -16
  66. package/src/transcript.ts +7 -1
  67. package/src/ui-language.ts +33 -0
  68. package/src/update/check.ts +7 -3
  69. package/src/update/handoff-system.ts +24 -16
  70. package/src/update/handoff.ts +23 -12
  71. package/src/update/helper.ts +72 -36
  72. package/src/update/request.ts +46 -30
  73. package/src/update/state.ts +21 -11
  74. package/src/update/strategy.ts +12 -5
  75. package/src/update/target.ts +32 -15
  76. package/src/usage.ts +43 -20
  77. package/src/version.ts +39 -16
  78. package/src/voice/agents.ts +8 -3
  79. package/src/voice/install.ts +29 -18
  80. package/src/work-watch.ts +40 -18
  81. package/web/agent-icon.js +15 -9
  82. package/web/agents.d.ts +201 -0
  83. package/web/agents.js +813 -0
  84. package/web/app-download.js +32 -19
  85. package/web/app.js +1407 -278
  86. package/web/autocopy.js +6 -1
  87. package/web/boot.js +27 -8
  88. package/web/commands.js +20 -11
  89. package/web/decisions.js +70 -49
  90. package/web/devlog.js +25 -19
  91. package/web/diagram.js +8 -3
  92. package/web/enroll.js +43 -22
  93. package/web/gc.js +32 -19
  94. package/web/i18n/agent-icon.js +24 -0
  95. package/web/i18n/agents.js +48 -0
  96. package/web/i18n/app-download.js +26 -0
  97. package/web/i18n/autocopy.js +5 -0
  98. package/web/i18n/boot.js +24 -0
  99. package/web/i18n/commands.js +34 -0
  100. package/web/i18n/decisions.js +24 -0
  101. package/web/i18n/devlog.js +60 -0
  102. package/web/i18n/diagram.js +12 -0
  103. package/web/i18n/enroll.js +22 -0
  104. package/web/i18n/gc.js +38 -0
  105. package/web/i18n/lastsent.js +10 -0
  106. package/web/i18n/main.d.ts +4 -0
  107. package/web/i18n/main.js +527 -0
  108. package/web/i18n/manual.d.ts +4 -0
  109. package/web/i18n/manual.js +14 -0
  110. package/web/i18n/memo.js +30 -0
  111. package/web/i18n/notifications.js +16 -0
  112. package/web/i18n/profile.js +13 -0
  113. package/web/i18n/reference.js +26 -0
  114. package/web/i18n/security-inbox.js +40 -0
  115. package/web/i18n/slash.js +38 -0
  116. package/web/i18n/turn-stats.js +5 -0
  117. package/web/i18n/update.js +42 -0
  118. package/web/i18n/voice.js +28 -0
  119. package/web/i18n/wake.js +16 -0
  120. package/web/i18n/workid.js +10 -0
  121. package/web/i18n/workstatus.js +20 -0
  122. package/web/i18n.d.ts +20 -0
  123. package/web/i18n.js +63 -0
  124. package/web/index.html +156 -96
  125. package/web/lastsent.js +9 -4
  126. package/web/manual.d.ts +1 -1
  127. package/web/manual.js +11 -5
  128. package/web/memo.js +18 -13
  129. package/web/notifications.js +12 -7
  130. package/web/profile.js +42 -17
  131. package/web/reference.js +20 -15
  132. package/web/security-inbox.d.ts +18 -1
  133. package/web/security-inbox.js +173 -4
  134. package/web/slash.js +23 -13
  135. package/web/style.css +132 -3
  136. package/web/tooltip.js +5 -2
  137. package/web/turn-stats.js +6 -2
  138. package/web/update.js +31 -23
  139. package/web/voice.js +18 -8
  140. package/web/wake.d.ts +4 -0
  141. package/web/wake.js +7 -3
  142. package/web/workid.js +12 -2
  143. package/web/workstatus.js +36 -32
package/src/hub.ts CHANGED
@@ -11,6 +11,7 @@ import { type TranscriptLog, toRecord } from "./transcript.ts";
11
11
  import { MaintenanceError, nativeMaintenanceBlocker } from "./maintenance.ts";
12
12
  import { isVersionCommand, VERSION_COMMAND } from "./version.ts";
13
13
  import { isUsageCommand, USAGE_COMMAND } from "./usage.ts";
14
+ import { COMPACT_COMMAND, compactInstructions } from "./compact.ts";
14
15
  import { MessageReferenceError, referencedInput, resolveMessageReferences } from "./message-references.ts";
15
16
  import { readProfile } from "./profile.ts";
16
17
  import type { MessageGuard, SecurityChecker } from "./security-check.ts";
@@ -18,6 +19,16 @@ import { SecurityRuntimeError, type SecurityReceipt } from "./security-allow.ts"
18
19
  import { nativeReplyEntry, type NativeReplyObserver } from "./reply-notifications.ts";
19
20
  import { notificationHistory } from "./notification-history.ts";
20
21
  import { messageTimestamp, nativeTurnStarted, nativeTurnStats } from "./turn-stats.ts";
22
+ import { join } from "node:path";
23
+ import { mergeAgentDefinition, AgentDefinitionError, type MergedAgentDefinition } from "./agent/definition.ts";
24
+ import { AgentFileStore } from "./agent/files.ts";
25
+ import { type Language, say, serverLanguage, type Text, textIn } from "./server-text.ts";
26
+
27
+ class AgentSessionError extends Error {
28
+ constructor() {
29
+ super("agent_definition_invalid");
30
+ }
31
+ }
21
32
 
22
33
  export type Broadcast = (envelope: Envelope) => void;
23
34
 
@@ -41,6 +52,7 @@ const HISTORY_MAX_RESUME = 1000;
41
52
 
42
53
  export interface HubOptions {
43
54
  replies?: NativeReplyObserver;
55
+ agents?: AgentFileStore;
44
56
  cwd: string;
45
57
  sessionDir: string;
46
58
  permissionPreset: string;
@@ -74,21 +86,37 @@ const PROMPT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
74
86
  const MAX_ATTACHMENTS = 4;
75
87
 
76
88
  const COMPACTION_RECOVERY = {
77
- invalid_model: { action: "settings", message: "요약 모델 설정을 확인하거나 compaction.model을 auto로 바꾼 뒤 다시 진행해 주세요." },
78
- auth_missing: { action: "login", message: "요약에 사용하는 제공자에 로그인한 뒤 다시 진행해 주세요." },
79
- auth_expired: { action: "login", message: "요약에 사용하는 제공자에 다시 로그인한 뒤 다시 진행해 주세요." },
80
- quota: { action: "quota", message: "제공자의 사용 한도가 복구된 뒤 다시 진행해 주세요." },
81
- org_denied: { action: "account", message: "해당 제공자의 계정과 조직 권한을 확인한 뒤 다시 진행해 주세요." },
82
- model_unavailable: { action: "settings", message: "요약 모델의 사용 가능 여부와 설정을 확인한 뒤 다시 진행해 주세요." },
83
- refused: { action: "retry", message: "제공자가 요약을 거절했습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
84
- timeout: { action: "retry", message: "요약 요청 시간이 초과됐습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
85
- incomplete: { action: "retry", message: "요약 응답이 완전하지 않습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
86
- empty: { action: "retry", message: "요약 응답이 비어 있습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
87
- context_budget: { action: "retry", message: "요약 결과가 대화 모델의 입력 한도에 맞지 않습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
88
- stale: { action: "retry", message: "요약 중 대화가 바뀌었습니다. 최신 대화에서 다시 진행해 주세요." },
89
- provider: { action: "retry", message: "원인을 해결한 뒤 다시 진행해 주세요." },
90
- account_blocked: { action: "account", message: "요약 공급자의 계정 묶음이 요청을 보내지 않았습니다. 원인이 사용 한도인지 인증인지는 확정되지 않았습니다. 계정 상태를 확인하거나 다른 모델을 고른 뒤 다시 진행해 주세요." },
91
- } as const satisfies Record<CompactionFailureKind, { readonly action: string; readonly message: string }>;
89
+ invalid_model: { action: "settings", message: { en: "Check the summary model setting or change compaction.model to auto, then try again.", ko: "요약 모델 설정을 확인하거나 compaction.model을 auto로 바꾼 뒤 다시 진행해 주세요." } },
90
+ auth_missing: { action: "login", message: { en: "Sign in to the provider used for summaries, then try again.", ko: "요약에 사용하는 제공자에 로그인한 뒤 다시 진행해 주세요." } },
91
+ auth_expired: { action: "login", message: { en: "Sign in again to the provider used for summaries, then try again.", ko: "요약에 사용하는 제공자에 다시 로그인한 뒤 다시 진행해 주세요." } },
92
+ quota: { action: "quota", message: { en: "Try again after the provider's usage limit resets.", ko: "제공자의 사용 한도가 복구된 뒤 다시 진행해 주세요." } },
93
+ org_denied: { action: "account", message: { en: "Check the provider account and its organization permissions, then try again.", ko: "해당 제공자의 계정과 조직 권한을 확인한 뒤 다시 진행해 주세요." } },
94
+ model_unavailable: { action: "settings", message: { en: "Check that the summary model is available and its settings are correct, then try again.", ko: "요약 모델의 사용 가능 여부와 설정을 확인한 뒤 다시 진행해 주세요." } },
95
+ refused: { action: "retry", message: { en: "The provider refused the summary. Fix the cause, then try again.", ko: "제공자가 요약을 거절했습니다. 원인을 해결한 뒤 다시 진행해 주세요." } },
96
+ timeout: { action: "retry", message: { en: "The summary request timed out. Fix the cause, then try again.", ko: "요약 요청 시간이 초과됐습니다. 원인을 해결한 뒤 다시 진행해 주세요." } },
97
+ incomplete: { action: "retry", message: { en: "The summary response was incomplete. Fix the cause, then try again.", ko: "요약 응답이 완전하지 않습니다. 원인을 해결한 뒤 다시 진행해 주세요." } },
98
+ empty: { action: "retry", message: { en: "The summary response was empty. Fix the cause, then try again.", ko: "요약 응답이 비어 있습니다. 원인을 해결한 뒤 다시 진행해 주세요." } },
99
+ context_budget: { action: "retry", message: { en: "The summary does not fit the chat model's input limit. Fix the cause, then try again.", ko: "요약 결과가 대화 모델의 입력 한도에 맞지 않습니다. 원인을 해결한 뒤 다시 진행해 주세요." } },
100
+ stale: { action: "retry", message: { en: "The conversation changed during the summary. Try again from the latest conversation.", ko: "요약 중 대화가 바뀌었습니다. 최신 대화에서 다시 진행해 주세요." } },
101
+ provider: { action: "retry", message: { en: "Fix the cause, then try again.", ko: "원인을 해결한 뒤 다시 진행해 주세요." } },
102
+ account_blocked: { action: "account", message: { en: "The summary provider's account pool did not send the request. It is not confirmed whether the cause is the usage limit or authentication. Check the account status or choose another model, then try again.", ko: "요약 공급자의 계정 묶음이 요청을 보내지 않았습니다. 원인이 사용 한도인지 인증인지는 확정되지 않았습니다. 계정 상태를 확인하거나 다른 모델을 고른 뒤 다시 진행해 주세요." } },
103
+ } as const satisfies Record<CompactionFailureKind, { readonly action: string; readonly message: Text }>;
104
+
105
+ /** Improvement 230: why a `/compact` was refused and what to do; a refused `/compact` is never queued. */
106
+ const COMPACT_REFUSED = {
107
+ engine: { en: "The conversation was not summarized because the engine is not connected. Send /compact again after the connection returns.", ko: "엔진에 연결되지 않아 대화를 요약하지 않았습니다. 연결이 돌아온 뒤 /compact를 다시 보내 주세요." },
108
+ session: { en: "The conversation was not summarized because the session is being switched. Send /compact again after the switch finishes.", ko: "세션을 교체하고 있어 대화를 요약하지 않았습니다. 교체가 끝난 뒤 /compact를 다시 보내 주세요." },
109
+ compacting: { en: "The conversation is already being summarized. If needed, send /compact again after the summary finishes.", ko: "이미 대화를 요약하고 있습니다. 요약이 끝난 뒤 필요하면 /compact를 다시 보내 주세요." },
110
+ retrying: { en: "The conversation was not summarized because a model request is being retried. Send /compact again after the retry finishes.", ko: "모델 요청을 재시도하고 있어 대화를 요약하지 않았습니다. 재시도가 끝난 뒤 /compact를 다시 보내 주세요." },
111
+ answering: { en: "The conversation was not summarized because an answer is in progress. Send /compact again after the answer finishes.", ko: "답변이 진행 중이라 대화를 요약하지 않았습니다. 답변이 끝난 뒤 /compact를 다시 보내 주세요." },
112
+ } as const satisfies Record<string, Text>;
113
+ /** A compaction failure notice: what failed, then the recovery text of COMPACTION_RECOVERY. */
114
+ const COMPACTION_FAILED: Text = { en: "Could not summarize the conversation. The earlier content was kept. {recovery}",
115
+ ko: "대화 요약에 실패했습니다. 이전 내용은 유지했습니다. {recovery}" };
116
+ /** Improvement 230: the engine refused `/compact` and no terminal compaction event reported it. */
117
+ function compactNotReported(): string {
118
+ return say(COMPACTION_FAILED, { recovery: say(COMPACTION_RECOVERY.provider.message) });
119
+ }
92
120
 
93
121
  /** Bug 223: the longest provider error a compaction failure log line keeps, after its sensitive values are removed. */
94
122
  export const COMPACTION_ERROR_MAX_CHARS = 512;
@@ -204,7 +232,65 @@ function describeBlockers(blockers: readonly RotationBlocker[]): string {
204
232
  const HANDOFF_MAX_CHARS = 8000;
205
233
  const HANDOFF_LINE_CHARS = 600;
206
234
  const CONTEXT_OVERFLOW = /prompt is too long|context overflow/i;
207
- const COMPACTION_REQUIRED_MESSAGE = "대화 요약이 끝나지 않아 엔진이 이 메시지를 받지 않았습니다. 메시지는 기록에 남아 있고 자동으로 다시 보내지 않습니다. 요약 실패 원인을 해결하거나 '새 세션'을 눌러 주세요.";
235
+ const COMPACTION_REQUIRED_MESSAGE: Text = {
236
+ en: "The engine did not receive this message because the conversation summary did not finish. The message stays in the history and will not be sent again automatically. Fix the cause of the summary failure or press 'New session'.",
237
+ ko: "대화 요약이 끝나지 않아 엔진이 이 메시지를 받지 않았습니다. 메시지는 기록에 남아 있고 자동으로 다시 보내지 않습니다. 요약 실패 원인을 해결하거나 '새 세션'을 눌러 주세요.",
238
+ };
239
+
240
+ /** Improvement 253: fixed hub text people read in the chat (notices, errors, cancel reasons, labels), chosen by say() when made. */
241
+ export const HUB_TEXT = {
242
+ newConversation: { en: "New conversation", ko: "새 대화" },
243
+ engineDisconnected: { en: "The engine connection was lost.", ko: "엔진 연결이 끊겼습니다." },
244
+ recoveryFailed: { en: "Could not recover the previous work: {error}", ko: "이전 작업 복구 실패: {error}" },
245
+ closeChecking: { en: "Checking whether the session closed. The message will be delivered after the check.", ko: "세션 닫기 상태를 확인하고 있습니다. 메시지는 확인 후 전달합니다." },
246
+ workNotifyFailed: { en: "Could not send the work result notification: {error}", ko: "작업 결과 알림 실패: {error}" },
247
+ workReceiptUnknown: { en: "Could not confirm that the work result notification was received. The result record is kept.", ko: "작업 결과 알림의 수신을 확인하지 못했습니다. 결과 기록은 보존되어 있습니다." },
248
+ workReconcileFailed: { en: "Could not check the work result notifications: {error}", ko: "작업 결과 알림 대조 실패: {error}" },
249
+ inputQueued: { en: "The message is still in the engine queue. It will not be sent again automatically.", ko: "메시지가 엔진 대기열에 남아 있습니다. 자동 재전송하지 않습니다." },
250
+ inputConsumptionUnknown: { en: "Could not confirm that the message was processed. The record is kept, and the message will not be sent again automatically.", ko: "메시지의 실행 소비를 확인하지 못했습니다. 기록을 보존하며 자동 재전송하지 않습니다." },
251
+ queuedResumeFailed: { en: "Could not confirm that the queued input resumed: {error}", ko: "대기 입력 재개 확인 실패: {error}" },
252
+ userInputReconcileFailed: { en: "Could not check the user input: {error}", ko: "사용자 입력 대조 실패: {error}" },
253
+ sessionTransition: { en: "Switching session", ko: "세션 교체 중" },
254
+ sessionRotated: { en: "Switched to a new engine session ({reason}). The chat history on the screen stays as it is, and the next message continues in the new session.{extra}",
255
+ ko: "새 엔진 세션으로 교체했습니다 ({reason}). 화면의 대화 기록은 그대로이고, 다음 메시지부터 새 세션에서 이어집니다.{extra}" },
256
+ rotatedAfterFailure: { en: " The previous session file was kept, and messages the engine did not receive will not be sent again automatically.", ko: " 이전 세션 파일은 그대로 남겨 두었고, 받지 못한 메시지는 자동으로 다시 보내지 않습니다." },
257
+ closeResolved: { en: "Checked whether the session closed. Messages continue in the existing conversation.", ko: "세션 닫기 상태를 확인했습니다. 기존 대화에서 메시지 처리를 계속합니다." },
258
+ closeRecoveryExhausted: { en: "session_close_recovery_exhausted: {error}. The message is kept. Check the engine connection and the session list, then restart the gateway.",
259
+ ko: "session_close_recovery_exhausted: {error}. 메시지는 보관 중입니다. 엔진 연결과 세션 목록을 확인한 뒤 게이트웨이를 다시 시작하세요." },
260
+ rotationQuestionClosed: { en: "This question was closed by a session switch. If you answer it in the inbox, the answer goes to the new session.", ko: "세션 교체로 닫힌 질문입니다. 받은편지함에서 답하면 새 세션으로 전달됩니다." },
261
+ continuityProtected: { en: "{message}. The same engine session is kept. Fix the cause, then send an instruction to continue. The earlier input will not be sent again automatically.",
262
+ ko: "{message}. 같은 엔진 세션을 유지합니다. 원인을 해결한 뒤 계속할 지시를 보내 주세요. 이전 입력은 자동 재전송하지 않습니다." },
263
+ userStopped: { en: "The user stopped the task.", ko: "사용자가 작업을 중단했습니다." },
264
+ stopped: { en: "Stopped the task.", ko: "작업을 중단했습니다." },
265
+ modelResponseError: { en: "Model response error", ko: "모델 응답 오류" },
266
+ compactionStarted: { en: "Summarizing the conversation. Please wait until it finishes.", ko: "대화 내용을 요약하고 있습니다. 완료될 때까지 기다려 주세요." },
267
+ compactionCancelled: { en: "Cancelled the conversation summary. The earlier content and the same session are kept. The earlier input will not be sent again automatically.",
268
+ ko: "대화 요약을 취소했습니다. 이전 내용과 같은 세션을 유지합니다. 이전 입력은 자동으로 다시 보내지 않습니다." },
269
+ compactionCompleted: { en: "Finished summarizing the conversation. Continuing in the same session.", ko: "대화 요약을 완료했습니다. 같은 세션에서 계속합니다." },
270
+ turnEnded: { en: "The task finished.", ko: "작업이 끝났습니다." },
271
+ permissionTitle: { en: "Permission request: {tool}", ko: "권한 요청: {tool}" },
272
+ endedWhileOff: { en: "This question ended while the gateway was off.", ko: "게이트웨이가 꺼져 있던 동안 끝난 질문입니다." },
273
+ reattached: { en: "The gateway reconnected and continues to receive the work in progress.{recovered}", ko: "게이트웨이가 다시 연결돼 진행 중인 작업을 이어서 받습니다.{recovered}" },
274
+ sendReceiptUnknown: { en: "Could not confirm that the message was received after it was sent. To avoid running it twice, it will not be sent again automatically.",
275
+ ko: "메시지 전송 이후 수신을 확인하지 못했습니다. 중복 실행을 피하기 위해 자동 재전송하지 않습니다." },
276
+ recovered: { en: "Fetched the work results that finished while the gateway was off.{recovered}", ko: "게이트웨이가 꺼져 있던 동안 끝난 작업 결과를 가져왔습니다.{recovered}" },
277
+ interrupted: { en: "The work was interrupted. It was continued automatically {tries} times but did not finish. Check it, then give the instruction again.",
278
+ ko: "작업이 끊겨 자동으로 {tries}번 이어 보냈지만 끝나지 않았습니다. 확인 후 다시 지시해 주세요." },
279
+ autoContinue: { en: "Continuing the work a restart interrupted ({attempt}/{limit}).{recovered}", ko: "재시작으로 끊긴 작업을 이어서 진행합니다 ({attempt}/{limit}).{recovered}" },
280
+ recoveredOne: { en: " Filled in 1 missed answer.", ko: " 놓친 답변 1개를 채웠습니다." },
281
+ recoveredMany: { en: " Filled in {count} missed answers.", ko: " 놓친 답변 {count}개를 채웠습니다." },
282
+ multiSelect: { en: " (multiple choices allowed)", ko: " (여러 개 선택 가능)" },
283
+ questionUnreachable: { en: "The question window did not reach the web. Press Stop, then answer in the chat.", ko: "질문 창이 웹에 도착하지 않았습니다. 중단 버튼을 누른 뒤 채팅으로 답해 주세요." },
284
+ } as const satisfies Record<string, Text>;
285
+
286
+ /** Why a session was replaced. The user notice uses the UI language; the engine context keeps the Korean text. */
287
+ const ROTATION_REASON = {
288
+ size: { en: "the session file reached {mb} MB", ko: "세션 파일이 {mb}MB에 닿음" },
289
+ compactions: { en: "compaction ran {count} more times", ko: "압축이 {count}번 더 이어짐" },
290
+ compaction_failed: { en: "the conversation summary failed {count} times in a row", ko: "대화 요약이 {count}번 연속 실패함" },
291
+ manual: { en: "requested directly", ko: "직접 요청" },
292
+ age: { en: "the session passed {hours} hours and is idle", ko: "세션이 {hours}시간을 넘겨 쉬는 중" },
293
+ } as const satisfies Record<string, Text>;
208
294
 
209
295
  interface OpenResult {
210
296
  sessionId: string;
@@ -234,6 +320,11 @@ export interface SlashCommand {
234
320
  source: string;
235
321
  }
236
322
 
323
+ /** An agent session's command list (D5): skill commands outside its `skills` allowlist are left out; other sources stay. */
324
+ export function allowedAgentCommands<T extends { readonly name: string; readonly source: string }>(commands: readonly T[], skills: ReadonlySet<string>): T[] {
325
+ return commands.filter((command) => command.source !== "skill" || skills.has(command.name) || skills.has(command.name.replace(/^skill:/, "")));
326
+ }
327
+
237
328
  interface WorkWake {
238
329
  input: StoredEvent;
239
330
  /** Releases a live receipt wait after reconciliation or connection loss. Absent after restart. */
@@ -381,6 +472,7 @@ export function modelOptions(models: OmoModel[]): ModelChoice[] {
381
472
 
382
473
  export class Hub {
383
474
  private readonly runtime = new Map<string, ConvRuntime>();
475
+ private readonly deletingAgents = new Set<string>();
384
476
  private readonly sessionToConv = new Map<string, string>();
385
477
  /** Open ask_user_question cards by scope-qualified gateway action ID. omo owns their lifetime. */
386
478
  private readonly questions = new Map<string, QuestionCard>();
@@ -676,9 +768,10 @@ export class Hub {
676
768
  }
677
769
 
678
770
  snapshot(): Record<string, unknown> {
771
+ const conversations = this.store.listConversations(false);
679
772
  return {
680
- conversations: this.store.listConversations(),
681
- activity: Object.fromEntries(this.store.listConversations().map((conv) => [conv.id, this.activity(conv.id)])),
773
+ conversations,
774
+ activity: Object.fromEntries(conversations.map((conv) => [conv.id, this.activity(conv.id)])),
682
775
  approvals: [...this.dialogs.list(), ...this.questions.values(), ...this.releasedMainCards()],
683
776
  sessionTransitions: [...this.runtime]
684
777
  .filter(([, runtime]) => runtime.rotating)
@@ -720,6 +813,8 @@ export class Hub {
720
813
  private modelPending = false;
721
814
  private readonly knownModels = new Set<string>();
722
815
  private providerErrorChecker: SecurityChecker | null = null;
816
+ /** Improvement 230: conversations whose `/compact` RPC has not answered yet. */
817
+ private readonly manualCompactions = new Set<string>();
723
818
 
724
819
  /** The models omo can use right now (its logged-in providers) and the one a conversation's session is on. */
725
820
  async models(convId = "main"): Promise<ModelInfo> {
@@ -787,13 +882,43 @@ export class Hub {
787
882
  }
788
883
  }
789
884
 
790
- createConversation(title: string): ConversationInfo {
791
- const conv = this.store.createConversation(this.nextId("c"), title.trim() || "새 대화");
885
+ createConversation(title: string, agentId?: string): ConversationInfo {
886
+ const conv = this.store.createConversation(this.nextId("c"), title.trim() || say(HUB_TEXT.newConversation), agentId);
792
887
  this.emit("*", "conversation.created", conv);
793
888
  this.publishActivity(conv.id);
794
889
  return conv;
795
890
  }
796
891
 
892
+ agentUpdated(data: unknown): void {
893
+ this.emit("*", "agent.updated", data);
894
+ }
895
+
896
+ /** Keep file movement and visibility changes inside the same deletion admission. */
897
+ async deleteAgentConversation(convId: string, trash: () => string): Promise<string> {
898
+ const conv = this.store.getConversation(convId);
899
+ if (!conv?.agentId) throw new Error("agent_conversation_missing");
900
+ const rt = this.rt(convId);
901
+ if (this.maintenanceId || this.deletingAgents.has(convId) || rt.rotating || this.closeUncertain(convId) ||
902
+ this.hasUnfinishedTurn(convId) || this.rotationBlocked(convId) ||
903
+ this.openQuestionCards(convId, this.conversationScope(convId)).size > 0) throw new Error("agent_busy");
904
+ this.deletingAgents.add(convId);
905
+ try {
906
+ if (conv.threadId || rt.sessionId) {
907
+ const { sessionId } = await this.ensureSession(conv, true);
908
+ await this.requireOmo().command("close_session", { sessionId });
909
+ this.sessionToConv.delete(sessionId);
910
+ const owner = this.ownerForConversation(convId, sessionId);
911
+ if (owner) this.releaseScopedUiOwner(owner);
912
+ rt.sessionId = null;
913
+ }
914
+ const entry = trash();
915
+ this.store.setConversationHidden(convId, true);
916
+ return entry;
917
+ } finally {
918
+ this.deletingAgents.delete(convId);
919
+ }
920
+ }
921
+
797
922
  onOmoStatus(status: OmoStatus, detail?: string): Promise<number> {
798
923
  const activityRecovery = this.beginActivityRecovery();
799
924
  this.omoStatus = status;
@@ -821,7 +946,7 @@ export class Hub {
821
946
  rt.wake = null;
822
947
  rt.notified.clear();
823
948
  const scope = this.conversationScope(conv);
824
- this.dialogs.cancelScope(scope, "엔진 연결이 끊겼습니다.");
949
+ this.dialogs.cancelScope(scope, say(HUB_TEXT.engineDisconnected));
825
950
  for (const [id, card] of this.questions) {
826
951
  if (card.scope !== scope) continue;
827
952
  this.questions.delete(id);
@@ -884,7 +1009,7 @@ export class Hub {
884
1009
  this.activityRecoveryFailedGeneration = activityRecovery;
885
1010
  this.publishAllActivities();
886
1011
  }
887
- this.emit(conv.id, "error", { message: `이전 작업 복구 실패: ${errorMessage(error)}` });
1012
+ if (!(error instanceof AgentSessionError)) this.emit(conv.id, "error", { message: say(HUB_TEXT.recoveryFailed, { error: errorMessage(error) }) });
888
1013
  }
889
1014
  }
890
1015
  }
@@ -892,6 +1017,7 @@ export class Hub {
892
1017
  /** `images` are names of files already uploaded to the media folder; unknown names are dropped. */
893
1018
  async sendText(convId: string, text: string, src: Source, trust: Trust, images: string[] = [], referenceIds?: unknown, checked = false, submissionId?: string): Promise<void> {
894
1019
  if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
1020
+ this.requireVisibleAgent(convId);
895
1021
  if (submissionId !== undefined && !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(submissionId)) throw new SecurityRuntimeError("held_conflict");
896
1022
  const context = { ...(submissionId ? { key: JSON.stringify(["message", convId, submissionId]), submissionId } : {}),
897
1023
  snapshot: structuredClone({ text, src, trust, images, referenceIds }) };
@@ -909,6 +1035,7 @@ export class Hub {
909
1035
  this.emit(convId, "notice", { kind: "usage", level: "info", message: await this.options.usageReport() });
910
1036
  return;
911
1037
  }
1038
+ const compact = images.length === 0 && references.length === 0 ? compactInstructions(body) : null;
912
1039
  const dir = this.options.media?.dir;
913
1040
  const attached = dir ? [...new Set(images)].filter((n) => mediaFile(dir, n)).slice(0, MAX_ATTACHMENTS) : [];
914
1041
  const prompt = referencedInput(body, attached, references);
@@ -919,13 +1046,17 @@ export class Hub {
919
1046
  if (!body && attached.length === 0 && references.length === 0) return;
920
1047
  const attachmentSnapshot = this.attachments(prompt.images);
921
1048
  const send = async (receipt?: (ref: SecurityReceipt) => void) => {
1049
+ this.requireVisibleAgent(convId);
922
1050
  if (receipt && prompt.images.some((name) => !dir || !mediaFile(dir, name))) throw new SecurityRuntimeError("dispatch_failed");
1051
+ // A message stored now would cancel the session replacement in progress (it admits no new user message).
1052
+ if (compact !== null && this.rt(convId).rotating) return this.refuseCompact(convId, COMPACT_REFUSED.session, receipt !== undefined);
923
1053
  const input = this.store.acceptMainUser(convId, {
924
1054
  text: body, src: source, trust, ...(attached.length ? { images: attached } : {}),
925
1055
  ...(references.length ? { references } : {}),
926
- }, receipt);
1056
+ }, receipt, compact !== null);
927
1057
  this.publishStored(input);
928
1058
  this.decisions?.refresh();
1059
+ if (compact !== null) return this.manualCompact(convId, input.seq, compact, receipt !== undefined);
929
1060
  const rotationDone = this.rt(convId).rotationDone;
930
1061
  if (rotationDone) await rotationDone;
931
1062
  if (this.closeUncertain(convId)) {
@@ -935,7 +1066,7 @@ export class Hub {
935
1066
  }
936
1067
  this.scheduleCloseRecovery(convId);
937
1068
  this.emit(convId, "notice", { kind: "session-close-recovery", level: "info",
938
- message: "세션 닫기 상태를 확인하고 있습니다. 메시지는 확인 후 전달합니다." });
1069
+ message: say(HUB_TEXT.closeChecking) });
939
1070
  return;
940
1071
  }
941
1072
  await this.prompt(convId, prompt.text, prompt.images, input.seq, true, attachmentSnapshot, receipt !== undefined);
@@ -992,7 +1123,7 @@ export class Hub {
992
1123
  const generation = this.generation;
993
1124
  const flight = this.injectWorkUpdates(convId, generation).catch((error) => {
994
1125
  if (this.omoStatus === "ready" && generation === this.generation) {
995
- this.emit(convId, "error", { message: `작업 결과 알림 실패: ${errorMessage(error)}` });
1126
+ this.emit(convId, "error", { message: say(HUB_TEXT.workNotifyFailed, { error: errorMessage(error) }) });
996
1127
  }
997
1128
  }).finally(() => {
998
1129
  if (rt.waking !== flight) return;
@@ -1054,7 +1185,7 @@ export class Hub {
1054
1185
  if (!acknowledged && generation === this.generation && this.omoStatus === "ready" && rt.wake === wake) {
1055
1186
  this.emit(convId, "notice", {
1056
1187
  kind: "work-input-receipt-unknown", level: "warning",
1057
- input_seq: input.seq, message: "작업 결과 알림의 수신을 확인하지 못했습니다. 결과 기록은 보존되어 있습니다.",
1188
+ input_seq: input.seq, message: say(HUB_TEXT.workReceiptUnknown),
1058
1189
  });
1059
1190
  }
1060
1191
  }
@@ -1099,7 +1230,7 @@ export class Hub {
1099
1230
  }
1100
1231
  } catch (error) {
1101
1232
  if (generation === this.generation && this.omoStatus === "ready") {
1102
- this.emit(conv, "error", { message: `작업 결과 알림 대조 실패: ${errorMessage(error)}` });
1233
+ this.emit(conv, "error", { message: say(HUB_TEXT.workReconcileFailed, { error: errorMessage(error) }) });
1103
1234
  }
1104
1235
  }
1105
1236
  if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId || rt.run !== run) return;
@@ -1119,7 +1250,7 @@ export class Hub {
1119
1250
  await this.reconcileWorkInput(pendingWake.input, sessionId, generation, true);
1120
1251
  } catch (error) {
1121
1252
  if (generation === this.generation && this.omoStatus === "ready") {
1122
- this.emit(conv, "error", { message: `작업 결과 알림 대조 실패: ${errorMessage(error)}` });
1253
+ this.emit(conv, "error", { message: say(HUB_TEXT.workReconcileFailed, { error: errorMessage(error) }) });
1123
1254
  }
1124
1255
  }
1125
1256
  if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId || rt.run !== run) return;
@@ -1180,8 +1311,7 @@ export class Hub {
1180
1311
  && (event.data as { input_seq?: number }).input_seq === input.seq).at(-1);
1181
1312
  if ((previous?.data as { kind?: string } | undefined)?.kind !== kind) this.emit(conv, "notice", {
1182
1313
  kind, input_seq: input.seq, level: "warning",
1183
- message: queued ? "메시지가 엔진 대기열에 남아 있습니다. 자동 재전송하지 않습니다."
1184
- : "메시지의 실행 소비를 확인하지 못했습니다. 기록을 보존하며 자동 재전송하지 않습니다.",
1314
+ message: say(queued ? HUB_TEXT.inputQueued : HUB_TEXT.inputConsumptionUnknown),
1185
1315
  });
1186
1316
  if (queued && data.native_identity && rt.atomicQueuedResume && state &&
1187
1317
  state.isStreaming === false && typeof state.queueEpoch === "string") {
@@ -1192,7 +1322,7 @@ export class Hub {
1192
1322
  sessionId, clientInputId: data.input_id, queueEpoch: state.queueEpoch,
1193
1323
  }).catch((error) => {
1194
1324
  if (generation === this.generation && this.omoStatus === "ready" && rt.sessionId === sessionId) {
1195
- this.emit(conv, "error", { message: `대기 입력 재개 확인 실패: ${errorMessage(error)}` });
1325
+ this.emit(conv, "error", { message: say(HUB_TEXT.queuedResumeFailed, { error: errorMessage(error) }) });
1196
1326
  }
1197
1327
  });
1198
1328
  }
@@ -1216,7 +1346,7 @@ export class Hub {
1216
1346
  }
1217
1347
  } catch (error) {
1218
1348
  if (generation === this.generation && this.omoStatus === "ready" && rt.sessionId === sessionId) {
1219
- this.emit(conv, "error", { message: `사용자 입력 대조 실패: ${errorMessage(error)}` });
1349
+ this.emit(conv, "error", { message: say(HUB_TEXT.userInputReconcileFailed, { error: errorMessage(error) }) });
1220
1350
  }
1221
1351
  } finally {
1222
1352
  clearTimeout(timer);
@@ -1243,6 +1373,7 @@ export class Hub {
1243
1373
  */
1244
1374
  async rotateSession(convId: string, reason: RotationReason): Promise<ConversationInfo> {
1245
1375
  if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
1376
+ this.requireVisibleAgent(convId);
1246
1377
  const conv = this.store.getConversation(convId);
1247
1378
  if (!conv) throw new Error(`unknown_conversation:${convId}`);
1248
1379
  const rt = this.rt(convId);
@@ -1258,7 +1389,7 @@ export class Hub {
1258
1389
  rt.rotating = true;
1259
1390
  rt.rotationDone = latch.promise;
1260
1391
  rt.finishRotation = latch.resolve;
1261
- this.emit(convId, "session.transition", { active: true, label: "세션 교체 중" }, false);
1392
+ this.emit(convId, "session.transition", { active: true, label: say(HUB_TEXT.sessionTransition) }, false);
1262
1393
  try {
1263
1394
  const { sessionId } = await this.ensureSession(conv, true);
1264
1395
  const readiness = await this.requireOmo().command<{
@@ -1382,7 +1513,7 @@ export class Hub {
1382
1513
  const from = conv.threadId;
1383
1514
  const sessionId = rt.sessionId;
1384
1515
  // Synchronous from the check above to the new thread, so no prompt or rotation interleaves; no latch is needed.
1385
- this.emit(convId, "session.transition", { active: true, label: "세션 교체 중" }, false);
1516
+ this.emit(convId, "session.transition", { active: true, label: say(HUB_TEXT.sessionTransition) }, false);
1386
1517
  try {
1387
1518
  if (sessionId) this.retireScopedQuestions(convId, sessionId);
1388
1519
  rt.running = false;
@@ -1425,14 +1556,17 @@ export class Hub {
1425
1556
  return this.options.rotation?.freshSessionCompactionFailures ?? FRESH_SESSION_COMPACTION_FAILURES;
1426
1557
  }
1427
1558
 
1428
- /** Bug 224: failed blocking compactions since the last completed one in this session, one per native request id. */
1559
+ /**
1560
+ * Bug 224: failed blocking compactions since the last completed one in this session, one per native request id.
1561
+ * Improvement 230: a failed manual summary (`/compact`) does not count.
1562
+ */
1429
1563
  private compactionFailuresInRow(conv: string): number {
1430
1564
  const failed = new Set<string>();
1431
1565
  for (const event of this.store.eventsAfter(conv, this.store.lastSeq(conv, "session.rotated")).reverse()) {
1432
1566
  if (event.type !== "session.compaction") continue;
1433
- const data = event.data as { status?: string; requestId?: unknown };
1567
+ const data = event.data as { status?: string; requestId?: unknown; reason?: unknown };
1434
1568
  if (data.status === "completed") break;
1435
- if (data.status === "failed" && typeof data.requestId === "string") failed.add(data.requestId);
1569
+ if (data.status === "failed" && data.reason !== "manual" && typeof data.requestId === "string") failed.add(data.requestId);
1436
1570
  }
1437
1571
  return failed.size;
1438
1572
  }
@@ -1474,8 +1608,8 @@ export class Hub {
1474
1608
  this.emit(convId, "conversation.thread", { threadId: to });
1475
1609
  this.emit(convId, "session.rotated", { reason: retirement.reason, from: retirement.from, to });
1476
1610
  this.emit(convId, "notice", { kind: "session-rotated", level: "info",
1477
- message: `새 엔진 세션으로 교체했습니다 (${this.reasonLabel(retirement.reason)}). 화면의 대화 기록은 그대로이고, 다음 메시지부터 새 세션에서 이어집니다.${
1478
- retirement.reason === "compaction_failed" ? " 이전 세션 파일은 그대로 남겨 두었고, 받지 못한 메시지는 자동으로 다시 보내지 않습니다." : ""}` });
1611
+ message: say(HUB_TEXT.sessionRotated, { reason: this.reasonLabel(retirement.reason, serverLanguage()),
1612
+ extra: retirement.reason === "compaction_failed" ? say(HUB_TEXT.rotatedAfterFailure) : "" }) });
1479
1613
  }
1480
1614
 
1481
1615
  private closeTimer(callback: () => void, ms: number): ReturnType<typeof setTimeout> {
@@ -1545,14 +1679,14 @@ export class Hub {
1545
1679
  this.emit(convId, "session.close.resolved", { sessionId: data.sessionId, sessionPath: data.sessionPath,
1546
1680
  outcome: present ? "retained" : "superseded", attempts: recovery.attempts });
1547
1681
  this.emit(convId, "notice", { kind: "session-close-resolved", level: "info",
1548
- message: "세션 닫기 상태를 확인했습니다. 기존 대화에서 메시지 처리를 계속합니다." });
1682
+ message: say(HUB_TEXT.closeResolved) });
1549
1683
  }
1550
1684
  this.closeRecoveries.delete(convId);
1551
1685
  } catch (error) {
1552
1686
  if (generation !== this.generation) return;
1553
1687
  if (recovery.attempts >= (this.options.closeRecovery?.maxAttempts ?? 3)) {
1554
1688
  recovery.exhausted = true;
1555
- this.emit(convId, "error", { message: `session_close_recovery_exhausted: ${errorMessage(error)}. 메시지는 보관 중입니다. 엔진 연결과 세션 목록을 확인한 뒤 게이트웨이를 다시 시작하세요.` });
1689
+ this.emit(convId, "error", { message: say(HUB_TEXT.closeRecoveryExhausted, { error: errorMessage(error) }) });
1556
1690
  }
1557
1691
  } finally {
1558
1692
  if (timeout !== undefined) this.clearCloseTimer(timeout);
@@ -1715,7 +1849,7 @@ export class Hub {
1715
1849
  this.legacyQuestionHistoryAliases.delete(id);
1716
1850
  this.emit(conv, "action.resolved", {
1717
1851
  actionId: historyActionId,
1718
- answer: { kind: "cancel", reason: "세션 교체로 닫힌 질문입니다. 받은편지함에서 답하면 새 세션으로 전달됩니다." },
1852
+ answer: { kind: "cancel", reason: say(HUB_TEXT.rotationQuestionClosed) },
1719
1853
  by: "cancelled",
1720
1854
  });
1721
1855
  }
@@ -1748,12 +1882,13 @@ export class Hub {
1748
1882
  );
1749
1883
  }
1750
1884
 
1751
- private reasonLabel(reason: RotationReason): string {
1885
+ /** `lang`: the UI language for the user notice, "ko" for the engine context (engine-facing text stays Korean). */
1886
+ private reasonLabel(reason: RotationReason, lang: Language): string {
1752
1887
  const hours = Math.round((this.options.rotation?.maxAgeMs ?? ROTATE_MAX_AGE_MS) / 3_600_000);
1753
- if (reason === "size") return `세션 파일이 ${Math.round((this.options.rotation?.forceMaxBytes ?? FORCE_ROTATE_MAX_BYTES) / 1024 / 1024)}MB에 닿음`;
1754
- if (reason === "compactions") return `압축이 ${this.options.rotation?.forceUpdateCompactions ?? FORCE_ROTATE_UPDATE_COMPACTIONS}번 더 이어짐`;
1755
- if (reason === "compaction_failed") return `대화 요약이 ${this.freshSessionCompactionFailures()}번 연속 실패함`;
1756
- return reason === "manual" ? "직접 요청" : `세션이 ${hours}시간을 넘겨 쉬는 중`;
1888
+ if (reason === "size") return textIn(lang, ROTATION_REASON.size, { mb: Math.round((this.options.rotation?.forceMaxBytes ?? FORCE_ROTATE_MAX_BYTES) / 1024 / 1024) });
1889
+ if (reason === "compactions") return textIn(lang, ROTATION_REASON.compactions, { count: this.options.rotation?.forceUpdateCompactions ?? FORCE_ROTATE_UPDATE_COMPACTIONS });
1890
+ if (reason === "compaction_failed") return textIn(lang, ROTATION_REASON.compaction_failed, { count: this.freshSessionCompactionFailures() });
1891
+ return reason === "manual" ? textIn(lang, ROTATION_REASON.manual) : textIn(lang, ROTATION_REASON.age, { hours });
1757
1892
  }
1758
1893
 
1759
1894
  /** Recent user/assistant lines from the gateway's own history, newest kept, within HANDOFF_MAX_CHARS. */
@@ -1774,7 +1909,7 @@ export class Hub {
1774
1909
  lines.unshift(line);
1775
1910
  }
1776
1911
  return [
1777
- `[게이트웨이 알림] 이 대화는 새 엔진 세션으로 교체됐습니다 (${this.reasonLabel(reason)}).`,
1912
+ `[게이트웨이 알림] 이 대화는 새 엔진 세션으로 교체됐습니다 (${this.reasonLabel(reason, "ko")}).`,
1778
1913
  from ? `이전 세션의 전체 기록은 ${from} 에 남아 있습니다. 파일이 크니 필요할 때 끝부분이나 필요한 부분만 검색해서 보고, 전체를 읽지 마세요.` : "",
1779
1914
  ...this.handoffOpenItems(conv),
1780
1915
  "아래는 직전 대화의 최근 부분입니다. 맥락으로만 참고하세요.",
@@ -1788,6 +1923,7 @@ export class Hub {
1788
1923
 
1789
1924
  /** Sends input once. Native same-session compaction owns overflow recovery, never gateway replay. */
1790
1925
  private async prompt(convId: string, body: string, images: string[] = [], messageSeq?: number, admitted = false, attachmentSnapshot?: ReturnType<Hub["attachments"]>, protectedPayload = false): Promise<void> {
1926
+ this.requireVisibleAgent(convId);
1791
1927
  const conv = this.store.getConversation(convId);
1792
1928
  if (!conv) return;
1793
1929
  const rt = this.rt(convId);
@@ -1853,8 +1989,9 @@ export class Hub {
1853
1989
  if (refused && input) this.emit(convId, "main.input.handled", { input_seq: input.seq, reason: "prompt_rejected" });
1854
1990
  if (protectedPayload) throw new SecurityRuntimeError("dispatch_unknown");
1855
1991
  if (retired) return;
1992
+ if (error instanceof AgentSessionError) return; // The definition failure was already persisted and broadcast.
1856
1993
  const message = errorMessage(error);
1857
- this.emit(convId, "error", refused ? { kind: "compaction-required", message: COMPACTION_REQUIRED_MESSAGE } : this.continuityError(message));
1994
+ this.emit(convId, "error", refused ? { kind: "compaction-required", message: say(COMPACTION_REQUIRED_MESSAGE) } : this.continuityError(message));
1858
1995
  } finally {
1859
1996
  releaseAdmission();
1860
1997
  }
@@ -1864,7 +2001,7 @@ export class Hub {
1864
2001
  if (!CONTEXT_OVERFLOW.test(message)) return { message };
1865
2002
  return {
1866
2003
  kind: "continuity-protected",
1867
- message: `${message}. 같은 엔진 세션을 유지합니다. 원인을 해결한 뒤 계속할 지시를 보내 주세요. 이전 입력은 자동 재전송하지 않습니다.`,
2004
+ message: say(HUB_TEXT.continuityProtected, { message }),
1868
2005
  };
1869
2006
  }
1870
2007
 
@@ -1890,20 +2027,91 @@ export class Hub {
1890
2027
  const rt = this.rt(convId);
1891
2028
  if (!rt.sessionId || !rt.running) return;
1892
2029
  await this.requireOmo().command("abort", { sessionId: rt.sessionId });
1893
- this.dialogs.cancelScope(this.conversationScope(convId), "사용자가 작업을 중단했습니다.");
2030
+ this.dialogs.cancelScope(this.conversationScope(convId), say(HUB_TEXT.userStopped));
2031
+ }
2032
+
2033
+ /**
2034
+ * Improvement 230: `/compact` asks the engine to summarize this conversation's session now. The message stays in the
2035
+ * history and its input is closed before the RPC, so recovery never sends it. While an answer runs, a summary runs, a
2036
+ * retry waits or the session is being replaced it is refused with a notice and never queued. The engine's own
2037
+ * compaction_start/compaction_end events carry the usual start and terminal notices; this returns once the RPC is sent.
2038
+ */
2039
+ private async manualCompact(convId: string, messageSeq: number, instructions: string, protectedPayload: boolean): Promise<void> {
2040
+ const conv = this.store.getConversation(convId);
2041
+ if (!conv) return;
2042
+ const rt = this.rt(convId);
2043
+ const refuse = (message: Text) => this.refuseCompact(convId, message, protectedPayload);
2044
+ const held = () => rt.rotating || this.closeUncertain(convId) ? COMPACT_REFUSED.session
2045
+ : this.manualCompactions.has(convId) ? COMPACT_REFUSED.compacting
2046
+ : rt.running || rt.promptAdmissions.size > 0 || rt.waking !== null || this.store.pendingMainUsers(convId).length > 0
2047
+ ? COMPACT_REFUSED.answering : null;
2048
+ const early = this.omoStatus !== "ready" ? COMPACT_REFUSED.engine : held();
2049
+ if (early) return refuse(early);
2050
+ const generation = this.generation;
2051
+ let sessionId: string;
2052
+ let state: { isStreaming?: boolean; isCompacting?: boolean; isBashRunning?: boolean; pendingMessageCount?: number; retryAttempt?: number };
2053
+ try {
2054
+ ({ sessionId } = await this.ensureSession(conv));
2055
+ state = await this.requireOmo().command("get_state", { sessionId });
2056
+ } catch (error) {
2057
+ console.error(`manual compaction state: ${errorMessage(error)}`);
2058
+ return refuse(COMPACT_REFUSED.engine);
2059
+ }
2060
+ if (this.omoStatus !== "ready") return refuse(COMPACT_REFUSED.engine);
2061
+ if (generation !== this.generation || rt.sessionId !== sessionId) return refuse(COMPACT_REFUSED.session);
2062
+ const busy = held() ?? (state.isCompacting ? COMPACT_REFUSED.compacting
2063
+ : (state.retryAttempt ?? 0) > 0 ? COMPACT_REFUSED.retrying
2064
+ : state.isStreaming || state.isBashRunning || (state.pendingMessageCount ?? 0) > 0 ? COMPACT_REFUSED.answering : null);
2065
+ if (busy) return refuse(busy);
2066
+ this.manualCompactions.add(convId);
2067
+ const input = this.emit(convId, "main.input", { origin: "user", input_ref: { message_seq: messageSeq },
2068
+ session_path: this.store.getConversation(convId)?.threadId, handoff: null });
2069
+ if (input) {
2070
+ this.store.ackMainInput(input);
2071
+ this.emit(convId, "main.input.handled", { input_seq: input.seq, reason: "compact" });
2072
+ }
2073
+ const before = this.store.lastSeq(convId, "session.compaction");
2074
+ void this.requireOmo().command("compact", { sessionId, ...(instructions ? { customInstructions: instructions } : {}) })
2075
+ .catch(async (error) => {
2076
+ if (generation !== this.generation || this.rt(convId).sessionId !== sessionId) return;
2077
+ // A summary that started reports its own terminal notice and failure log line (bug 223).
2078
+ const reported = this.store.eventsAfter(convId, before).some((event) => event.type === "session.compaction" &&
2079
+ (event.data as { status?: string }).status !== "running");
2080
+ if (reported) return;
2081
+ if (!this.providerErrorChecker) this.providerErrorChecker = (await import("./security-check.ts")).defaultChecker();
2082
+ console.error("manual_compaction_failed", JSON.stringify({ error: sanitizeProviderError(errorMessage(error), this.providerErrorChecker) ?? "unknown" }));
2083
+ this.emit(convId, "error", { kind: "compaction-failed", action: "retry", message: compactNotReported() });
2084
+ })
2085
+ .catch((error) => console.error(`manual compaction: ${errorMessage(error)}`))
2086
+ .finally(() => this.manualCompactions.delete(convId));
2087
+ }
2088
+
2089
+ /** A security hold that released this `/compact` ends as failed (nothing was sent), not as an unconfirmed send. */
2090
+ private refuseCompact(convId: string, message: Text, protectedPayload: boolean): void {
2091
+ this.emit(convId, "notice", { kind: "compact-refused", level: "warning", message: say(message) });
2092
+ if (protectedPayload) throw new SecurityRuntimeError("dispatch_failed");
1894
2093
  }
1895
2094
 
2095
+
1896
2096
  /** omo's slash commands and skills for the conversation's session, trimmed to what the input menu shows. */
1897
2097
  async commands(convId: string): Promise<SlashCommand[]> {
1898
2098
  const conv = this.store.getConversation(convId);
1899
2099
  if (!conv) throw new Error(`unknown_conversation:${convId}`);
1900
2100
  const { sessionId } = await this.ensureSession(conv);
1901
2101
  const { commands } = await this.requireOmo().command<{ commands: { name: string; description?: string; source: string }[] }>("get_commands", { sessionId });
1902
- const listed = commands.map((c) => ({ name: c.name, description: c.description ?? "", source: c.source }));
1903
- const own = [...(this.options.versionReport ? [VERSION_COMMAND] : []), ...(this.options.usageReport ? [USAGE_COMMAND] : [])];
2102
+ const visible = conv.agentId ? allowedAgentCommands(commands, this.agentSkills(conv.agentId)) : commands;
2103
+ const listed = visible.map((c) => ({ name: c.name, description: c.description ?? "", source: c.source }));
2104
+ const own = [...(this.options.versionReport ? [VERSION_COMMAND] : []), ...(this.options.usageReport ? [USAGE_COMMAND] : []), COMPACT_COMMAND];
1904
2105
  return [...listed.filter((c) => !own.some((command) => command.name === c.name)), ...own];
1905
2106
  }
1906
2107
 
2108
+ /** The agent's skills allowlist (D4.2, merged definition `skills`); the extension limits the session to the same set. */
2109
+ private agentSkills(agentId: string): ReadonlySet<string> {
2110
+ const files = this.options.agents ?? new AgentFileStore();
2111
+ const skills = mergeAgentDefinition(files.read(agentId), files.readDefaults()).fields.skills;
2112
+ return new Set(Array.isArray(skills) ? skills.filter((skill): skill is string => typeof skill === "string") : []);
2113
+ }
2114
+
1907
2115
  answerAction(data: ActionAnswerData, conv?: string): boolean {
1908
2116
  if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
1909
2117
  if (this.decisions) {
@@ -2031,9 +2239,9 @@ export class Hub {
2031
2239
  ? { engineTurn: { id: rt.statsTurnId, sessionId: rt.durableId, messageTimestamp: message.timestamp, messageIndex: rt.statsMessageIndex } } : {}),
2032
2240
  });
2033
2241
  if (message.stopReason === "aborted") {
2034
- this.emit(conv, "notice", { message: "작업을 중단했습니다.", level: "info" });
2242
+ this.emit(conv, "notice", { message: say(HUB_TEXT.stopped), level: "info" });
2035
2243
  } else if (message.stopReason === "error" || message.errorMessage) {
2036
- this.emit(conv, "error", this.continuityError(message.errorMessage ?? "모델 응답 오류"));
2244
+ this.emit(conv, "error", this.continuityError(message.errorMessage ?? say(HUB_TEXT.modelResponseError)));
2037
2245
  }
2038
2246
  rt.itemId = null;
2039
2247
  rt.itemText = "";
@@ -2064,9 +2272,9 @@ export class Hub {
2064
2272
  case "compaction_start": {
2065
2273
  if (!blockingCompaction(event) || this.compactionSeen(conv, event, false)) return;
2066
2274
  const requestId = typeof event.requestId === "string" ? event.requestId : undefined;
2067
- this.emit(conv, "session.compaction", { status: "running", requestId });
2275
+ this.emit(conv, "session.compaction", { status: "running", requestId, ...(event.reason === "manual" ? { reason: "manual" } : {}) });
2068
2276
  this.emit(conv, "notice", { kind: "compaction-started", level: "info", requestId,
2069
- message: "대화 내용을 요약하고 있습니다. 완료될 때까지 기다려 주세요." });
2277
+ message: say(HUB_TEXT.compactionStarted) });
2070
2278
  return;
2071
2279
  }
2072
2280
  case "compaction_end": {
@@ -2075,16 +2283,17 @@ export class Hub {
2075
2283
  const outcome = compactionOutcome(event);
2076
2284
  this.emit(conv, "session.compaction", {
2077
2285
  ...outcome, requestId, accepted: outcome.status === "completed",
2286
+ ...(event.reason === "manual" ? { reason: "manual" } : {}),
2078
2287
  });
2079
2288
  switch (outcome.status) {
2080
2289
  case "cancelled":
2081
2290
  this.emit(conv, "notice", { kind: "compaction-cancelled", level: "warning", requestId,
2082
- message: "대화 요약을 취소했습니다. 이전 내용과 같은 세션을 유지합니다. 이전 입력은 자동으로 다시 보내지 않습니다." });
2291
+ message: say(HUB_TEXT.compactionCancelled) });
2083
2292
  break;
2084
2293
  case "failed": {
2085
2294
  const recovery = COMPACTION_RECOVERY[outcome.failureKind];
2086
2295
  this.emit(conv, "error", { kind: "compaction-failed", requestId, failureKind: outcome.failureKind,
2087
- action: recovery.action, message: `대화 요약에 실패했습니다. 이전 내용은 유지했습니다. ${recovery.message}` });
2296
+ action: recovery.action, message: say(COMPACTION_FAILED, { recovery: say(recovery.message) }) });
2088
2297
  void this.logCompactionFailure(outcome.failureKind, event.failure)
2089
2298
  .catch((error) => console.error(`compaction failure log: ${errorMessage(error)}`));
2090
2299
  if (this.compactionFailuresInRow(conv) >= this.freshSessionCompactionFailures()) this.openFreshSession(conv);
@@ -2092,7 +2301,7 @@ export class Hub {
2092
2301
  }
2093
2302
  case "completed":
2094
2303
  this.emit(conv, "notice", { kind: "compaction-completed", level: "info", requestId,
2095
- message: "대화 요약을 완료했습니다. 같은 세션에서 계속합니다." });
2304
+ message: say(HUB_TEXT.compactionCompleted) });
2096
2305
  break;
2097
2306
  }
2098
2307
  return;
@@ -2103,7 +2312,7 @@ export class Hub {
2103
2312
  rt.statsMessageIndex = 0;
2104
2313
  this.publishActivity(conv);
2105
2314
  this.unwatchConversation(conv);
2106
- this.dialogs.cancelScope(owner.scope, "작업이 끝났습니다.");
2315
+ this.dialogs.cancelScope(owner.scope, say(HUB_TEXT.turnEnded));
2107
2316
  if (this.decisions) {
2108
2317
  void this.reconcileDecisionMain(conv)
2109
2318
  .then(() => this.decisions?.settled(owner.scope))
@@ -2180,7 +2389,7 @@ export class Hub {
2180
2389
  legacyActionIds: [requestId],
2181
2390
  method: method as DialogMethod,
2182
2391
  permission,
2183
- title: permission ? `권한 요청: ${firstLine?.slice(PERMISSION_PREFIX.length).trim() ?? ""}` : (firstLine ?? ""),
2392
+ title: permission ? say(HUB_TEXT.permissionTitle, { tool: firstLine?.slice(PERMISSION_PREFIX.length).trim() ?? "" }) : (firstLine ?? ""),
2184
2393
  detail: detailParts.filter(Boolean).join("\n\n"),
2185
2394
  options: Array.isArray(event.options) ? (event.options as unknown[]).map(String) : [],
2186
2395
  },
@@ -2292,12 +2501,12 @@ export class Hub {
2292
2501
  this.questions.delete(id);
2293
2502
  const historyActionId = this.legacyQuestionHistoryAliases.get(id) ?? id;
2294
2503
  this.legacyQuestionHistoryAliases.delete(id);
2295
- this.emit(owner.conv, "action.resolved", { actionId: historyActionId, answer: { kind: "cancel", reason: "게이트웨이가 꺼져 있던 동안 끝난 질문입니다." }, by: "cancelled" });
2504
+ this.emit(owner.conv, "action.resolved", { actionId: historyActionId, answer: { kind: "cancel", reason: say(HUB_TEXT.endedWhileOff) }, by: "cancelled" });
2296
2505
  }
2297
2506
  for (const id of this.openQuestionCards(owner.conv, owner.scope)) {
2298
2507
  if (live.has(id)) continue;
2299
2508
  if (this.store.getDecisionByAlias(owner.scope, id)?.sourceRequestId.startsWith("legacy:")) continue;
2300
- this.emit(owner.conv, "action.resolved", { actionId: id, answer: { kind: "cancel", reason: "게이트웨이가 꺼져 있던 동안 끝난 질문입니다." }, by: "cancelled" });
2509
+ this.emit(owner.conv, "action.resolved", { actionId: id, answer: { kind: "cancel", reason: say(HUB_TEXT.endedWhileOff) }, by: "cancelled" });
2301
2510
  }
2302
2511
  }
2303
2512
 
@@ -2431,7 +2640,7 @@ export class Hub {
2431
2640
  const recovered = this.emitMissedAnswers(conv.id, assistantTexts(currentEntries));
2432
2641
  if (!this.hasUnfinishedTurn(conv.id)) return;
2433
2642
  if (rt.running) {
2434
- this.emit(conv.id, "notice", { kind: "reattached", level: "info", message: `게이트웨이가 다시 연결돼 진행 중인 작업을 이어서 받습니다.${recoveredNote(recovered)}` });
2643
+ this.emit(conv.id, "notice", { kind: "reattached", level: "info", message: say(HUB_TEXT.reattached, { recovered: recoveredNote(recovered) }) });
2435
2644
  return;
2436
2645
  }
2437
2646
  if (turn.source.origin === "work_update") {
@@ -2453,12 +2662,12 @@ export class Hub {
2453
2662
  this.emit(conv.id, "notice", {
2454
2663
  kind: "user-input-receipt-unknown", level: "warning",
2455
2664
  message_seq: turn.source.input_ref.message_seq,
2456
- message: "메시지 전송 이후 수신을 확인하지 못했습니다. 중복 실행을 피하기 위해 자동 재전송하지 않습니다.",
2665
+ message: say(HUB_TEXT.sendReceiptUnknown),
2457
2666
  });
2458
2667
  return;
2459
2668
  }
2460
2669
  if (turnFinished(currentEntries)) {
2461
- this.emit(conv.id, "notice", { kind: "recovered", level: "info", message: `게이트웨이가 꺼져 있던 동안 끝난 작업 결과를 가져왔습니다.${recoveredNote(recovered)}` });
2670
+ this.emit(conv.id, "notice", { kind: "recovered", level: "info", message: say(HUB_TEXT.recovered, { recovered: recoveredNote(recovered) }) });
2462
2671
  this.completeTurn(conv.id, "completed");
2463
2672
  rt.wake?.release?.();
2464
2673
  rt.wake = null;
@@ -2468,7 +2677,7 @@ export class Hub {
2468
2677
  .eventsAfter(conv.id, this.store.lastSeq(conv.id, "message.user"))
2469
2678
  .filter((e) => e.type === "notice" && (e.data as { kind?: string }).kind === "auto-continue").length;
2470
2679
  if (tries >= AUTO_CONTINUE_LIMIT) {
2471
- this.emit(conv.id, "notice", { kind: "interrupted", level: "warning", message: `작업이 끊겨 자동으로 ${tries}번 이어 보냈지만 끝나지 않았습니다. 확인 후 다시 지시해 주세요.` });
2680
+ this.emit(conv.id, "notice", { kind: "interrupted", level: "warning", message: say(HUB_TEXT.interrupted, { tries }) });
2472
2681
  this.completeTurn(conv.id, "interrupted");
2473
2682
  rt.wake?.release?.();
2474
2683
  rt.wake = null;
@@ -2478,7 +2687,7 @@ export class Hub {
2478
2687
  || currentEntries.some((e) => e.type === "message" && e.role === "user");
2479
2688
  const lastUser = this.store.getEvent(turn.source.input_ref.message_seq)?.data as { text?: string; images?: string[] } | undefined;
2480
2689
  const message = promptReached || !lastUser?.text ? CONTINUE_PROMPT : lastUser.text;
2481
- this.emit(conv.id, "notice", { kind: "auto-continue", level: "warning", message: `재시작으로 끊긴 작업을 이어서 진행합니다 (${tries + 1}/${AUTO_CONTINUE_LIMIT}).${recoveredNote(recovered)}` });
2690
+ this.emit(conv.id, "notice", { kind: "auto-continue", level: "warning", message: say(HUB_TEXT.autoContinue, { attempt: tries + 1, limit: AUTO_CONTINUE_LIMIT, recovered: recoveredNote(recovered) }) });
2482
2691
  await this.prompt(conv.id, message, promptReached ? [] : lastUser?.images ?? [], turn.source.input_ref.message_seq);
2483
2692
  }
2484
2693
 
@@ -2486,6 +2695,14 @@ export class Hub {
2486
2695
  return this.store.mainTurn(conv) !== null || this.store.pendingMainUsers(conv).length > 0;
2487
2696
  }
2488
2697
 
2698
+ private requireVisibleAgent(convId: string): void {
2699
+ if (this.deletingAgents.has(convId)) throw new Error("agent_busy");
2700
+ const conv = this.store.getConversation(convId);
2701
+ if (conv?.agentId && !this.store.listConversations(false).some((visible) => visible.id === convId)) {
2702
+ throw new Error("agent_hidden");
2703
+ }
2704
+ }
2705
+
2489
2706
  private completeTurn(conv: string, status: "completed" | "interrupted", pendingInputSeqs: number[] = []): void {
2490
2707
  const turn = this.store.mainTurn(conv);
2491
2708
  const source = this.store.mainConsumedSource(conv);
@@ -2653,6 +2870,29 @@ export class Hub {
2653
2870
  private openSession(conv: ConversationInfo) {
2654
2871
  const storedPath = conv.threadId?.startsWith("/") ? conv.threadId : null;
2655
2872
  const sessionPath = storedPath ?? `${this.options.sessionDir.replace(/\/$/, "")}/zgw-${conv.id}.jsonl`;
2873
+ let agent: MergedAgentDefinition | undefined;
2874
+ let cwd = this.options.cwd;
2875
+ let model = this.savedModel();
2876
+ if (conv.agentId) {
2877
+ try {
2878
+ const files = this.options.agents ?? new AgentFileStore();
2879
+ agent = mergeAgentDefinition(files.read(conv.agentId), files.readDefaults());
2880
+ cwd = join(files.root, conv.agentId, "knowledge");
2881
+ const selected = agent.fields.model;
2882
+ if (selected !== undefined) {
2883
+ if (typeof selected !== "object" || selected === null ||
2884
+ !("provider" in selected) || typeof selected.provider !== "string" || !selected.provider.trim() ||
2885
+ !("modelId" in selected) || typeof selected.modelId !== "string" || !selected.modelId.trim()) {
2886
+ throw new AgentDefinitionError("model", "provider and modelId required");
2887
+ }
2888
+ model = { provider: selected.provider, modelId: selected.modelId };
2889
+ }
2890
+ } catch {
2891
+ // File/parser errors can contain private paths or definition text. Publish only the stable code.
2892
+ this.emit(conv.id, "error", { kind: "agent-definition", message: "agent_definition_invalid" });
2893
+ throw new AgentSessionError();
2894
+ }
2895
+ }
2656
2896
  const opened = this.requireOmo().command<{ sessionId: string; attached?: boolean; state?: {
2657
2897
  isStreaming?: boolean; pendingQuestions?: ScopedQuestionFrame[];
2658
2898
  clientInputIdentity?: boolean; atomicQueuedResume?: boolean;
@@ -2660,15 +2900,18 @@ export class Hub {
2660
2900
  } }>("open_session", {
2661
2901
  sessionPath,
2662
2902
  // Native ignores creation defaults when the session file already exists.
2663
- ...(this.savedModel() ?? {}),
2664
- cwd: this.options.cwd,
2903
+ ...(model ?? {}),
2904
+ ...(typeof agent?.fields.thinking === "string" ? { thinkingLevel: agent.fields.thinking } : {}),
2905
+ cwd,
2665
2906
  permissionPreset: this.options.permissionPreset,
2666
2907
  retain_on_disconnect: true,
2667
2908
  kind: "interactive",
2668
2909
  promptSurface: "app",
2669
- context: { zgw_conv: conv.id, zgw_role: "main" },
2910
+ context: conv.agentId
2911
+ ? { zgw_conv: conv.id, zgw_role: "agent", zgw_agent: conv.agentId }
2912
+ : { zgw_conv: conv.id, zgw_role: "main" },
2670
2913
  });
2671
- return { sessionPath, opened };
2914
+ return { sessionPath, opened, agent };
2672
2915
  }
2673
2916
 
2674
2917
  private watchAsk(conv: string, toolCallId: string, args: unknown): void {
@@ -2713,6 +2956,7 @@ export class Hub {
2713
2956
 
2714
2957
  private async ensureSession(conv: ConversationInfo, duringRotation = false): Promise<OpenResult> {
2715
2958
  if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
2959
+ if (!duringRotation) this.requireVisibleAgent(conv.id);
2716
2960
  if (this.omoStatus !== "ready") throw new Error("omo_not_ready");
2717
2961
  if (this.closeUncertain(conv.id)) throw new Error("session_close_unknown: source retained; verify native closure before retry");
2718
2962
  const rt = this.rt(conv.id);
@@ -2720,9 +2964,9 @@ export class Hub {
2720
2964
  if (rt.sessionId) return { sessionId: rt.sessionId, streaming: rt.running };
2721
2965
  if (rt.opening) return rt.opening;
2722
2966
  const generation = this.generation;
2723
- const { sessionPath, opened } = this.openSession(conv);
2967
+ const { sessionPath, opened, agent } = this.openSession(conv);
2724
2968
  const opening = opened
2725
- .then((data) => {
2969
+ .then(async (data) => {
2726
2970
  if (generation !== this.generation || this.omoStatus !== "ready") throw new Error("stale_open_session");
2727
2971
  rt.sessionId = data.sessionId;
2728
2972
  rt.durableId = typeof data.state?.sessionId === "string" ? data.state.sessionId : null;
@@ -2736,6 +2980,20 @@ export class Hub {
2736
2980
  }
2737
2981
  const owner = { conv: conv.id, scope: this.conversationScope(conv.id), sessionId: data.sessionId, sessionPath };
2738
2982
  if (!this.bindScopedUiOwner(owner)) throw new Error("omo_owner_not_ready");
2983
+ if (agent && data.attached !== true) {
2984
+ // Notification policy is a session snapshot too; edits must not change a live session.
2985
+ this.emit(conv.id, "agent.applied", { sha: agent.sha, at: new Date(this.now()).toISOString(), notify: agent.fields.notify === true });
2986
+ let warnings: { code: string; message: string }[] = [];
2987
+ try {
2988
+ const result = await this.requireOmo().command<{ warnings?: { code: string; message: string }[] } | null>(
2989
+ "extension_request", { sessionId: data.sessionId, name: "zgw.agent_warnings" });
2990
+ if (Array.isArray(result?.warnings)) warnings = result.warnings;
2991
+ else console.error(`agent startup warnings failed (${conv.id}): invalid warnings response`);
2992
+ } catch (error) {
2993
+ console.error(`agent startup warnings failed (${conv.id}): ${errorMessage(error)}`);
2994
+ }
2995
+ for (const warning of warnings) this.emit(conv.id, "notice", { kind: "agent-warning", level: "warning", ...warning });
2996
+ }
2739
2997
  this.syncScopedQuestions(owner, data.state?.pendingQuestions ?? []);
2740
2998
  this.publishActivity(conv.id);
2741
2999
  return { sessionId: data.sessionId, streaming: data.state?.isStreaming === true };
@@ -2812,9 +3070,9 @@ function unreachableQuestionText(args: unknown): string {
2812
3070
  const questions = ((args as { questions?: unknown } | undefined)?.questions ?? []) as Partial<QuestionItem>[];
2813
3071
  const lines = questions.map((q) => {
2814
3072
  const options = (q.options ?? []).map((o) => o.label).join(" / ");
2815
- return `- ${q.header ? `[${q.header}] ` : ""}${q.question ?? ""}${q.multiSelect ? " (여러 개 선택 가능)" : ""}${options ? `: ${options}` : ""}`;
3073
+ return `- ${q.header ? `[${q.header}] ` : ""}${q.question ?? ""}${q.multiSelect ? say(HUB_TEXT.multiSelect) : ""}${options ? `: ${options}` : ""}`;
2816
3074
  });
2817
- return ["질문 창이 웹에 도착하지 않았습니다. 중단 버튼을 누른 뒤 채팅으로 답해 주세요.", ...lines].join("\n");
3075
+ return [say(HUB_TEXT.questionUnreachable), ...lines].join("\n");
2818
3076
  }
2819
3077
 
2820
3078
  /** 2026-09-26T15:43:01.123Z -> 20260926T154301Z */
@@ -2885,8 +3143,10 @@ function turnFinished(entries: readonly LedgerEntry[]): boolean {
2885
3143
  return last?.role === "assistant" && last.stopReason === "stop";
2886
3144
  }
2887
3145
 
2888
- function recoveredNote(count: number): string {
2889
- return count > 0 ? ` 놓친 답변 ${count}개를 채웠습니다.` : "";
3146
+ /** The sentence added to a recovery notice for answers the gateway filled in while it was away (exported for tests). */
3147
+ export function recoveredNote(count: number): string {
3148
+ if (count <= 0) return "";
3149
+ return count === 1 ? say(HUB_TEXT.recoveredOne) : say(HUB_TEXT.recoveredMany, { count });
2890
3150
  }
2891
3151
 
2892
3152
  function errorMessage(error: unknown): string {