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

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 (145) hide show
  1. package/README.ko.md +35 -5
  2. package/README.md +35 -5
  3. package/assets/agent/gateway-rules.md +1 -1
  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 +51 -11
  8. package/docs/manual.md +52 -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/model-control.ts +15 -3
  13. package/host/omo-bridge/task-control.ts +26 -16
  14. package/host/omo-check/patches/omo-5.1.48-compaction-recovery.patch +912 -0
  15. package/host/omo-check/patches/omo-5.1.48-gateway-port.manifest.json +12 -11
  16. package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
  17. package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
  18. package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
  19. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
  20. package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
  21. package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
  22. package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
  23. package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
  24. package/package.json +3 -2
  25. package/src/agent/api.ts +215 -0
  26. package/src/agent/definition.ts +177 -0
  27. package/src/agent/fields.ts +42 -0
  28. package/src/agent/files.ts +192 -0
  29. package/src/agent/legacy-files.ts +46 -0
  30. package/src/agent/list.ts +21 -0
  31. package/src/agent-rules.ts +14 -5
  32. package/src/approvals.ts +6 -1
  33. package/src/cli/main.ts +33 -0
  34. package/src/cli/messages.ts +10 -2
  35. package/src/cli/rotate-main.ts +2 -1
  36. package/src/cli/scan.ts +414 -0
  37. package/src/cli/security-import.ts +155 -0
  38. package/src/compact.ts +22 -0
  39. package/src/decisions.ts +6 -4
  40. package/src/engine/compat.json +3 -3
  41. package/src/engine/index.ts +1 -0
  42. package/src/engine/schema-guard.ts +12 -4
  43. package/src/engine/sdk.ts +8 -0
  44. package/src/formal-verification.ts +366 -0
  45. package/src/gc.ts +37 -6
  46. package/src/hub.ts +525 -85
  47. package/src/main.ts +95 -9
  48. package/src/maintenance.ts +1 -1
  49. package/src/manual.ts +9 -2
  50. package/src/omo/client.ts +2 -0
  51. package/src/profile-prompt.ts +73 -0
  52. package/src/profile.ts +33 -10
  53. package/src/protocol.ts +11 -2
  54. package/src/question-notifications.ts +4 -1
  55. package/src/reply-notifications.ts +7 -1
  56. package/src/repo-security-setting.ts +33 -0
  57. package/src/runtime/paths.ts +16 -0
  58. package/src/security/baseline.ts +170 -0
  59. package/src/security/dex-strings.ts +242 -0
  60. package/src/security/repo-scan.ts +369 -0
  61. package/src/security/revision-probe.ts +144 -0
  62. package/src/security/terms.ts +97 -0
  63. package/src/security-allow.ts +1 -0
  64. package/src/security-baseline.ts +113 -0
  65. package/src/security-check.ts +91 -16
  66. package/src/server-text.ts +38 -0
  67. package/src/store.ts +142 -16
  68. package/src/transcript.ts +7 -1
  69. package/src/ui-language.ts +33 -0
  70. package/src/update/check.ts +7 -3
  71. package/src/update/handoff-system.ts +24 -16
  72. package/src/update/handoff.ts +23 -12
  73. package/src/update/helper.ts +72 -36
  74. package/src/update/request.ts +46 -30
  75. package/src/update/state.ts +21 -11
  76. package/src/update/strategy.ts +12 -5
  77. package/src/update/target.ts +32 -15
  78. package/src/usage.ts +43 -20
  79. package/src/version.ts +39 -16
  80. package/src/voice/agents.ts +8 -3
  81. package/src/voice/install.ts +29 -18
  82. package/src/work-watch.ts +40 -18
  83. package/web/agent-icon.js +15 -9
  84. package/web/agents.d.ts +201 -0
  85. package/web/agents.js +813 -0
  86. package/web/app-download.js +32 -19
  87. package/web/app.js +1407 -278
  88. package/web/autocopy.js +6 -1
  89. package/web/boot.js +27 -8
  90. package/web/commands.js +20 -11
  91. package/web/decisions.js +70 -49
  92. package/web/devlog.js +25 -19
  93. package/web/diagram.js +8 -3
  94. package/web/enroll.js +43 -22
  95. package/web/gc.js +32 -19
  96. package/web/i18n/agent-icon.js +24 -0
  97. package/web/i18n/agents.js +48 -0
  98. package/web/i18n/app-download.js +26 -0
  99. package/web/i18n/autocopy.js +5 -0
  100. package/web/i18n/boot.js +24 -0
  101. package/web/i18n/commands.js +34 -0
  102. package/web/i18n/decisions.js +24 -0
  103. package/web/i18n/devlog.js +60 -0
  104. package/web/i18n/diagram.js +12 -0
  105. package/web/i18n/enroll.js +22 -0
  106. package/web/i18n/gc.js +38 -0
  107. package/web/i18n/lastsent.js +10 -0
  108. package/web/i18n/main.d.ts +4 -0
  109. package/web/i18n/main.js +527 -0
  110. package/web/i18n/manual.d.ts +4 -0
  111. package/web/i18n/manual.js +14 -0
  112. package/web/i18n/memo.js +30 -0
  113. package/web/i18n/notifications.js +16 -0
  114. package/web/i18n/profile.js +13 -0
  115. package/web/i18n/reference.js +26 -0
  116. package/web/i18n/security-inbox.js +40 -0
  117. package/web/i18n/slash.js +38 -0
  118. package/web/i18n/turn-stats.js +5 -0
  119. package/web/i18n/update.js +42 -0
  120. package/web/i18n/voice.js +28 -0
  121. package/web/i18n/wake.js +16 -0
  122. package/web/i18n/workid.js +10 -0
  123. package/web/i18n/workstatus.js +20 -0
  124. package/web/i18n.d.ts +20 -0
  125. package/web/i18n.js +63 -0
  126. package/web/index.html +156 -96
  127. package/web/lastsent.js +9 -4
  128. package/web/manual.d.ts +1 -1
  129. package/web/manual.js +11 -5
  130. package/web/memo.js +18 -13
  131. package/web/notifications.js +12 -7
  132. package/web/profile.js +42 -17
  133. package/web/reference.js +20 -15
  134. package/web/security-inbox.d.ts +18 -1
  135. package/web/security-inbox.js +173 -4
  136. package/web/slash.js +23 -13
  137. package/web/style.css +132 -3
  138. package/web/tooltip.js +5 -2
  139. package/web/turn-stats.js +6 -2
  140. package/web/update.js +31 -23
  141. package/web/voice.js +18 -8
  142. package/web/wake.d.ts +4 -0
  143. package/web/wake.js +7 -3
  144. package/web/workid.js +12 -2
  145. package/web/workstatus.js +36 -32
package/src/hub.ts CHANGED
@@ -11,13 +11,24 @@ 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
- import type { MessageGuard } from "./security-check.ts";
17
+ import type { MessageGuard, SecurityChecker } from "./security-check.ts";
17
18
  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;
@@ -52,7 +64,7 @@ export interface HubOptions {
52
64
  /** Where attached images live (`dir`, as this process sees it) and the same folder's path on the Mac, for the agent. */
53
65
  media?: { dir: string; hostDir?: string };
54
66
  now?: () => number;
55
- rotation?: { maxAgeMs?: number; idleMs?: number; forceMaxBytes?: number; forceUpdateCompactions?: number };
67
+ rotation?: { maxAgeMs?: number; idleMs?: number; forceMaxBytes?: number; forceUpdateCompactions?: number; freshSessionCompactionFailures?: number };
56
68
  /** Bug 152: bounded native ownership reconciliation; timers are injectable for deterministic tests. */
57
69
  closeRecovery?: {
58
70
  intervalMs?: number;
@@ -74,22 +86,91 @@ 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
- } as const satisfies Record<CompactionFailureKind, { readonly action: string; readonly message: string }>;
91
-
92
- export type RotationReason = "manual" | "age" | ForcedRotationReason;
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
+ }
120
+
121
+ /** Bug 223: the longest provider error a compaction failure log line keeps, after its sensitive values are removed. */
122
+ export const COMPACTION_ERROR_MAX_CHARS = 512;
123
+
124
+ /**
125
+ * Bug 223: the provider's own error text with every value the repository security check finds (tokens, keys,
126
+ * credentials, home paths, e-mail, internal addresses, device ids) replaced by [redacted], on one line, at most
127
+ * COMPACTION_ERROR_MAX_CHARS. The cause wording stays. Values are found before the text is cut.
128
+ */
129
+ export function sanitizeProviderError(text: unknown, checker: Pick<SecurityChecker, "runtimeOccurrences">): string | null {
130
+ if (typeof text !== "string") return null;
131
+ const withoutPrivateKeys = text.replace(/-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----|$)/g, "[redacted]");
132
+ const flat = withoutPrivateKeys.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ").replace(/\s+/g, " ").trim();
133
+ if (!flat) return null;
134
+ const spans = checker.runtimeOccurrences([flat])
135
+ .map((found) => [found.start ?? 0, found.end ?? flat.length] as const).sort((a, b) => a[0] - b[0]);
136
+ let kept = "";
137
+ let at = 0;
138
+ for (const [start, end] of spans) {
139
+ if (end <= at) continue;
140
+ if (start >= at) kept += `${flat.slice(at, start)}[redacted]`;
141
+ at = end;
142
+ }
143
+ kept += flat.slice(at);
144
+ return kept.length > COMPACTION_ERROR_MAX_CHARS ? `${kept.slice(0, COMPACTION_ERROR_MAX_CHARS)}…` : kept;
145
+ }
146
+
147
+ /**
148
+ * Bug 223: the fields of one gateway log line per failed blocking compaction. A provider/model the engine did not list
149
+ * or a malformed number never reaches the log. The provider error is kept only through sanitizeProviderError;
150
+ * errorMessage and other free text are never read.
151
+ */
152
+ export function compactionFailureLog(failureKind: CompactionFailureKind, failure: unknown, knownModels: ReadonlySet<string>,
153
+ sanitize: (text: unknown) => string | null = () => null): Record<string, unknown> {
154
+ const fields = typeof failure === "object" && failure !== null ? failure as Record<string, unknown> : {};
155
+ const provider = typeof fields.provider === "string" ? fields.provider : "";
156
+ const model = typeof fields.model === "string" ? fields.model : "";
157
+ const listed = knownModels.has(`${provider}/${model}`);
158
+ const tokens = fields.inputTokens;
159
+ const retryAt = fields.retryAt;
160
+ return {
161
+ failureKind,
162
+ provider: listed ? provider : "unknown",
163
+ model: listed ? model : "unknown",
164
+ error: sanitize(fields.error) ?? "unknown",
165
+ inputTokens: typeof tokens === "number" && Number.isSafeInteger(tokens) && tokens >= 0 ? tokens : "unknown",
166
+ inputTokensSource: fields.inputTokensSource === "estimate" || fields.inputTokensSource === "provider" ? fields.inputTokensSource : "unknown",
167
+ requestSent: typeof fields.requestSent === "boolean" ? fields.requestSent : "unknown",
168
+ ...(typeof retryAt === "number" && Number.isSafeInteger(retryAt) && retryAt > 0 && retryAt < 8.64e15
169
+ ? { retryAt: new Date(retryAt).toISOString() } : {}),
170
+ };
171
+ }
172
+
173
+ export type RotationReason = "manual" | "age" | "compaction_failed" | ForcedRotationReason;
93
174
  /** Bug 72 proposal 4: limits that rotate a session even with works, updates, decisions or questions open. */
94
175
  export type ForcedRotationReason = "size" | "compactions";
95
176
 
@@ -106,6 +187,10 @@ export const ROTATION_HELD_LOG_MS = 6 * 60 * 60 * 1000;
106
187
  export const ROTATION_RETRY_MS = 30 * 60 * 1000;
107
188
  /** Bug 72: completed turns that must carry an unconsumed user input before an idle native releases it from the turn. */
108
189
  export const UNCONSUMED_INPUT_RELEASE_TURNS = 2;
190
+ /** Bug 224: the engine's exact refusal of a prompt before accepting it while compaction is unfinished (RequiredCompactionError). */
191
+ export const REQUIRED_COMPACTION_REFUSAL = "Context remains above the compaction threshold because compaction did not complete";
192
+ /** Bug 224 (user decision 2026-10-29): failed compactions in a row of one session that open a fresh session. */
193
+ export const FRESH_SESSION_COMPACTION_FAILURES = 2;
109
194
 
110
195
  /** Bug 72: one condition that now holds a conversation's session rotation. */
111
196
  export interface RotationBlocker {
@@ -147,6 +232,65 @@ function describeBlockers(blockers: readonly RotationBlocker[]): string {
147
232
  const HANDOFF_MAX_CHARS = 8000;
148
233
  const HANDOFF_LINE_CHARS = 600;
149
234
  const CONTEXT_OVERFLOW = /prompt is too long|context overflow/i;
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>;
150
294
 
151
295
  interface OpenResult {
152
296
  sessionId: string;
@@ -176,6 +320,11 @@ export interface SlashCommand {
176
320
  source: string;
177
321
  }
178
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
+
179
328
  interface WorkWake {
180
329
  input: StoredEvent;
181
330
  /** Releases a live receipt wait after reconciliation or connection loss. Absent after restart. */
@@ -189,6 +338,7 @@ interface ConvRuntime {
189
338
  run: number;
190
339
  rotating: boolean;
191
340
  admitting: number;
341
+ promptAdmissions: Set<() => void>;
192
342
  clientInputIdentity: boolean;
193
343
  atomicQueuedResume: boolean;
194
344
  /** `open_session` state.sessionId: the session file's own identity, which survives reconnects. */
@@ -322,6 +472,7 @@ export function modelOptions(models: OmoModel[]): ModelChoice[] {
322
472
 
323
473
  export class Hub {
324
474
  private readonly runtime = new Map<string, ConvRuntime>();
475
+ private readonly deletingAgents = new Set<string>();
325
476
  private readonly sessionToConv = new Map<string, string>();
326
477
  /** Open ask_user_question cards by scope-qualified gateway action ID. omo owns their lifetime. */
327
478
  private readonly questions = new Map<string, QuestionCard>();
@@ -617,9 +768,10 @@ export class Hub {
617
768
  }
618
769
 
619
770
  snapshot(): Record<string, unknown> {
771
+ const conversations = this.store.listConversations(false);
620
772
  return {
621
- conversations: this.store.listConversations(),
622
- 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)])),
623
775
  approvals: [...this.dialogs.list(), ...this.questions.values(), ...this.releasedMainCards()],
624
776
  sessionTransitions: [...this.runtime]
625
777
  .filter(([, runtime]) => runtime.rotating)
@@ -659,6 +811,10 @@ export class Hub {
659
811
 
660
812
  /** Whether the last model answer named a held switch; the next settled main turn re-reads it. */
661
813
  private modelPending = false;
814
+ private readonly knownModels = new Set<string>();
815
+ private providerErrorChecker: SecurityChecker | null = null;
816
+ /** Improvement 230: conversations whose `/compact` RPC has not answered yet. */
817
+ private readonly manualCompactions = new Set<string>();
662
818
 
663
819
  /** The models omo can use right now (its logged-in providers) and the one a conversation's session is on. */
664
820
  async models(convId = "main"): Promise<ModelInfo> {
@@ -671,6 +827,7 @@ export class Hub {
671
827
  omo.command<{ model?: OmoModel | null; thinkingLevel?: string; pendingModelSwitch?: { provider: string; id: string } | null }>("get_state", { sessionId }),
672
828
  ]);
673
829
  const options = modelOptions(available.models ?? []);
830
+ for (const option of options) this.knownModels.add(`${option.provider}/${option.id}`);
674
831
  const held = state.pendingModelSwitch;
675
832
  const pending = held
676
833
  ? options.find((o) => o.provider === held.provider && o.id === held.id) ?? { provider: held.provider, id: held.id, name: held.id }
@@ -725,13 +882,43 @@ export class Hub {
725
882
  }
726
883
  }
727
884
 
728
- createConversation(title: string): ConversationInfo {
729
- 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);
730
887
  this.emit("*", "conversation.created", conv);
731
888
  this.publishActivity(conv.id);
732
889
  return conv;
733
890
  }
734
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
+
735
922
  onOmoStatus(status: OmoStatus, detail?: string): Promise<number> {
736
923
  const activityRecovery = this.beginActivityRecovery();
737
924
  this.omoStatus = status;
@@ -759,7 +946,7 @@ export class Hub {
759
946
  rt.wake = null;
760
947
  rt.notified.clear();
761
948
  const scope = this.conversationScope(conv);
762
- this.dialogs.cancelScope(scope, "엔진 연결이 끊겼습니다.");
949
+ this.dialogs.cancelScope(scope, say(HUB_TEXT.engineDisconnected));
763
950
  for (const [id, card] of this.questions) {
764
951
  if (card.scope !== scope) continue;
765
952
  this.questions.delete(id);
@@ -822,7 +1009,7 @@ export class Hub {
822
1009
  this.activityRecoveryFailedGeneration = activityRecovery;
823
1010
  this.publishAllActivities();
824
1011
  }
825
- 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) }) });
826
1013
  }
827
1014
  }
828
1015
  }
@@ -830,6 +1017,7 @@ export class Hub {
830
1017
  /** `images` are names of files already uploaded to the media folder; unknown names are dropped. */
831
1018
  async sendText(convId: string, text: string, src: Source, trust: Trust, images: string[] = [], referenceIds?: unknown, checked = false, submissionId?: string): Promise<void> {
832
1019
  if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
1020
+ this.requireVisibleAgent(convId);
833
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");
834
1022
  const context = { ...(submissionId ? { key: JSON.stringify(["message", convId, submissionId]), submissionId } : {}),
835
1023
  snapshot: structuredClone({ text, src, trust, images, referenceIds }) };
@@ -847,6 +1035,7 @@ export class Hub {
847
1035
  this.emit(convId, "notice", { kind: "usage", level: "info", message: await this.options.usageReport() });
848
1036
  return;
849
1037
  }
1038
+ const compact = images.length === 0 && references.length === 0 ? compactInstructions(body) : null;
850
1039
  const dir = this.options.media?.dir;
851
1040
  const attached = dir ? [...new Set(images)].filter((n) => mediaFile(dir, n)).slice(0, MAX_ATTACHMENTS) : [];
852
1041
  const prompt = referencedInput(body, attached, references);
@@ -857,13 +1046,17 @@ export class Hub {
857
1046
  if (!body && attached.length === 0 && references.length === 0) return;
858
1047
  const attachmentSnapshot = this.attachments(prompt.images);
859
1048
  const send = async (receipt?: (ref: SecurityReceipt) => void) => {
1049
+ this.requireVisibleAgent(convId);
860
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);
861
1053
  const input = this.store.acceptMainUser(convId, {
862
1054
  text: body, src: source, trust, ...(attached.length ? { images: attached } : {}),
863
1055
  ...(references.length ? { references } : {}),
864
- }, receipt);
1056
+ }, receipt, compact !== null);
865
1057
  this.publishStored(input);
866
1058
  this.decisions?.refresh();
1059
+ if (compact !== null) return this.manualCompact(convId, input.seq, compact, receipt !== undefined);
867
1060
  const rotationDone = this.rt(convId).rotationDone;
868
1061
  if (rotationDone) await rotationDone;
869
1062
  if (this.closeUncertain(convId)) {
@@ -873,7 +1066,7 @@ export class Hub {
873
1066
  }
874
1067
  this.scheduleCloseRecovery(convId);
875
1068
  this.emit(convId, "notice", { kind: "session-close-recovery", level: "info",
876
- message: "세션 닫기 상태를 확인하고 있습니다. 메시지는 확인 후 전달합니다." });
1069
+ message: say(HUB_TEXT.closeChecking) });
877
1070
  return;
878
1071
  }
879
1072
  await this.prompt(convId, prompt.text, prompt.images, input.seq, true, attachmentSnapshot, receipt !== undefined);
@@ -930,7 +1123,7 @@ export class Hub {
930
1123
  const generation = this.generation;
931
1124
  const flight = this.injectWorkUpdates(convId, generation).catch((error) => {
932
1125
  if (this.omoStatus === "ready" && generation === this.generation) {
933
- this.emit(convId, "error", { message: `작업 결과 알림 실패: ${errorMessage(error)}` });
1126
+ this.emit(convId, "error", { message: say(HUB_TEXT.workNotifyFailed, { error: errorMessage(error) }) });
934
1127
  }
935
1128
  }).finally(() => {
936
1129
  if (rt.waking !== flight) return;
@@ -992,7 +1185,7 @@ export class Hub {
992
1185
  if (!acknowledged && generation === this.generation && this.omoStatus === "ready" && rt.wake === wake) {
993
1186
  this.emit(convId, "notice", {
994
1187
  kind: "work-input-receipt-unknown", level: "warning",
995
- input_seq: input.seq, message: "작업 결과 알림의 수신을 확인하지 못했습니다. 결과 기록은 보존되어 있습니다.",
1188
+ input_seq: input.seq, message: say(HUB_TEXT.workReceiptUnknown),
996
1189
  });
997
1190
  }
998
1191
  }
@@ -1037,7 +1230,7 @@ export class Hub {
1037
1230
  }
1038
1231
  } catch (error) {
1039
1232
  if (generation === this.generation && this.omoStatus === "ready") {
1040
- this.emit(conv, "error", { message: `작업 결과 알림 대조 실패: ${errorMessage(error)}` });
1233
+ this.emit(conv, "error", { message: say(HUB_TEXT.workReconcileFailed, { error: errorMessage(error) }) });
1041
1234
  }
1042
1235
  }
1043
1236
  if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId || rt.run !== run) return;
@@ -1057,7 +1250,7 @@ export class Hub {
1057
1250
  await this.reconcileWorkInput(pendingWake.input, sessionId, generation, true);
1058
1251
  } catch (error) {
1059
1252
  if (generation === this.generation && this.omoStatus === "ready") {
1060
- this.emit(conv, "error", { message: `작업 결과 알림 대조 실패: ${errorMessage(error)}` });
1253
+ this.emit(conv, "error", { message: say(HUB_TEXT.workReconcileFailed, { error: errorMessage(error) }) });
1061
1254
  }
1062
1255
  }
1063
1256
  if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId || rt.run !== run) return;
@@ -1118,8 +1311,7 @@ export class Hub {
1118
1311
  && (event.data as { input_seq?: number }).input_seq === input.seq).at(-1);
1119
1312
  if ((previous?.data as { kind?: string } | undefined)?.kind !== kind) this.emit(conv, "notice", {
1120
1313
  kind, input_seq: input.seq, level: "warning",
1121
- message: queued ? "메시지가 엔진 대기열에 남아 있습니다. 자동 재전송하지 않습니다."
1122
- : "메시지의 실행 소비를 확인하지 못했습니다. 기록을 보존하며 자동 재전송하지 않습니다.",
1314
+ message: say(queued ? HUB_TEXT.inputQueued : HUB_TEXT.inputConsumptionUnknown),
1123
1315
  });
1124
1316
  if (queued && data.native_identity && rt.atomicQueuedResume && state &&
1125
1317
  state.isStreaming === false && typeof state.queueEpoch === "string") {
@@ -1130,7 +1322,7 @@ export class Hub {
1130
1322
  sessionId, clientInputId: data.input_id, queueEpoch: state.queueEpoch,
1131
1323
  }).catch((error) => {
1132
1324
  if (generation === this.generation && this.omoStatus === "ready" && rt.sessionId === sessionId) {
1133
- this.emit(conv, "error", { message: `대기 입력 재개 확인 실패: ${errorMessage(error)}` });
1325
+ this.emit(conv, "error", { message: say(HUB_TEXT.queuedResumeFailed, { error: errorMessage(error) }) });
1134
1326
  }
1135
1327
  });
1136
1328
  }
@@ -1154,7 +1346,7 @@ export class Hub {
1154
1346
  }
1155
1347
  } catch (error) {
1156
1348
  if (generation === this.generation && this.omoStatus === "ready" && rt.sessionId === sessionId) {
1157
- this.emit(conv, "error", { message: `사용자 입력 대조 실패: ${errorMessage(error)}` });
1349
+ this.emit(conv, "error", { message: say(HUB_TEXT.userInputReconcileFailed, { error: errorMessage(error) }) });
1158
1350
  }
1159
1351
  } finally {
1160
1352
  clearTimeout(timer);
@@ -1181,11 +1373,13 @@ export class Hub {
1181
1373
  */
1182
1374
  async rotateSession(convId: string, reason: RotationReason): Promise<ConversationInfo> {
1183
1375
  if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
1376
+ this.requireVisibleAgent(convId);
1184
1377
  const conv = this.store.getConversation(convId);
1185
1378
  if (!conv) throw new Error(`unknown_conversation:${convId}`);
1186
1379
  const rt = this.rt(convId);
1187
1380
  const forced = reason === "size" || reason === "compactions";
1188
- if (rt.rotating || this.rotationHeldFor(convId, forced)) throw new Error("turn_running");
1381
+ const residue = this.residualTurn(convId, reason);
1382
+ if (rt.rotating || (this.rotationHeldFor(convId, forced) && residue === null)) throw new Error("turn_running");
1189
1383
  if (this.omoStatus !== "ready" || this.closeUncertain(convId)) throw new Error("session_ownership_unknown");
1190
1384
  const generation = this.generation;
1191
1385
  const from = conv.threadId;
@@ -1195,7 +1389,7 @@ export class Hub {
1195
1389
  rt.rotating = true;
1196
1390
  rt.rotationDone = latch.promise;
1197
1391
  rt.finishRotation = latch.resolve;
1198
- this.emit(convId, "session.transition", { active: true, label: "세션 교체 중" }, false);
1392
+ this.emit(convId, "session.transition", { active: true, label: say(HUB_TEXT.sessionTransition) }, false);
1199
1393
  try {
1200
1394
  const { sessionId } = await this.ensureSession(conv, true);
1201
1395
  const readiness = await this.requireOmo().command<{
@@ -1243,7 +1437,7 @@ export class Hub {
1243
1437
  rt.sessionId !== sessionId ||
1244
1438
  from !== source ||
1245
1439
  this.store.lastSeq(convId, "message.user") !== admittedUser ||
1246
- this.rotationHeldFor(convId, forced)
1440
+ (this.rotationHeldFor(convId, forced) && (residue === null || this.residualTurn(convId, reason) !== residue))
1247
1441
  ) {
1248
1442
  throw new Error("turn_running");
1249
1443
  }
@@ -1288,6 +1482,95 @@ export class Hub {
1288
1482
  }
1289
1483
  }
1290
1484
 
1485
+ /**
1486
+ * Bug 224: a manual rotation may retire a session whose main turn left only residue: inputs nothing runs any more and
1487
+ * a work wake whose send already ended. Returns that residue's identity, or null when anything else holds. rotateSession
1488
+ * still proves the engine idle (no streaming, compaction, retry, queue or question) before it closes the session.
1489
+ */
1490
+ private residualTurn(conv: string, reason: RotationReason): string | null {
1491
+ if (reason !== "manual") return null;
1492
+ const rt = this.rt(conv);
1493
+ const holds = this.rotationHoldsFor(conv, false);
1494
+ if (holds.length === 0 || this.store.pendingMainUsers(conv).length > 0) return null;
1495
+ const residual = holds.every((hold) => hold.kind === "main_turn" &&
1496
+ (hold.state === "unfinished_turn" || (hold.state === "waking" && rt.waking === null)));
1497
+ if (!residual) return null;
1498
+ const turn = this.store.mainTurn(conv);
1499
+ return JSON.stringify({ turn: turn?.seq ?? null, inputs: turn?.inputs.map((input) => input.seq) ?? [], wake: rt.wake?.input.seq ?? null });
1500
+ }
1501
+
1502
+ /**
1503
+ * Bug 224 (user decision 2026-10-29): FRESH_SESSION_COMPACTION_FAILURES failed compactions in a row open a fresh session
1504
+ * without asking the old one for anything. The old session is detached first, so its late events never reach this
1505
+ * conversation; stopping and closing it are best effort. Its file stays; the handoff names it and carries only the
1506
+ * recent exchange. Works, decisions and never-sent user input carry over as in any rotation; nothing is replayed.
1507
+ */
1508
+ private openFreshSession(convId: string): void {
1509
+ if (this.maintenanceId || this.omoStatus !== "ready") return;
1510
+ const conv = this.store.getConversation(convId);
1511
+ const rt = this.rt(convId);
1512
+ if (!conv?.threadId || rt.rotating) return;
1513
+ const from = conv.threadId;
1514
+ const sessionId = rt.sessionId;
1515
+ // Synchronous from the check above to the new thread, so no prompt or rotation interleaves; no latch is needed.
1516
+ this.emit(convId, "session.transition", { active: true, label: say(HUB_TEXT.sessionTransition) }, false);
1517
+ try {
1518
+ if (sessionId) this.retireScopedQuestions(convId, sessionId);
1519
+ rt.running = false;
1520
+ rt.durableId = null;
1521
+ rt.run++;
1522
+ this.finishRetirement(convId, {
1523
+ sessionId: sessionId ?? "", sessionPath: from, from, reason: "compaction_failed",
1524
+ handoff: this.buildHandoff(convId, "compaction_failed", from),
1525
+ });
1526
+ this.publishActivity(convId);
1527
+ } finally {
1528
+ this.emit(convId, "session.transition", { active: false, label: null }, false);
1529
+ }
1530
+ if (sessionId) void this.stopAbandonedSession(sessionId);
1531
+ if (this.store.pendingMainUsers(convId).length > 0) void this.recover();
1532
+ void this.notifyWorkUpdates(convId);
1533
+ }
1534
+
1535
+ /** A detached session gets one abort and one close; it no longer routes to any conversation, so failures only log. */
1536
+ private async stopAbandonedSession(sessionId: string): Promise<void> {
1537
+ for (const command of ["abort", "close_session"] as const) {
1538
+ try {
1539
+ await this.requireOmo().command(command, { sessionId });
1540
+ } catch (error) {
1541
+ console.error(`abandoned session ${command} failed (${sessionId}): ${errorMessage(error)}`);
1542
+ }
1543
+ }
1544
+ }
1545
+
1546
+ /** Bug 223: log with cached model identities; an unhealthy engine must never hold its own failure diagnostic. */
1547
+ private async logCompactionFailure(failureKind: CompactionFailureKind, failure: unknown): Promise<void> {
1548
+ // Loaded on first use: the security rules ship beside src/, and only a failure needs them.
1549
+ if (!this.providerErrorChecker) this.providerErrorChecker = (await import("./security-check.ts")).defaultChecker();
1550
+ const checker = this.providerErrorChecker;
1551
+ console.error("compaction_failed", JSON.stringify(compactionFailureLog(failureKind, failure, this.knownModels,
1552
+ (text) => sanitizeProviderError(text, checker))));
1553
+ }
1554
+
1555
+ private freshSessionCompactionFailures(): number {
1556
+ return this.options.rotation?.freshSessionCompactionFailures ?? FRESH_SESSION_COMPACTION_FAILURES;
1557
+ }
1558
+
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
+ */
1563
+ private compactionFailuresInRow(conv: string): number {
1564
+ const failed = new Set<string>();
1565
+ for (const event of this.store.eventsAfter(conv, this.store.lastSeq(conv, "session.rotated")).reverse()) {
1566
+ if (event.type !== "session.compaction") continue;
1567
+ const data = event.data as { status?: string; requestId?: unknown; reason?: unknown };
1568
+ if (data.status === "completed") break;
1569
+ if (data.status === "failed" && data.reason !== "manual" && typeof data.requestId === "string") failed.add(data.requestId);
1570
+ }
1571
+ return failed.size;
1572
+ }
1573
+
1291
1574
  private closeUncertain(conv: string): boolean {
1292
1575
  return this.store.lastSeq(conv, "session.close.unknown") >
1293
1576
  Math.max(this.store.lastSeq(conv, "session.rotated"), this.store.lastSeq(conv, "session.close.resolved"));
@@ -1304,9 +1587,16 @@ export class Hub {
1304
1587
 
1305
1588
  private finishRetirement(convId: string, retirement: SessionRetirement): void {
1306
1589
  const rt = this.rt(convId);
1307
- const owner = this.ownerForConversation(convId, retirement.sessionId);
1308
- this.sessionToConv.delete(retirement.sessionId);
1309
- if (owner) this.releaseScopedUiOwner(owner);
1590
+ for (const release of rt.promptAdmissions) release();
1591
+ if (retirement.sessionId) {
1592
+ const owner = this.ownerForConversation(convId, retirement.sessionId);
1593
+ this.sessionToConv.delete(retirement.sessionId);
1594
+ if (owner) this.releaseScopedUiOwner(owner);
1595
+ }
1596
+ // Bug 224: nothing the retired session left unfinished can run any more; close it so recovery never replays it.
1597
+ if (this.store.mainTurn(convId)) this.completeTurn(convId, "interrupted");
1598
+ rt.wake?.release?.();
1599
+ rt.wake = null;
1310
1600
  rt.sessionId = null;
1311
1601
  rt.itemId = null;
1312
1602
  rt.itemText = "";
@@ -1318,7 +1608,8 @@ export class Hub {
1318
1608
  this.emit(convId, "conversation.thread", { threadId: to });
1319
1609
  this.emit(convId, "session.rotated", { reason: retirement.reason, from: retirement.from, to });
1320
1610
  this.emit(convId, "notice", { kind: "session-rotated", level: "info",
1321
- message: `새 엔진 세션으로 교체했습니다 (${this.reasonLabel(retirement.reason)}). 화면의 대화 기록은 그대로이고, 다음 메시지부터 새 세션에서 이어집니다.` });
1611
+ message: say(HUB_TEXT.sessionRotated, { reason: this.reasonLabel(retirement.reason, serverLanguage()),
1612
+ extra: retirement.reason === "compaction_failed" ? say(HUB_TEXT.rotatedAfterFailure) : "" }) });
1322
1613
  }
1323
1614
 
1324
1615
  private closeTimer(callback: () => void, ms: number): ReturnType<typeof setTimeout> {
@@ -1388,14 +1679,14 @@ export class Hub {
1388
1679
  this.emit(convId, "session.close.resolved", { sessionId: data.sessionId, sessionPath: data.sessionPath,
1389
1680
  outcome: present ? "retained" : "superseded", attempts: recovery.attempts });
1390
1681
  this.emit(convId, "notice", { kind: "session-close-resolved", level: "info",
1391
- message: "세션 닫기 상태를 확인했습니다. 기존 대화에서 메시지 처리를 계속합니다." });
1682
+ message: say(HUB_TEXT.closeResolved) });
1392
1683
  }
1393
1684
  this.closeRecoveries.delete(convId);
1394
1685
  } catch (error) {
1395
1686
  if (generation !== this.generation) return;
1396
1687
  if (recovery.attempts >= (this.options.closeRecovery?.maxAttempts ?? 3)) {
1397
1688
  recovery.exhausted = true;
1398
- this.emit(convId, "error", { message: `session_close_recovery_exhausted: ${errorMessage(error)}. 메시지는 보관 중입니다. 엔진 연결과 세션 목록을 확인한 뒤 게이트웨이를 다시 시작하세요.` });
1689
+ this.emit(convId, "error", { message: say(HUB_TEXT.closeRecoveryExhausted, { error: errorMessage(error) }) });
1399
1690
  }
1400
1691
  } finally {
1401
1692
  if (timeout !== undefined) this.clearCloseTimer(timeout);
@@ -1558,7 +1849,7 @@ export class Hub {
1558
1849
  this.legacyQuestionHistoryAliases.delete(id);
1559
1850
  this.emit(conv, "action.resolved", {
1560
1851
  actionId: historyActionId,
1561
- answer: { kind: "cancel", reason: "세션 교체로 닫힌 질문입니다. 받은편지함에서 답하면 새 세션으로 전달됩니다." },
1852
+ answer: { kind: "cancel", reason: say(HUB_TEXT.rotationQuestionClosed) },
1562
1853
  by: "cancelled",
1563
1854
  });
1564
1855
  }
@@ -1591,11 +1882,13 @@ export class Hub {
1591
1882
  );
1592
1883
  }
1593
1884
 
1594
- 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 {
1595
1887
  const hours = Math.round((this.options.rotation?.maxAgeMs ?? ROTATE_MAX_AGE_MS) / 3_600_000);
1596
- if (reason === "size") return `세션 파일이 ${Math.round((this.options.rotation?.forceMaxBytes ?? FORCE_ROTATE_MAX_BYTES) / 1024 / 1024)}MB에 닿음`;
1597
- if (reason === "compactions") return `압축이 ${this.options.rotation?.forceUpdateCompactions ?? FORCE_ROTATE_UPDATE_COMPACTIONS}번 더 이어짐`;
1598
- 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 });
1599
1892
  }
1600
1893
 
1601
1894
  /** Recent user/assistant lines from the gateway's own history, newest kept, within HANDOFF_MAX_CHARS. */
@@ -1616,8 +1909,8 @@ export class Hub {
1616
1909
  lines.unshift(line);
1617
1910
  }
1618
1911
  return [
1619
- `[게이트웨이 알림] 이 대화는 새 엔진 세션으로 교체됐습니다 (${this.reasonLabel(reason)}).`,
1620
- from ? `이전 세션의 전체 기록은 ${from} 에 남아 있습니다. 파일이 크니 필요할 때 필요한 부분만 검색해서 보세요.` : "",
1912
+ `[게이트웨이 알림] 이 대화는 새 엔진 세션으로 교체됐습니다 (${this.reasonLabel(reason, "ko")}).`,
1913
+ from ? `이전 세션의 전체 기록은 ${from} 에 남아 있습니다. 파일이 크니 필요할 때 끝부분이나 필요한 부분만 검색해서 보고, 전체를 읽지 마세요.` : "",
1621
1914
  ...this.handoffOpenItems(conv),
1622
1915
  "아래는 직전 대화의 최근 부분입니다. 맥락으로만 참고하세요.",
1623
1916
  "---",
@@ -1630,19 +1923,29 @@ export class Hub {
1630
1923
 
1631
1924
  /** Sends input once. Native same-session compaction owns overflow recovery, never gateway replay. */
1632
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);
1633
1927
  const conv = this.store.getConversation(convId);
1634
1928
  if (!conv) return;
1635
1929
  const rt = this.rt(convId);
1636
1930
  if (rt.rotating) return; // Accepted user intent remains definitely unsent until rotation finishes.
1637
1931
  const generation = this.generation;
1932
+ let input: StoredEvent | null = null;
1933
+ let nativeCommand = false;
1934
+ let sessionId: string | null = null;
1935
+ const releaseAdmission = () => {
1936
+ if (rt.promptAdmissions.delete(releaseAdmission)) rt.admitting -= 1;
1937
+ };
1938
+ rt.promptAdmissions.add(releaseAdmission);
1939
+ // Hold user admission through RPC settlement, including the idle-before-agent_start interval.
1940
+ rt.admitting += 1;
1638
1941
  try {
1639
- const { sessionId } = await this.ensureSession(conv);
1942
+ ({ sessionId } = await this.ensureSession(conv));
1640
1943
  if (generation !== this.generation || this.omoStatus !== "ready") return;
1641
1944
  const reference = messageSeq ?? this.store.lastSeq(convId, "message.user");
1642
1945
  // sendText and reconnect recovery can share admission; only one may cross the write boundary.
1643
1946
  if (admitted && this.store.mainUserDispatched(convId, reference)) return;
1644
1947
  const commandName = body.slice(1).split(" ", 1)[0];
1645
- const nativeCommand = (body === "." && images.length === 0) ||
1948
+ nativeCommand = (body === "." && images.length === 0) ||
1646
1949
  (body.startsWith("/") && (await this.commands(convId)).some((command) => command.name === commandName));
1647
1950
  if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId) return;
1648
1951
  if (admitted && this.store.mainUserDispatched(convId, reference)) return;
@@ -1660,7 +1963,7 @@ export class Hub {
1660
1963
  ...(rt.clientInputIdentity ? { clientInputId: inputId } : {}),
1661
1964
  };
1662
1965
  if (attached.images.length) params.images = attached.images;
1663
- const input = this.emit(convId, "main.input", {
1966
+ input = this.emit(convId, "main.input", {
1664
1967
  origin: "user", input_ref: { message_seq: reference },
1665
1968
  session_path: this.store.getConversation(convId)?.threadId, handoff,
1666
1969
  ...(!nativeCommand || rt.clientInputIdentity ? {
@@ -1677,9 +1980,20 @@ export class Hub {
1677
1980
  this.decisions?.refresh();
1678
1981
  } catch (error) {
1679
1982
  if (generation !== this.generation || this.omoStatus !== "ready") return;
1983
+ // A retired session's late answer: retirement already closed its input, so it must not close or report it again.
1984
+ const retired = sessionId !== null && rt.sessionId !== sessionId;
1985
+ // Bug 224: the engine refused this prompt before accepting it, so it never runs and must not hold the turn.
1986
+ // Native commands ("." continues through a queued custom message) and unknown delivery stay open.
1987
+ const refused = !retired && input !== null && !nativeCommand && error instanceof OmoCommandError &&
1988
+ error.command === "prompt" && error.code === REQUIRED_COMPACTION_REFUSAL && !this.store.mainInputConsumed(input);
1989
+ if (refused && input) this.emit(convId, "main.input.handled", { input_seq: input.seq, reason: "prompt_rejected" });
1680
1990
  if (protectedPayload) throw new SecurityRuntimeError("dispatch_unknown");
1991
+ if (retired) return;
1992
+ if (error instanceof AgentSessionError) return; // The definition failure was already persisted and broadcast.
1681
1993
  const message = errorMessage(error);
1682
- this.emit(convId, "error", this.continuityError(message));
1994
+ this.emit(convId, "error", refused ? { kind: "compaction-required", message: say(COMPACTION_REQUIRED_MESSAGE) } : this.continuityError(message));
1995
+ } finally {
1996
+ releaseAdmission();
1683
1997
  }
1684
1998
  }
1685
1999
 
@@ -1687,7 +2001,7 @@ export class Hub {
1687
2001
  if (!CONTEXT_OVERFLOW.test(message)) return { message };
1688
2002
  return {
1689
2003
  kind: "continuity-protected",
1690
- message: `${message}. 같은 엔진 세션을 유지합니다. 원인을 해결한 뒤 계속할 지시를 보내 주세요. 이전 입력은 자동 재전송하지 않습니다.`,
2004
+ message: say(HUB_TEXT.continuityProtected, { message }),
1691
2005
  };
1692
2006
  }
1693
2007
 
@@ -1713,20 +2027,91 @@ export class Hub {
1713
2027
  const rt = this.rt(convId);
1714
2028
  if (!rt.sessionId || !rt.running) return;
1715
2029
  await this.requireOmo().command("abort", { sessionId: rt.sessionId });
1716
- this.dialogs.cancelScope(this.conversationScope(convId), "사용자가 작업을 중단했습니다.");
2030
+ this.dialogs.cancelScope(this.conversationScope(convId), say(HUB_TEXT.userStopped));
1717
2031
  }
1718
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");
2093
+ }
2094
+
2095
+
1719
2096
  /** omo's slash commands and skills for the conversation's session, trimmed to what the input menu shows. */
1720
2097
  async commands(convId: string): Promise<SlashCommand[]> {
1721
2098
  const conv = this.store.getConversation(convId);
1722
2099
  if (!conv) throw new Error(`unknown_conversation:${convId}`);
1723
2100
  const { sessionId } = await this.ensureSession(conv);
1724
2101
  const { commands } = await this.requireOmo().command<{ commands: { name: string; description?: string; source: string }[] }>("get_commands", { sessionId });
1725
- const listed = commands.map((c) => ({ name: c.name, description: c.description ?? "", source: c.source }));
1726
- 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];
1727
2105
  return [...listed.filter((c) => !own.some((command) => command.name === c.name)), ...own];
1728
2106
  }
1729
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
+
1730
2115
  answerAction(data: ActionAnswerData, conv?: string): boolean {
1731
2116
  if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
1732
2117
  if (this.decisions) {
@@ -1854,9 +2239,9 @@ export class Hub {
1854
2239
  ? { engineTurn: { id: rt.statsTurnId, sessionId: rt.durableId, messageTimestamp: message.timestamp, messageIndex: rt.statsMessageIndex } } : {}),
1855
2240
  });
1856
2241
  if (message.stopReason === "aborted") {
1857
- this.emit(conv, "notice", { message: "작업을 중단했습니다.", level: "info" });
2242
+ this.emit(conv, "notice", { message: say(HUB_TEXT.stopped), level: "info" });
1858
2243
  } else if (message.stopReason === "error" || message.errorMessage) {
1859
- this.emit(conv, "error", this.continuityError(message.errorMessage ?? "모델 응답 오류"));
2244
+ this.emit(conv, "error", this.continuityError(message.errorMessage ?? say(HUB_TEXT.modelResponseError)));
1860
2245
  }
1861
2246
  rt.itemId = null;
1862
2247
  rt.itemText = "";
@@ -1887,9 +2272,9 @@ export class Hub {
1887
2272
  case "compaction_start": {
1888
2273
  if (!blockingCompaction(event) || this.compactionSeen(conv, event, false)) return;
1889
2274
  const requestId = typeof event.requestId === "string" ? event.requestId : undefined;
1890
- this.emit(conv, "session.compaction", { status: "running", requestId });
2275
+ this.emit(conv, "session.compaction", { status: "running", requestId, ...(event.reason === "manual" ? { reason: "manual" } : {}) });
1891
2276
  this.emit(conv, "notice", { kind: "compaction-started", level: "info", requestId,
1892
- message: "대화 내용을 요약하고 있습니다. 완료될 때까지 기다려 주세요." });
2277
+ message: say(HUB_TEXT.compactionStarted) });
1893
2278
  return;
1894
2279
  }
1895
2280
  case "compaction_end": {
@@ -1898,21 +2283,25 @@ export class Hub {
1898
2283
  const outcome = compactionOutcome(event);
1899
2284
  this.emit(conv, "session.compaction", {
1900
2285
  ...outcome, requestId, accepted: outcome.status === "completed",
2286
+ ...(event.reason === "manual" ? { reason: "manual" } : {}),
1901
2287
  });
1902
2288
  switch (outcome.status) {
1903
2289
  case "cancelled":
1904
2290
  this.emit(conv, "notice", { kind: "compaction-cancelled", level: "warning", requestId,
1905
- message: "대화 요약을 취소했습니다. 이전 내용과 같은 세션을 유지합니다. 이전 입력은 자동으로 다시 보내지 않습니다." });
2291
+ message: say(HUB_TEXT.compactionCancelled) });
1906
2292
  break;
1907
2293
  case "failed": {
1908
2294
  const recovery = COMPACTION_RECOVERY[outcome.failureKind];
1909
2295
  this.emit(conv, "error", { kind: "compaction-failed", requestId, failureKind: outcome.failureKind,
1910
- action: recovery.action, message: `대화 요약에 실패했습니다. 이전 내용은 유지했습니다. ${recovery.message}` });
2296
+ action: recovery.action, message: say(COMPACTION_FAILED, { recovery: say(recovery.message) }) });
2297
+ void this.logCompactionFailure(outcome.failureKind, event.failure)
2298
+ .catch((error) => console.error(`compaction failure log: ${errorMessage(error)}`));
2299
+ if (this.compactionFailuresInRow(conv) >= this.freshSessionCompactionFailures()) this.openFreshSession(conv);
1911
2300
  break;
1912
2301
  }
1913
2302
  case "completed":
1914
2303
  this.emit(conv, "notice", { kind: "compaction-completed", level: "info", requestId,
1915
- message: "대화 요약을 완료했습니다. 같은 세션에서 계속합니다." });
2304
+ message: say(HUB_TEXT.compactionCompleted) });
1916
2305
  break;
1917
2306
  }
1918
2307
  return;
@@ -1923,7 +2312,7 @@ export class Hub {
1923
2312
  rt.statsMessageIndex = 0;
1924
2313
  this.publishActivity(conv);
1925
2314
  this.unwatchConversation(conv);
1926
- this.dialogs.cancelScope(owner.scope, "작업이 끝났습니다.");
2315
+ this.dialogs.cancelScope(owner.scope, say(HUB_TEXT.turnEnded));
1927
2316
  if (this.decisions) {
1928
2317
  void this.reconcileDecisionMain(conv)
1929
2318
  .then(() => this.decisions?.settled(owner.scope))
@@ -2000,7 +2389,7 @@ export class Hub {
2000
2389
  legacyActionIds: [requestId],
2001
2390
  method: method as DialogMethod,
2002
2391
  permission,
2003
- title: permission ? `권한 요청: ${firstLine?.slice(PERMISSION_PREFIX.length).trim() ?? ""}` : (firstLine ?? ""),
2392
+ title: permission ? say(HUB_TEXT.permissionTitle, { tool: firstLine?.slice(PERMISSION_PREFIX.length).trim() ?? "" }) : (firstLine ?? ""),
2004
2393
  detail: detailParts.filter(Boolean).join("\n\n"),
2005
2394
  options: Array.isArray(event.options) ? (event.options as unknown[]).map(String) : [],
2006
2395
  },
@@ -2112,12 +2501,12 @@ export class Hub {
2112
2501
  this.questions.delete(id);
2113
2502
  const historyActionId = this.legacyQuestionHistoryAliases.get(id) ?? id;
2114
2503
  this.legacyQuestionHistoryAliases.delete(id);
2115
- 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" });
2116
2505
  }
2117
2506
  for (const id of this.openQuestionCards(owner.conv, owner.scope)) {
2118
2507
  if (live.has(id)) continue;
2119
2508
  if (this.store.getDecisionByAlias(owner.scope, id)?.sourceRequestId.startsWith("legacy:")) continue;
2120
- 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" });
2121
2510
  }
2122
2511
  }
2123
2512
 
@@ -2251,7 +2640,7 @@ export class Hub {
2251
2640
  const recovered = this.emitMissedAnswers(conv.id, assistantTexts(currentEntries));
2252
2641
  if (!this.hasUnfinishedTurn(conv.id)) return;
2253
2642
  if (rt.running) {
2254
- 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) }) });
2255
2644
  return;
2256
2645
  }
2257
2646
  if (turn.source.origin === "work_update") {
@@ -2273,12 +2662,12 @@ export class Hub {
2273
2662
  this.emit(conv.id, "notice", {
2274
2663
  kind: "user-input-receipt-unknown", level: "warning",
2275
2664
  message_seq: turn.source.input_ref.message_seq,
2276
- message: "메시지 전송 이후 수신을 확인하지 못했습니다. 중복 실행을 피하기 위해 자동 재전송하지 않습니다.",
2665
+ message: say(HUB_TEXT.sendReceiptUnknown),
2277
2666
  });
2278
2667
  return;
2279
2668
  }
2280
2669
  if (turnFinished(currentEntries)) {
2281
- 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) }) });
2282
2671
  this.completeTurn(conv.id, "completed");
2283
2672
  rt.wake?.release?.();
2284
2673
  rt.wake = null;
@@ -2288,7 +2677,7 @@ export class Hub {
2288
2677
  .eventsAfter(conv.id, this.store.lastSeq(conv.id, "message.user"))
2289
2678
  .filter((e) => e.type === "notice" && (e.data as { kind?: string }).kind === "auto-continue").length;
2290
2679
  if (tries >= AUTO_CONTINUE_LIMIT) {
2291
- 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 }) });
2292
2681
  this.completeTurn(conv.id, "interrupted");
2293
2682
  rt.wake?.release?.();
2294
2683
  rt.wake = null;
@@ -2298,7 +2687,7 @@ export class Hub {
2298
2687
  || currentEntries.some((e) => e.type === "message" && e.role === "user");
2299
2688
  const lastUser = this.store.getEvent(turn.source.input_ref.message_seq)?.data as { text?: string; images?: string[] } | undefined;
2300
2689
  const message = promptReached || !lastUser?.text ? CONTINUE_PROMPT : lastUser.text;
2301
- 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) }) });
2302
2691
  await this.prompt(conv.id, message, promptReached ? [] : lastUser?.images ?? [], turn.source.input_ref.message_seq);
2303
2692
  }
2304
2693
 
@@ -2306,6 +2695,14 @@ export class Hub {
2306
2695
  return this.store.mainTurn(conv) !== null || this.store.pendingMainUsers(conv).length > 0;
2307
2696
  }
2308
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
+
2309
2706
  private completeTurn(conv: string, status: "completed" | "interrupted", pendingInputSeqs: number[] = []): void {
2310
2707
  const turn = this.store.mainTurn(conv);
2311
2708
  const source = this.store.mainConsumedSource(conv);
@@ -2473,6 +2870,29 @@ export class Hub {
2473
2870
  private openSession(conv: ConversationInfo) {
2474
2871
  const storedPath = conv.threadId?.startsWith("/") ? conv.threadId : null;
2475
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
+ }
2476
2896
  const opened = this.requireOmo().command<{ sessionId: string; attached?: boolean; state?: {
2477
2897
  isStreaming?: boolean; pendingQuestions?: ScopedQuestionFrame[];
2478
2898
  clientInputIdentity?: boolean; atomicQueuedResume?: boolean;
@@ -2480,15 +2900,18 @@ export class Hub {
2480
2900
  } }>("open_session", {
2481
2901
  sessionPath,
2482
2902
  // Native ignores creation defaults when the session file already exists.
2483
- ...(this.savedModel() ?? {}),
2484
- cwd: this.options.cwd,
2903
+ ...(model ?? {}),
2904
+ ...(typeof agent?.fields.thinking === "string" ? { thinkingLevel: agent.fields.thinking } : {}),
2905
+ cwd,
2485
2906
  permissionPreset: this.options.permissionPreset,
2486
2907
  retain_on_disconnect: true,
2487
2908
  kind: "interactive",
2488
2909
  promptSurface: "app",
2489
- 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" },
2490
2913
  });
2491
- return { sessionPath, opened };
2914
+ return { sessionPath, opened, agent };
2492
2915
  }
2493
2916
 
2494
2917
  private watchAsk(conv: string, toolCallId: string, args: unknown): void {
@@ -2533,6 +2956,7 @@ export class Hub {
2533
2956
 
2534
2957
  private async ensureSession(conv: ConversationInfo, duringRotation = false): Promise<OpenResult> {
2535
2958
  if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
2959
+ if (!duringRotation) this.requireVisibleAgent(conv.id);
2536
2960
  if (this.omoStatus !== "ready") throw new Error("omo_not_ready");
2537
2961
  if (this.closeUncertain(conv.id)) throw new Error("session_close_unknown: source retained; verify native closure before retry");
2538
2962
  const rt = this.rt(conv.id);
@@ -2540,9 +2964,9 @@ export class Hub {
2540
2964
  if (rt.sessionId) return { sessionId: rt.sessionId, streaming: rt.running };
2541
2965
  if (rt.opening) return rt.opening;
2542
2966
  const generation = this.generation;
2543
- const { sessionPath, opened } = this.openSession(conv);
2967
+ const { sessionPath, opened, agent } = this.openSession(conv);
2544
2968
  const opening = opened
2545
- .then((data) => {
2969
+ .then(async (data) => {
2546
2970
  if (generation !== this.generation || this.omoStatus !== "ready") throw new Error("stale_open_session");
2547
2971
  rt.sessionId = data.sessionId;
2548
2972
  rt.durableId = typeof data.state?.sessionId === "string" ? data.state.sessionId : null;
@@ -2556,6 +2980,20 @@ export class Hub {
2556
2980
  }
2557
2981
  const owner = { conv: conv.id, scope: this.conversationScope(conv.id), sessionId: data.sessionId, sessionPath };
2558
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
+ }
2559
2997
  this.syncScopedQuestions(owner, data.state?.pendingQuestions ?? []);
2560
2998
  this.publishActivity(conv.id);
2561
2999
  return { sessionId: data.sessionId, streaming: data.state?.isStreaming === true };
@@ -2577,7 +3015,7 @@ export class Hub {
2577
3015
  if (!rt) {
2578
3016
  rt = {
2579
3017
  sessionId: null, opening: null, running: false, run: 0, rotating: false, admitting: 0, clientInputIdentity: false, atomicQueuedResume: false, durableId: null,
2580
- waking: null, wake: null, notified: new Set(),
3018
+ waking: null, wake: null, notified: new Set(), promptAdmissions: new Set(),
2581
3019
  itemId: null, itemText: "", statsTurnId: null, statsMessageIndex: 0,
2582
3020
  rotationDone: null, finishRotation: null,
2583
3021
  };
@@ -2632,9 +3070,9 @@ function unreachableQuestionText(args: unknown): string {
2632
3070
  const questions = ((args as { questions?: unknown } | undefined)?.questions ?? []) as Partial<QuestionItem>[];
2633
3071
  const lines = questions.map((q) => {
2634
3072
  const options = (q.options ?? []).map((o) => o.label).join(" / ");
2635
- 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}` : ""}`;
2636
3074
  });
2637
- return ["질문 창이 웹에 도착하지 않았습니다. 중단 버튼을 누른 뒤 채팅으로 답해 주세요.", ...lines].join("\n");
3075
+ return [say(HUB_TEXT.questionUnreachable), ...lines].join("\n");
2638
3076
  }
2639
3077
 
2640
3078
  /** 2026-09-26T15:43:01.123Z -> 20260926T154301Z */
@@ -2705,8 +3143,10 @@ function turnFinished(entries: readonly LedgerEntry[]): boolean {
2705
3143
  return last?.role === "assistant" && last.stopReason === "stop";
2706
3144
  }
2707
3145
 
2708
- function recoveredNote(count: number): string {
2709
- 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 });
2710
3150
  }
2711
3151
 
2712
3152
  function errorMessage(error: unknown): string {