@sayknow-cli/coding-agent 0.2.7 → 0.3.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 (216) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/types/async/job-manager.d.ts +3 -1
  3. package/dist/types/cli/daemon-cli.d.ts +25 -0
  4. package/dist/types/cli/notify-cli.d.ts +25 -0
  5. package/dist/types/cli/setup-cli.d.ts +20 -1
  6. package/dist/types/commands/daemon.d.ts +41 -0
  7. package/dist/types/commands/notify.d.ts +41 -0
  8. package/dist/types/config/model-profile-activation.d.ts +12 -0
  9. package/dist/types/config/model-profiles.d.ts +2 -1
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/models-config-schema.d.ts +5 -0
  12. package/dist/types/config/settings-schema.d.ts +136 -3
  13. package/dist/types/coordinator/contract.d.ts +1 -1
  14. package/dist/types/daemon/builtin.d.ts +20 -0
  15. package/dist/types/daemon/control-types.d.ts +57 -0
  16. package/dist/types/daemon/runtime.d.ts +25 -0
  17. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  18. package/dist/types/extensibility/shared-events.d.ts +1 -0
  19. package/dist/types/i18n/messages/en.d.ts +1 -0
  20. package/dist/types/lsp/types.d.ts +2 -0
  21. package/dist/types/modes/components/oauth-selector.d.ts +2 -0
  22. package/dist/types/modes/components/settings-defs.d.ts +1 -0
  23. package/dist/types/modes/controllers/selector-controller.d.ts +2 -2
  24. package/dist/types/modes/interactive-mode.d.ts +1 -1
  25. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  26. package/dist/types/modes/theme/theme.d.ts +1 -1
  27. package/dist/types/modes/types.d.ts +7 -1
  28. package/dist/types/notifications/attachment-registry.d.ts +17 -0
  29. package/dist/types/notifications/chat-adapters.d.ts +9 -0
  30. package/dist/types/notifications/config-commands.d.ts +26 -0
  31. package/dist/types/notifications/config.d.ts +69 -0
  32. package/dist/types/notifications/engine.d.ts +59 -0
  33. package/dist/types/notifications/helpers.d.ts +55 -0
  34. package/dist/types/notifications/html-format.d.ts +62 -0
  35. package/dist/types/notifications/index.d.ts +28 -0
  36. package/dist/types/notifications/managed-daemon.d.ts +48 -0
  37. package/dist/types/notifications/rate-limit-pool.d.ts +93 -0
  38. package/dist/types/notifications/telegram-cli.d.ts +19 -0
  39. package/dist/types/notifications/telegram-daemon-cli.d.ts +11 -0
  40. package/dist/types/notifications/telegram-daemon-control.d.ts +56 -0
  41. package/dist/types/notifications/telegram-daemon.d.ts +295 -0
  42. package/dist/types/notifications/telegram-reference.d.ts +111 -0
  43. package/dist/types/notifications/threaded-inbound.d.ts +77 -0
  44. package/dist/types/notifications/threaded-render.d.ts +71 -0
  45. package/dist/types/notifications/topic-registry.d.ts +67 -0
  46. package/dist/types/rlm/index.d.ts +12 -0
  47. package/dist/types/session/agent-session.d.ts +41 -2
  48. package/dist/types/session/auth-storage.d.ts +1 -1
  49. package/dist/types/setup/credential-auto-import.d.ts +63 -0
  50. package/dist/types/setup/credential-import.d.ts +3 -0
  51. package/dist/types/setup/host-plugin-setup.d.ts +39 -0
  52. package/dist/types/skc-runtime/launch-tmux.d.ts +1 -0
  53. package/dist/types/skc-runtime/ralplan-runtime.d.ts +1 -1
  54. package/dist/types/skc-runtime/state-writer.d.ts +2 -0
  55. package/dist/types/skc-runtime/tmux-common.d.ts +3 -0
  56. package/dist/types/skc-runtime/tmux-sessions.d.ts +2 -0
  57. package/dist/types/skc-runtime/ultragoal-guard.d.ts +15 -0
  58. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +14 -0
  59. package/dist/types/tools/ask-answer-registry.d.ts +13 -0
  60. package/dist/types/tools/fetch.d.ts +23 -0
  61. package/dist/types/tools/index.d.ts +19 -0
  62. package/dist/types/tools/subagent.d.ts +3 -0
  63. package/dist/types/tools/telegram-send.d.ts +32 -0
  64. package/dist/types/web/insane/bridge.d.ts +103 -0
  65. package/dist/types/web/insane/url-guard.d.ts +22 -0
  66. package/dist/types/web/search/provider.d.ts +18 -1
  67. package/dist/types/web/search/providers/insane.d.ts +53 -0
  68. package/dist/types/web/search/providers/text-citations.d.ts +23 -0
  69. package/dist/types/web/search/types.d.ts +12 -4
  70. package/package.json +10 -8
  71. package/scripts/build-binary.ts +3 -0
  72. package/scripts/verify-insane-vendor.ts +132 -0
  73. package/src/async/job-manager.ts +5 -1
  74. package/src/cli/args.ts +1 -1
  75. package/src/cli/daemon-cli.ts +122 -0
  76. package/src/cli/fast-help.ts +1 -1
  77. package/src/cli/notify-cli.ts +421 -0
  78. package/src/cli/setup-cli.ts +173 -84
  79. package/src/cli.ts +3 -3
  80. package/src/commands/daemon.ts +47 -0
  81. package/src/commands/notify.ts +61 -0
  82. package/src/commands/setup.ts +11 -1
  83. package/src/commands/team.ts +1 -1
  84. package/src/config/model-profile-activation.ts +74 -5
  85. package/src/config/model-profiles.ts +7 -4
  86. package/src/config/model-registry.ts +6 -3
  87. package/src/config/models-config-schema.ts +1 -1
  88. package/src/config/settings-schema.ts +143 -0
  89. package/src/coordinator/contract.ts +3 -0
  90. package/src/coordinator-mcp/server.ts +270 -1
  91. package/src/daemon/builtin.ts +46 -0
  92. package/src/daemon/control-types.ts +65 -0
  93. package/src/daemon/runtime.ts +51 -0
  94. package/src/defaults/skc/rules/ponytail.md +68 -0
  95. package/src/defaults/skc/skills/ralplan/SKILL.md +11 -4
  96. package/src/defaults/skc/skills/ultragoal/SKILL.md +16 -0
  97. package/src/edit/modes/replace.ts +1 -1
  98. package/src/extensibility/extensions/runner.ts +4 -0
  99. package/src/extensibility/extensions/types.ts +8 -0
  100. package/src/extensibility/shared-events.ts +1 -0
  101. package/src/goals/tools/goal-tool.ts +11 -2
  102. package/src/hashline/hash.ts +1 -1
  103. package/src/i18n/messages/de.ts +1 -0
  104. package/src/i18n/messages/en.ts +1 -0
  105. package/src/i18n/messages/es.ts +1 -0
  106. package/src/i18n/messages/fr.ts +1 -0
  107. package/src/i18n/messages/ja.ts +1 -0
  108. package/src/i18n/messages/ko.ts +1 -0
  109. package/src/i18n/messages/zh.ts +1 -0
  110. package/src/internal-urls/docs-index.generated.ts +12 -10
  111. package/src/lsp/config.ts +16 -3
  112. package/src/lsp/defaults.json +7 -0
  113. package/src/lsp/types.ts +2 -0
  114. package/src/main.ts +30 -0
  115. package/src/modes/acp/acp-event-mapper.ts +1 -0
  116. package/src/modes/components/hook-editor.ts +7 -2
  117. package/src/modes/components/oauth-selector.ts +19 -0
  118. package/src/modes/components/settings-defs.ts +2 -1
  119. package/src/modes/components/settings-selector.ts +7 -2
  120. package/src/modes/controllers/event-controller.ts +35 -0
  121. package/src/modes/controllers/selector-controller.ts +80 -17
  122. package/src/modes/interactive-mode.ts +52 -4
  123. package/src/modes/runtime-init.ts +1 -0
  124. package/src/modes/shared/agent-wire/event-contract.ts +1 -0
  125. package/src/modes/shared/agent-wire/event-envelope.ts +1 -0
  126. package/src/modes/shared/agent-wire/event-observation.ts +16 -0
  127. package/src/modes/shared/agent-wire/unattended-session.ts +22 -0
  128. package/src/modes/theme/theme.ts +4 -0
  129. package/src/modes/types.ts +7 -1
  130. package/src/modes/utils/context-usage.ts +2 -2
  131. package/src/modes/utils/ui-helpers.ts +23 -0
  132. package/src/notifications/attachment-registry.ts +23 -0
  133. package/src/notifications/chat-adapters.ts +147 -0
  134. package/src/notifications/config-commands.ts +50 -0
  135. package/src/notifications/config.ts +128 -0
  136. package/src/notifications/engine.ts +100 -0
  137. package/src/notifications/helpers.ts +135 -0
  138. package/src/notifications/html-format.ts +389 -0
  139. package/src/notifications/index.ts +842 -0
  140. package/src/notifications/managed-daemon.ts +163 -0
  141. package/src/notifications/rate-limit-pool.ts +179 -0
  142. package/src/notifications/telegram-cli.ts +194 -0
  143. package/src/notifications/telegram-daemon-cli.ts +74 -0
  144. package/src/notifications/telegram-daemon-control.ts +370 -0
  145. package/src/notifications/telegram-daemon.ts +1591 -0
  146. package/src/notifications/telegram-reference.ts +335 -0
  147. package/src/notifications/threaded-inbound.ts +136 -0
  148. package/src/notifications/threaded-render.ts +173 -0
  149. package/src/notifications/topic-registry.ts +133 -0
  150. package/src/rlm/index.ts +19 -0
  151. package/src/sdk.ts +16 -0
  152. package/src/session/agent-session.ts +195 -54
  153. package/src/session/auth-storage.ts +3 -0
  154. package/src/session/session-dump-format.ts +43 -2
  155. package/src/session/session-manager.ts +39 -5
  156. package/src/setup/credential-auto-import.ts +258 -0
  157. package/src/setup/credential-import.ts +17 -0
  158. package/src/setup/hermes/templates/operator-instructions.v1.md +10 -0
  159. package/src/setup/host-plugin-setup.ts +142 -0
  160. package/src/skc-runtime/deep-interview-recorder.ts +2 -2
  161. package/src/skc-runtime/launch-tmux.ts +27 -5
  162. package/src/skc-runtime/ledger-event-renderer.ts +1 -0
  163. package/src/skc-runtime/ralplan-runtime.ts +2 -2
  164. package/src/skc-runtime/state-runtime.ts +18 -10
  165. package/src/skc-runtime/state-writer.ts +8 -8
  166. package/src/skc-runtime/tmux-common.ts +8 -0
  167. package/src/skc-runtime/tmux-sessions.ts +8 -1
  168. package/src/skc-runtime/ultragoal-guard.ts +57 -2
  169. package/src/skc-runtime/ultragoal-runtime.ts +105 -19
  170. package/src/skc-runtime/workflow-manifest.generated.json +56 -2
  171. package/src/skc-runtime/workflow-manifest.ts +18 -3
  172. package/src/slash-commands/builtin-registry.ts +4 -1
  173. package/src/task/executor.ts +5 -1
  174. package/src/tools/ask-answer-registry.ts +25 -0
  175. package/src/tools/ask.ts +77 -6
  176. package/src/tools/fetch.ts +78 -1
  177. package/src/tools/image-gen.ts +5 -8
  178. package/src/tools/index.ts +22 -0
  179. package/src/tools/inspect-image.ts +16 -11
  180. package/src/tools/subagent-render.ts +7 -0
  181. package/src/tools/subagent.ts +38 -7
  182. package/src/tools/telegram-send.ts +137 -0
  183. package/src/web/insane/bridge.ts +350 -0
  184. package/src/web/insane/url-guard.ts +155 -0
  185. package/src/web/search/provider.ts +77 -18
  186. package/src/web/search/providers/anthropic.ts +70 -3
  187. package/src/web/search/providers/codex.ts +1 -119
  188. package/src/web/search/providers/gemini.ts +99 -0
  189. package/src/web/search/providers/insane.ts +551 -0
  190. package/src/web/search/providers/openai-compatible.ts +66 -32
  191. package/src/web/search/providers/text-citations.ts +111 -0
  192. package/src/web/search/types.ts +13 -2
  193. package/vendor/insane-search/LICENSE +21 -0
  194. package/vendor/insane-search/MANIFEST.json +24 -0
  195. package/vendor/insane-search/engine/__init__.py +23 -0
  196. package/vendor/insane-search/engine/__main__.py +128 -0
  197. package/vendor/insane-search/engine/bias_check.py +183 -0
  198. package/vendor/insane-search/engine/executor.py +254 -0
  199. package/vendor/insane-search/engine/fetch_chain.py +725 -0
  200. package/vendor/insane-search/engine/learning.py +175 -0
  201. package/vendor/insane-search/engine/phase0.py +214 -0
  202. package/vendor/insane-search/engine/safety.py +91 -0
  203. package/vendor/insane-search/engine/templates/package.json +11 -0
  204. package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
  205. package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
  206. package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
  207. package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
  208. package/vendor/insane-search/engine/tests/test_u1.py +200 -0
  209. package/vendor/insane-search/engine/tests/test_u4.py +131 -0
  210. package/vendor/insane-search/engine/tests/test_u5.py +163 -0
  211. package/vendor/insane-search/engine/tests/test_u7.py +124 -0
  212. package/vendor/insane-search/engine/transport.py +211 -0
  213. package/vendor/insane-search/engine/url_transforms.py +98 -0
  214. package/vendor/insane-search/engine/validators.py +331 -0
  215. package/vendor/insane-search/engine/waf_detector.py +214 -0
  216. package/vendor/insane-search/engine/waf_profiles.yaml +162 -0
@@ -0,0 +1,842 @@
1
+ /**
2
+ * Notifications extension.
3
+ *
4
+ * Hosts a per-session loopback WebSocket notification server (the Rust core via
5
+ * N-API) and bridges SKC session events + the `ask` tool to it so a remote client
6
+ * (e.g. a Telegram bot) can both see action-needed signals and ANSWER them —
7
+ * without requiring RPC/unattended mode:
8
+ *
9
+ * - `ask` (interactive): registers an {@link AskAnswerSource}; the ask tool races
10
+ * the local UI against a remote reply. First valid answer wins; a local answer
11
+ * aborts the remote wait (and broadcasts `action_resolved` resolvedBy=local).
12
+ * - `ask` (unattended/RPC): observes emitted workflow gates and resolves the real
13
+ * gate on a remote reply via `ctx.workflowGate`.
14
+ * - `turn_end` -> `action_needed` (kind `idle`, deduped per turn).
15
+ * - `session_shutdown` -> stop the server + deregister the answer source.
16
+ *
17
+ * Enable with Settings notifications config, `SKC_NOTIFICATIONS=1` (a token is
18
+ * generated), or `SKC_NOTIFICATIONS_TOKEN`.
19
+ */
20
+
21
+ import { execFile } from "node:child_process";
22
+ import * as crypto from "node:crypto";
23
+ import * as fs from "node:fs";
24
+ import * as os from "node:os";
25
+ import * as path from "node:path";
26
+ import { promisify } from "node:util";
27
+ import type { ImageContent, TextContent } from "@sayknow-cli/ai";
28
+ import { NotificationServer } from "@sayknow-cli/natives";
29
+ import { logger } from "@sayknow-cli/utils";
30
+ import { Settings } from "../config/settings";
31
+ import type { ExtensionCommandContext, ExtensionContext, ExtensionFactory } from "../extensibility/extensions";
32
+ import { registerAskAnswerSource } from "../tools/ask-answer-registry";
33
+ import { registerTelegramFileSink } from "./attachment-registry";
34
+ import {
35
+ getNotificationConfig,
36
+ isGloballyConfigured,
37
+ isSessionNotificationsEnabled,
38
+ type NotificationConfig,
39
+ sessionTag,
40
+ } from "./config";
41
+ import { imageAttachmentsFromMessage, notificationActionPayload, summaryFromMessage } from "./helpers";
42
+ import { ensureTelegramDaemonRunning } from "./telegram-daemon";
43
+
44
+ /** Resolve the git dir for `cwd`, handling worktrees where `.git` is a file. */
45
+ function gitDir(cwd: string): string | undefined {
46
+ const dot = path.join(cwd, ".git");
47
+ try {
48
+ if (fs.statSync(dot).isDirectory()) return dot;
49
+ const m = fs
50
+ .readFileSync(dot, "utf8")
51
+ .trim()
52
+ .match(/^gitdir:\s*(.+)$/);
53
+ if (m) return path.resolve(cwd, m[1]);
54
+ } catch {}
55
+ return undefined;
56
+ }
57
+
58
+ /** Best-effort current branch from `.git/HEAD` (no git spawn). */
59
+ function readGitBranch(cwd: string): string | undefined {
60
+ const gd = gitDir(cwd);
61
+ if (!gd) return undefined;
62
+ try {
63
+ const head = fs.readFileSync(path.join(gd, "HEAD"), "utf8").trim();
64
+ const m = head.match(/^ref:\s*refs\/heads\/(.+)$/);
65
+ return m ? m[1] : head.slice(0, 12);
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ }
70
+
71
+ /** Resolve the shared git dir (the main repo's `.git`) for a possibly-linked worktree. */
72
+ function gitCommonDir(gd: string): string {
73
+ try {
74
+ const raw = fs.readFileSync(path.join(gd, "commondir"), "utf8").trim();
75
+ if (raw) return path.resolve(gd, raw);
76
+ } catch {}
77
+ return gd;
78
+ }
79
+
80
+ /**
81
+ * Best-effort real repository name (no git spawn): resolves the main worktree
82
+ * root directory so linked worktrees report the repo (e.g. `sayknow-cli`)
83
+ * instead of the worktree directory (e.g. `feat-foo-01047f11`).
84
+ */
85
+ export function readGitRepoName(cwd: string): string | undefined {
86
+ const gd = gitDir(cwd);
87
+ if (!gd) return undefined;
88
+ const commonDir = gitCommonDir(gd);
89
+ // Strip the trailing `.git` to land on the main worktree root directory.
90
+ const repoRoot = path.basename(commonDir) === ".git" ? path.dirname(commonDir) : commonDir;
91
+ const name = path.basename(repoRoot);
92
+ return name && name !== ".git" ? name : undefined;
93
+ }
94
+
95
+ /** Build the one-time identity header fields for a session thread. */
96
+ function buildIdentity(
97
+ cwd: string,
98
+ sessionName?: string,
99
+ ): {
100
+ repo: string;
101
+ branch: string;
102
+ machine: string;
103
+ title?: string;
104
+ } {
105
+ const repo = readGitRepoName(cwd) ?? (path.basename(cwd) || cwd);
106
+ const branch = readGitBranch(cwd) ?? "(detached)";
107
+ // Send repo/branch and the raw session title separately; the consumer
108
+ // composes the topic name ("{repo}/{branch}" before the session title is
109
+ // auto-generated, then "{repo}/{branch} - {session title}" once it exists).
110
+ return { repo, branch, machine: os.hostname(), title: sessionName };
111
+ }
112
+
113
+ const execFileAsync = promisify(execFile);
114
+
115
+ /** Best-effort working-tree diff stat for the context update (no throw). */
116
+ async function readGitDiffStat(cwd: string): Promise<string | undefined> {
117
+ try {
118
+ const { stdout } = await execFileAsync("git", ["-C", cwd, "diff", "--stat", "--no-color"], {
119
+ timeout: 3000,
120
+ maxBuffer: 256 * 1024,
121
+ });
122
+ const trimmed = stdout.trim();
123
+ return trimmed ? trimmed.slice(0, 1500) : undefined;
124
+ } catch {
125
+ return undefined;
126
+ }
127
+ }
128
+
129
+ interface PendingInteractiveAsk {
130
+ resolve: (label: string | undefined) => void;
131
+ options: string[];
132
+ }
133
+
134
+ interface SessionRuntime {
135
+ server: NotificationServer;
136
+ idleSeq: number;
137
+ /** Interactive asks awaiting a remote answer, by action id. */
138
+ pendingInteractive: Map<string, PendingInteractiveAsk>;
139
+ /** Deregisters this session's ask answer source. */
140
+ disposeAnswerSource: () => void;
141
+ /** Deregisters this session's Telegram file sink. */
142
+ disposeFileSink: () => void;
143
+ redact: boolean;
144
+ sessionTag: string;
145
+ /** Whether the agent loop is currently running (drives the typing indicator). */
146
+ busy: boolean;
147
+ /** Inbound Telegram update ids injected but not yet consumed by a turn. */
148
+ pendingInbound: Set<number>;
149
+ /** Latest assistant text of the in-flight turn (from message_update). */
150
+ currentTurnText?: string;
151
+ /** Assistant text already flushed before an ask this turn (turn-scoped dedupe
152
+ * so turn_end does not re-emit the pre-ask lead-in). Reset each turn. */
153
+ preAskFlushedText?: string;
154
+ }
155
+
156
+ interface ResolvedSettings {
157
+ settings: Settings | undefined;
158
+ cfg: NotificationConfig;
159
+ settingsAvailable: boolean;
160
+ }
161
+
162
+ const defaultConfig: NotificationConfig = {
163
+ enabled: false,
164
+ botToken: undefined,
165
+ chatId: undefined,
166
+ discord: {
167
+ botToken: undefined,
168
+ channelId: undefined,
169
+ },
170
+ slack: {
171
+ botToken: undefined,
172
+ channelId: undefined,
173
+ },
174
+ redact: false,
175
+ verbosity: "lean",
176
+ idleTimeoutMs: 60_000,
177
+ };
178
+
179
+ export function notificationsEnabled(): boolean {
180
+ return process.env.SKC_NOTIFICATIONS === "1" || Boolean(process.env.SKC_NOTIFICATIONS_TOKEN);
181
+ }
182
+
183
+ function resolveSettings(): ResolvedSettings {
184
+ try {
185
+ const settings = Settings.instance;
186
+ return { settings, cfg: getNotificationConfig(settings), settingsAvailable: true };
187
+ } catch {
188
+ return { settings: undefined, cfg: defaultConfig, settingsAvailable: false };
189
+ }
190
+ }
191
+
192
+ function resolveToken(): string {
193
+ return process.env.SKC_NOTIFICATIONS_TOKEN ?? crypto.randomBytes(24).toString("base64url");
194
+ }
195
+
196
+ function parseAnswer(answerJson: string): unknown {
197
+ try {
198
+ return JSON.parse(answerJson);
199
+ } catch {
200
+ return answerJson;
201
+ }
202
+ }
203
+
204
+ /** Map a client answer to the option LABEL the local UI would return (or free text). */
205
+ function mapAnswerToLabel(answerJson: string, options: string[]): string | undefined {
206
+ const answer = parseAnswer(answerJson);
207
+ if (typeof answer === "number") return options[answer];
208
+ if (typeof answer === "string") return answer;
209
+ if (answer && typeof answer === "object") {
210
+ const sel = (answer as { selected?: unknown; custom?: unknown }).selected;
211
+ if (Array.isArray(sel) && sel.length > 0) {
212
+ const first = sel[0];
213
+ return typeof first === "number" ? options[first] : String(first);
214
+ }
215
+ const custom = (answer as { custom?: unknown }).custom;
216
+ if (typeof custom === "string") return custom;
217
+ }
218
+ return undefined;
219
+ }
220
+
221
+ /** Map a client answer to the workflow-gate answer shape (unattended mode). */
222
+ function mapAnswerToGate(
223
+ answerJson: string,
224
+ options: string[],
225
+ ): { selected: string[]; other?: boolean; custom?: string } {
226
+ const answer = parseAnswer(answerJson);
227
+ if (typeof answer === "number") {
228
+ const label = options[answer];
229
+ return label === undefined ? { selected: [], other: true, custom: String(answer) } : { selected: [label] };
230
+ }
231
+ if (typeof answer === "string") {
232
+ return options.includes(answer) ? { selected: [answer] } : { selected: [], other: true, custom: answer };
233
+ }
234
+ if (answer && typeof answer === "object") {
235
+ const obj = answer as { selected?: unknown; custom?: unknown };
236
+ const selected = Array.isArray(obj.selected)
237
+ ? obj.selected.map(s => (typeof s === "number" ? (options[s] ?? String(s)) : String(s)))
238
+ : [];
239
+ const custom = typeof obj.custom === "string" ? obj.custom : undefined;
240
+ return { selected, other: custom !== undefined, custom };
241
+ }
242
+ return { selected: [] };
243
+ }
244
+
245
+ /** Register the interactive `ask` answer source for a session (the ask tool
246
+ * races the local UI against a remote reply). Returns the deregister disposer. */
247
+ function registerInteractiveAnswerSource(
248
+ id: string,
249
+ server: NotificationServer,
250
+ pendingInteractive: Map<string, PendingInteractiveAsk>,
251
+ redact: boolean,
252
+ tag: string,
253
+ ): () => void {
254
+ return registerAskAnswerSource(id, {
255
+ awaitAnswer(question, options, signal) {
256
+ if (signal?.aborted) return Promise.resolve(undefined);
257
+ const askId = `ask:${crypto.randomUUID()}`;
258
+ try {
259
+ server.registerAsk(
260
+ JSON.stringify(
261
+ notificationActionPayload(
262
+ { id: askId, kind: "ask", sessionId: id, question, options },
263
+ { redact, sessionTag: tag },
264
+ ),
265
+ ),
266
+ true,
267
+ );
268
+ } catch (e) {
269
+ logger.warn(`notifications: registerAsk failed: ${String(e)}`);
270
+ return Promise.resolve(undefined);
271
+ }
272
+ return new Promise<string | undefined>(resolve => {
273
+ pendingInteractive.set(askId, { resolve, options });
274
+ signal?.addEventListener("abort", () => {
275
+ if (!pendingInteractive.delete(askId)) return;
276
+ // Local UI answered: mark the remote action resolved-locally.
277
+ try {
278
+ server.resolveLocal(askId, undefined);
279
+ } catch {}
280
+ resolve(undefined);
281
+ });
282
+ });
283
+ },
284
+ });
285
+ }
286
+
287
+ /** Extract the session id from a `<timestamp>_<uuid>.jsonl` session file path. */
288
+ function sessionIdFromFile(file: string | undefined): string | undefined {
289
+ if (!file) return undefined;
290
+ const base = path.basename(file).replace(/\.jsonl$/, "");
291
+ const underscore = base.indexOf("_");
292
+ return underscore >= 0 ? base.slice(underscore + 1) : undefined;
293
+ }
294
+
295
+ export const createNotificationsExtension: ExtensionFactory = api => {
296
+ const runtimes = new Map<string, SessionRuntime>();
297
+ const disabledSessions = new Set<string>();
298
+ const sessionId = (ctx: ExtensionContext): string => ctx.sessionManager.getSessionId();
299
+
300
+ function stopSession(id: string): boolean {
301
+ const rt = runtimes.get(id);
302
+ if (!rt) return false;
303
+ runtimes.delete(id);
304
+ try {
305
+ rt.disposeAnswerSource();
306
+ rt.disposeFileSink();
307
+ } catch {}
308
+ // Resolve any still-pending interactive asks so the ask tool is not left hanging.
309
+ for (const pending of rt.pendingInteractive.values()) pending.resolve(undefined);
310
+ rt.pendingInteractive.clear();
311
+ try {
312
+ rt.server.stop();
313
+ } catch (e) {
314
+ logger.warn(`notifications: stop failed: ${String(e)}`);
315
+ }
316
+ return true;
317
+ }
318
+
319
+ function isEnabledForSession(id: string, cfg: NotificationConfig): boolean {
320
+ return isSessionNotificationsEnabled({ cfg, env: process.env, sessionDisabled: disabledSessions.has(id) });
321
+ }
322
+
323
+ async function startSession(ctx: ExtensionContext): Promise<"started" | "already" | "disabled" | "failed"> {
324
+ const id = sessionId(ctx);
325
+ const { settings, cfg, settingsAvailable } = resolveSettings();
326
+ if (!isEnabledForSession(id, cfg)) return "disabled";
327
+ if (runtimes.has(id)) return "already";
328
+
329
+ const stateRoot = path.join(ctx.cwd, ".skc", "state");
330
+ const gate = ctx.workflowGate;
331
+ const unattended =
332
+ gate?.isUnattended?.() === true &&
333
+ typeof gate.onGateEmitted === "function" &&
334
+ typeof gate.resolveGate === "function";
335
+ const gateOptions = new Map<string, string[]>();
336
+ const pendingInteractive = new Map<string, PendingInteractiveAsk>();
337
+ const tag = sessionTag(id);
338
+ const redact = cfg.redact;
339
+ let runtime: SessionRuntime | undefined;
340
+
341
+ // The SDK can always answer now (interactive via the answer source, or the
342
+ // unattended gate), so the endpoint advertises a resolver.
343
+ const server = new NotificationServer(id, resolveToken(), stateRoot, true);
344
+
345
+ server.onReply((err, reply) => {
346
+ if (err || !reply) return;
347
+ // 1) Interactive ask awaiting a remote answer.
348
+ const pending = pendingInteractive.get(reply.id);
349
+ if (pending) {
350
+ pendingInteractive.delete(reply.id);
351
+ const label = mapAnswerToLabel(reply.answerJson, pending.options);
352
+ try {
353
+ server.resolveClient(reply.id, reply.answerJson, reply.idempotencyKey ?? undefined);
354
+ } catch (e) {
355
+ logger.warn(`notifications: resolveClient failed: ${String(e)}`);
356
+ }
357
+ pending.resolve(label);
358
+ return;
359
+ }
360
+ // 2) Unattended workflow gate: resolve the real gate, then confirm.
361
+ if (unattended && gate?.resolveGate) {
362
+ const answer = mapAnswerToGate(reply.answerJson, gateOptions.get(reply.id) ?? []);
363
+ gate
364
+ .resolveGate({ gate_id: reply.id, answer, idempotency_key: reply.idempotencyKey ?? undefined })
365
+ .then(() => server.resolveClient(reply.id, reply.answerJson, reply.idempotencyKey ?? undefined))
366
+ .catch(e => {
367
+ logger.warn(`notifications: resolveGate failed: ${String(e)}`);
368
+ try {
369
+ server.reject(reply.id, "invalid_answer");
370
+ } catch {}
371
+ });
372
+ return;
373
+ }
374
+ // 3) No matching pending ask.
375
+ try {
376
+ server.reject(reply.id, "unknown_action");
377
+ } catch (e) {
378
+ logger.warn(`notifications: reject failed: ${String(e)}`);
379
+ }
380
+ });
381
+
382
+ // Inbound free-text injection / in-thread config command from a session
383
+ // thread (forwarded by the daemon over the WS, fail-closed at the daemon).
384
+ server.onInbound((err, inbound) => {
385
+ if (err || !inbound) return;
386
+ if (inbound.kind === "user_message") {
387
+ // Inject as a user turn (steers/continues the agent; the resulting
388
+ // turn streams back via the turn_end handler even when not idle).
389
+ // Record the update id so it can be acked as "consumed" on the next
390
+ // turn_start, and steer (vs start a fresh turn) when already busy.
391
+ const text = inbound.text ?? "";
392
+ const images = inbound.images ?? [];
393
+ if (!text && images.length === 0) return;
394
+ if (runtime && typeof inbound.updateId === "number") runtime.pendingInbound.add(inbound.updateId);
395
+ const content: string | (TextContent | ImageContent)[] =
396
+ images.length > 0
397
+ ? [
398
+ ...(text ? [{ type: "text", text } as TextContent] : []),
399
+ ...images.map(
400
+ img =>
401
+ ({ type: "image", data: img.data, mimeType: img.mime ?? "image/jpeg" }) as ImageContent,
402
+ ),
403
+ ]
404
+ : text;
405
+ try {
406
+ api.sendUserMessage(content, runtime?.busy ? { deliverAs: "steer" } : undefined);
407
+ } catch (e) {
408
+ logger.warn(`notifications: sendUserMessage failed: ${String(e)}`);
409
+ }
410
+ return;
411
+ }
412
+ if (inbound.kind === "config_command") {
413
+ if (runtime && typeof inbound.redact === "boolean") runtime.redact = inbound.redact;
414
+ }
415
+ });
416
+
417
+ try {
418
+ const endpoint = await server.start();
419
+
420
+ // Interactive answer source: the ask tool races the local UI against this.
421
+ const disposeAnswerSource = registerInteractiveAnswerSource(id, server, pendingInteractive, redact, tag);
422
+ const disposeFileSink = registerTelegramFileSink(id, async file => {
423
+ try {
424
+ const data = await fs.promises.readFile(file.path);
425
+ server.pushFrame(
426
+ JSON.stringify({
427
+ type: "file_attachment",
428
+ sessionId: id,
429
+ name: path.basename(file.path),
430
+ data: data.toString("base64"),
431
+ caption: file.caption,
432
+ }),
433
+ );
434
+ return { ok: true };
435
+ } catch (e) {
436
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
437
+ }
438
+ });
439
+
440
+ runtime = {
441
+ server,
442
+ idleSeq: 0,
443
+ pendingInteractive,
444
+ disposeAnswerSource,
445
+ disposeFileSink,
446
+ redact,
447
+ sessionTag: tag,
448
+ busy: false,
449
+ pendingInbound: new Set<number>(),
450
+ };
451
+ runtimes.set(id, runtime);
452
+ logger.info(`notifications: serving session ${id} at ${endpoint.url} (unattended=${unattended})`);
453
+
454
+ if (settingsAvailable && settings && isGloballyConfigured(cfg)) {
455
+ try {
456
+ await ensureTelegramDaemonRunning({ settings, cwd: ctx.cwd, sessionId: id });
457
+ } catch (e) {
458
+ logger.warn(`notifications: failed to ensure Telegram daemon: ${String(e)}`);
459
+ }
460
+ }
461
+
462
+ // One-time identity header (repo/branch/machine/session) pinned at the top
463
+ // of the session thread by the daemon.
464
+ try {
465
+ server.pushFrame(
466
+ JSON.stringify({
467
+ type: "identity_header",
468
+ sessionId: id,
469
+ ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
470
+ }),
471
+ );
472
+ } catch (e) {
473
+ logger.warn(`notifications: identity_header failed: ${String(e)}`);
474
+ }
475
+
476
+ // Unattended: a real ask emits a workflow gate; register it repliable by gate_id.
477
+ if (unattended && gate?.onGateEmitted) {
478
+ gate.onGateEmitted(g => {
479
+ const options = (g.options ?? []).map(o => String((o as { label?: unknown }).label ?? ""));
480
+ gateOptions.set(g.gate_id, options);
481
+ const promptCtx = g.context as { prompt?: unknown; title?: unknown } | undefined;
482
+ const question =
483
+ (typeof promptCtx?.prompt === "string" && promptCtx.prompt) ||
484
+ (typeof promptCtx?.title === "string" && promptCtx.title) ||
485
+ "Question";
486
+ try {
487
+ server.registerAsk(
488
+ JSON.stringify(
489
+ notificationActionPayload(
490
+ { id: g.gate_id, kind: "ask", sessionId: id, question, options },
491
+ { redact, sessionTag: tag },
492
+ ),
493
+ ),
494
+ true,
495
+ );
496
+ } catch (e) {
497
+ logger.warn(`notifications: registerAsk (gate) failed: ${String(e)}`);
498
+ }
499
+ });
500
+ }
501
+ return "started";
502
+ } catch (e) {
503
+ logger.warn(`notifications: failed to start server: ${String(e)}`);
504
+ return "failed";
505
+ }
506
+ }
507
+
508
+ api.registerCommand("notify", {
509
+ description: "Control notifications for this session (on, off, status).",
510
+ async handler(args: string, ctx: ExtensionCommandContext): Promise<void> {
511
+ const id = sessionId(ctx);
512
+ const command = args.trim().split(/\s+/, 1)[0]?.toLowerCase() || "status";
513
+ const resolved = resolveSettings();
514
+ const enabledWithoutLocalOff = isSessionNotificationsEnabled({
515
+ cfg: resolved.cfg,
516
+ env: process.env,
517
+ sessionDisabled: false,
518
+ });
519
+
520
+ if (command === "off") {
521
+ disabledSessions.add(id);
522
+ const stopped = stopSession(id);
523
+ ctx.ui.notify(
524
+ stopped
525
+ ? "Notifications disabled for this session."
526
+ : "Notifications already disabled for this session.",
527
+ "info",
528
+ );
529
+ return;
530
+ }
531
+
532
+ if (command === "on") {
533
+ if (process.env.SKC_NOTIFICATIONS === "0") {
534
+ ctx.ui.notify(
535
+ "Notifications remain disabled: SKC_NOTIFICATIONS=0 is an authoritative opt-out.",
536
+ "warning",
537
+ );
538
+ return;
539
+ }
540
+ if (!enabledWithoutLocalOff) {
541
+ ctx.ui.notify(
542
+ "Notifications are not configured. Run `skc notify setup` or set SKC_NOTIFICATIONS=1.",
543
+ "warning",
544
+ );
545
+ return;
546
+ }
547
+ disabledSessions.delete(id);
548
+ const result = await startSession(ctx);
549
+ ctx.ui.notify(
550
+ result === "started"
551
+ ? "Notifications enabled for this session."
552
+ : result === "already"
553
+ ? "Notifications already enabled for this session."
554
+ : result === "failed"
555
+ ? "Notifications failed to start for this session."
556
+ : "Notifications are not configured. Run `skc notify setup` or set SKC_NOTIFICATIONS=1.",
557
+ result === "failed" ? "error" : result === "disabled" ? "warning" : "info",
558
+ );
559
+ return;
560
+ }
561
+
562
+ if (command !== "status") {
563
+ ctx.ui.notify("Usage: /notify status | /notify on | /notify off", "warning");
564
+ return;
565
+ }
566
+
567
+ const running = runtimes.has(id);
568
+ const locallyDisabled = disabledSessions.has(id);
569
+ const enabled = isEnabledForSession(id, resolved.cfg);
570
+ ctx.ui.notify(
571
+ `Notifications ${running ? "running" : enabled ? "enabled" : "disabled"} for this session; redaction ${resolved.cfg.redact ? "on" : "off"}${locallyDisabled ? "; locally off" : ""}.`,
572
+ "info",
573
+ );
574
+ },
575
+ });
576
+
577
+ api.on("session_start", async (_event, ctx) => {
578
+ await startSession(ctx);
579
+ });
580
+
581
+ // A session id change within the same process needs reason-aware handling.
582
+ // `/new` and fork CONTINUE the same terminal thread (e.g. plan "approve and
583
+ // execute" clears into a fresh session), so re-key the existing runtime
584
+ // old→new WITHOUT recreating the NotificationServer: the server, its endpoint
585
+ // discovery file, and the daemon's forum topic are all keyed by the original
586
+ // session id and the daemon routes by socket, so the existing topic is reused
587
+ // and the next identity frame renames it in place instead of spawning a new
588
+ // thread. `resume`, by contrast, loads a DIFFERENT, already-persisted session
589
+ // that owns its own topic — tear the previous runtime down and start fresh
590
+ // under the resumed id so the daemon attaches to (or recreates) that
591
+ // session's own discovery + topic rather than hijacking this terminal's.
592
+ api.on("session_switch", async (event, ctx) => {
593
+ const newId = sessionId(ctx);
594
+ const prevId = sessionIdFromFile(event.previousSessionFile);
595
+ if (!prevId || prevId === newId) return;
596
+
597
+ if (event.reason === "resume") {
598
+ stopSession(prevId);
599
+ await startSession(ctx);
600
+ return;
601
+ }
602
+
603
+ // `/new` / fork: re-key in place and rename the existing topic.
604
+ if (disabledSessions.delete(prevId)) disabledSessions.add(newId);
605
+ const rt = runtimes.get(prevId);
606
+ if (!rt || runtimes.has(newId)) return;
607
+ runtimes.delete(prevId);
608
+ runtimes.set(newId, rt);
609
+ // Re-bind the interactive ask answer source: the ask tool resolves the
610
+ // source by the current session id, which just changed.
611
+ try {
612
+ rt.disposeAnswerSource();
613
+ rt.disposeFileSink();
614
+ } catch {}
615
+ rt.disposeAnswerSource = registerInteractiveAnswerSource(
616
+ newId,
617
+ rt.server,
618
+ rt.pendingInteractive,
619
+ rt.redact,
620
+ rt.sessionTag,
621
+ );
622
+ rt.disposeFileSink = registerTelegramFileSink(newId, async file => {
623
+ try {
624
+ const data = await fs.promises.readFile(file.path);
625
+ rt.server.pushFrame(
626
+ JSON.stringify({
627
+ type: "file_attachment",
628
+ sessionId: newId,
629
+ name: path.basename(file.path),
630
+ data: data.toString("base64"),
631
+ caption: file.caption,
632
+ }),
633
+ );
634
+ return { ok: true };
635
+ } catch (e) {
636
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
637
+ }
638
+ });
639
+ // Rename the existing topic now when the new session already has a name; a
640
+ // fresh unnamed session is renamed on its next agent_end re-assert, which
641
+ // avoids a transient rename to bare "repo/branch".
642
+ if (ctx.sessionManager.getSessionName()) {
643
+ try {
644
+ rt.server.pushFrame(
645
+ JSON.stringify({
646
+ type: "identity_header",
647
+ sessionId: newId,
648
+ ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
649
+ }),
650
+ );
651
+ } catch (e) {
652
+ logger.warn(`notifications: identity_header (switch) failed: ${String(e)}`);
653
+ }
654
+ }
655
+ });
656
+
657
+ // Drive the live typing indicator: mark busy when the agent loop starts so
658
+ // the daemon shows "typing…" in the thread while the agent is thinking,
659
+ // before any turn output exists. Cleared on `agent_end` below.
660
+ api.on("agent_start", (_event, ctx) => {
661
+ const id = sessionId(ctx);
662
+ const rt = runtimes.get(id);
663
+ if (!rt) return;
664
+ rt.busy = true;
665
+ try {
666
+ rt.server.pushFrame(JSON.stringify({ type: "activity", sessionId: id, state: "busy" }));
667
+ } catch (e) {
668
+ logger.warn(`notifications: activity (busy) failed: ${String(e)}`);
669
+ }
670
+ });
671
+
672
+ // Each turn that starts has absorbed any messages injected from the thread,
673
+ // so ack them as "consumed": the daemon flips the queued reaction on the
674
+ // originating Telegram message to the consumed (double-check) reaction.
675
+ api.on("turn_start", (_event, ctx) => {
676
+ const id = sessionId(ctx);
677
+ const rt = runtimes.get(id);
678
+ if (!rt || rt.pendingInbound.size === 0) return;
679
+ for (const updateId of rt.pendingInbound) {
680
+ try {
681
+ rt.server.pushFrame(JSON.stringify({ type: "inbound_ack", sessionId: id, updateId, state: "consumed" }));
682
+ } catch (e) {
683
+ logger.warn(`notifications: inbound_ack failed: ${String(e)}`);
684
+ }
685
+ }
686
+ rt.pendingInbound.clear();
687
+ });
688
+
689
+ // Idle fires on `agent_end` (the agent loop settling to await the user), NOT
690
+ // per `turn_end`. turn_end fires once per turn iteration, so a single
691
+ // user-visible idle previously produced many idle pings (the flood); agent_end
692
+ // fires exactly once per settle, yielding exactly one idle notification.
693
+ api.on("agent_end", (_event, ctx) => {
694
+ const id = sessionId(ctx);
695
+ const rt = runtimes.get(id);
696
+ if (!rt) return;
697
+ const seq = rt.idleSeq++;
698
+ // Clear the typing indicator: the agent loop has settled.
699
+ rt.busy = false;
700
+ try {
701
+ rt.server.pushFrame(JSON.stringify({ type: "activity", sessionId: id, state: "idle" }));
702
+ } catch (e) {
703
+ logger.warn(`notifications: activity (idle) failed: ${String(e)}`);
704
+ }
705
+ // Re-assert the identity header so the daemon renames the topic once the
706
+ // session title has been auto-generated ("{repo}/{branch} - {title}"). The
707
+ // daemon only renames when the title actually changed.
708
+ try {
709
+ rt.server.pushFrame(
710
+ JSON.stringify({
711
+ type: "identity_header",
712
+ sessionId: id,
713
+ ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
714
+ }),
715
+ );
716
+ } catch {}
717
+ try {
718
+ rt.server.noteIdle(
719
+ JSON.stringify(
720
+ notificationActionPayload(
721
+ {
722
+ id: `idle:${id}#${seq}`,
723
+ kind: "idle",
724
+ sessionId: id,
725
+ summary: undefined,
726
+ },
727
+ { redact: rt.redact, sessionTag: rt.sessionTag },
728
+ ),
729
+ ),
730
+ );
731
+ } catch (e) {
732
+ logger.warn(`notifications: noteIdle failed: ${String(e)}`);
733
+ }
734
+
735
+ // On idle, stream a context update with metadata (token/model usage +
736
+ // working-tree diff) unless redaction is on. The agent's last message is
737
+ // NOT repeated here — it is already streamed once via `turn_stream`.
738
+ if (!rt.redact) {
739
+ const usage = (
740
+ ctx as { getContextUsage?: () => { tokens: number | null; contextWindow: number } | undefined }
741
+ ).getContextUsage?.();
742
+ const model = (ctx as { getModel?: () => { id?: string } | undefined }).getModel?.();
743
+ const tokenUsage = usage && usage.tokens != null ? `${usage.tokens}/${usage.contextWindow}` : undefined;
744
+ const modelId = model?.id;
745
+ void readGitDiffStat(ctx.cwd).then(diff => {
746
+ if (!diff && !tokenUsage && !modelId) return;
747
+ try {
748
+ rt.server.pushFrame(
749
+ JSON.stringify({
750
+ type: "context_update",
751
+ sessionId: id,
752
+ tokenUsage,
753
+ model: modelId,
754
+ diff,
755
+ }),
756
+ );
757
+ } catch (e) {
758
+ logger.warn(`notifications: context_update failed: ${String(e)}`);
759
+ }
760
+ });
761
+ }
762
+ });
763
+
764
+ // Stream viable agent output per turn (the live thread mirror). Unlike idle,
765
+ // turn output is expected to be multiple messages — one per turn that
766
+ // produced assistant text. Tool-only turns yield no text and are skipped.
767
+ // Redaction suppresses streamed content (only the one-time identity header
768
+ // survives redaction). The daemon coalesces/throttles these via its shared
769
+ // rate-limit pool before sending to Telegram.
770
+ // Push the in-flight turn's assistant text as a finalized turn_stream, deduped
771
+ // against what was already flushed for this turn (the pre-ask lead-in).
772
+ const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined): void => {
773
+ if (!text || text === rt.preAskFlushedText) return;
774
+ rt.preAskFlushedText = text;
775
+ try {
776
+ rt.server.pushFrame(JSON.stringify({ type: "turn_stream", sessionId: id, phase: "finalized", text }));
777
+ } catch (e) {
778
+ logger.warn(`notifications: pushFrame (turn) failed: ${String(e)}`);
779
+ }
780
+ };
781
+
782
+ // Emit the assistant text that precedes an ask BEFORE the ask's action_needed
783
+ // is broadcast, so the remote (e.g. Telegram) shows the lead-in first instead
784
+ // of only after the ask resolves at turn_end. The text is captured on
785
+ // message_end (which, like tool_execution_start, is on the awaited extension
786
+ // path and ordered before it — unlike message_update, which is queued async),
787
+ // then flushed here before the ask tool's execute calls registerAsk.
788
+ api.on("tool_execution_start", (event, ctx) => {
789
+ if (event.toolName !== "ask") return;
790
+ const id = sessionId(ctx);
791
+ const rt = runtimes.get(id);
792
+ if (!rt || rt.redact) return;
793
+ flushTurnText(rt, id, rt.currentTurnText);
794
+ });
795
+
796
+ api.on("turn_end", (event, ctx) => {
797
+ const id = sessionId(ctx);
798
+ const rt = runtimes.get(id);
799
+ if (!rt) return;
800
+ const text = rt.redact ? undefined : summaryFromMessage(event.message, 3500);
801
+ if (text) flushTurnText(rt, id, text);
802
+ // Reset per-turn streaming state so the next turn starts fresh and a later
803
+ // turn with identical text is not falsely deduped.
804
+ rt.currentTurnText = undefined;
805
+ rt.preAskFlushedText = undefined;
806
+ });
807
+
808
+ // Stream agent-produced images (computer/browser/tool screenshots) as
809
+ // image_attachment frames; suppressed when redaction is on.
810
+ api.on("message_end", (event, ctx) => {
811
+ const id = sessionId(ctx);
812
+ const rt = runtimes.get(id);
813
+ if (!rt || rt.redact) return;
814
+ // Capture the in-flight ASSISTANT text here (message_end is on the awaited
815
+ // extension path and ordered before tool_execution_start) so the pre-ask
816
+ // flush can emit it before the ask prompt. Role-scoped: message_end also
817
+ // fires for the user prompt, which must never be mirrored back as turn output.
818
+ if ((event.message as { role?: unknown }).role === "assistant") {
819
+ const turnText = summaryFromMessage(event.message, 3500);
820
+ if (turnText) rt.currentTurnText = turnText;
821
+ }
822
+ for (const img of imageAttachmentsFromMessage(event.message)) {
823
+ try {
824
+ rt.server.pushFrame(
825
+ JSON.stringify({
826
+ type: "image_attachment",
827
+ sessionId: id,
828
+ source: img.source,
829
+ mime: img.mime,
830
+ data: img.data,
831
+ }),
832
+ );
833
+ } catch (e) {
834
+ logger.warn(`notifications: image_attachment failed: ${String(e)}`);
835
+ }
836
+ }
837
+ });
838
+
839
+ api.on("session_shutdown", (_event, ctx) => {
840
+ stopSession(sessionId(ctx));
841
+ });
842
+ };