@sayknow-cli/coding-agent 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/bin/skc.js +4 -0
  2. package/dist/types/cli/mcp-cli.d.ts +25 -0
  3. package/dist/types/cli/notify-cli.d.ts +2 -0
  4. package/dist/types/cli/plugin-cli.d.ts +2 -0
  5. package/dist/types/cli.d.ts +6 -0
  6. package/dist/types/commands/mcp.d.ts +70 -0
  7. package/dist/types/commands/plugin.d.ts +6 -0
  8. package/dist/types/commands/session.d.ts +6 -0
  9. package/dist/types/config/keybindings.d.ts +2 -2
  10. package/dist/types/config/model-profile-activation.d.ts +8 -1
  11. package/dist/types/config/settings-schema.d.ts +39 -2
  12. package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
  13. package/dist/types/defaults/skc-defaults.d.ts +2 -13
  14. package/dist/types/extensibility/shared-events.d.ts +1 -0
  15. package/dist/types/extensibility/skc-plugins/compiler.d.ts +19 -0
  16. package/dist/types/extensibility/skc-plugins/constrained-hooks.d.ts +29 -0
  17. package/dist/types/extensibility/skc-plugins/index.d.ts +9 -0
  18. package/dist/types/extensibility/skc-plugins/injection.d.ts +9 -0
  19. package/dist/types/extensibility/skc-plugins/installer.d.ts +13 -0
  20. package/dist/types/extensibility/skc-plugins/mcp-policy.d.ts +26 -0
  21. package/dist/types/extensibility/skc-plugins/observability.d.ts +27 -0
  22. package/dist/types/extensibility/skc-plugins/prompt-appendix.d.ts +16 -0
  23. package/dist/types/extensibility/skc-plugins/registry.d.ts +32 -0
  24. package/dist/types/extensibility/skc-plugins/runtime-adapters.d.ts +64 -0
  25. package/dist/types/extensibility/skc-plugins/session-validation.d.ts +42 -0
  26. package/dist/types/extensibility/skc-plugins/types.d.ts +158 -2
  27. package/dist/types/extensibility/skc-plugins/validation.d.ts +8 -1
  28. package/dist/types/lsp/types.d.ts +2 -0
  29. package/dist/types/main.d.ts +2 -0
  30. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  31. package/dist/types/modes/components/model-selector.d.ts +8 -0
  32. package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
  33. package/dist/types/modes/theme/defaults/index.d.ts +99 -0
  34. package/dist/types/notifications/attachment-registry.d.ts +17 -0
  35. package/dist/types/notifications/chat-adapters.d.ts +9 -0
  36. package/dist/types/notifications/config.d.ts +9 -1
  37. package/dist/types/notifications/engine.d.ts +59 -0
  38. package/dist/types/notifications/html-format.d.ts +11 -0
  39. package/dist/types/notifications/index.d.ts +149 -1
  40. package/dist/types/notifications/lifecycle-commands.d.ts +60 -0
  41. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  42. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  43. package/dist/types/notifications/managed-daemon.d.ts +48 -0
  44. package/dist/types/notifications/operator-runtime.d.ts +52 -0
  45. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  46. package/dist/types/notifications/recent-activity.d.ts +35 -0
  47. package/dist/types/notifications/telegram-daemon.d.ts +133 -16
  48. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  49. package/dist/types/notifications/threaded-inbound.d.ts +19 -0
  50. package/dist/types/notifications/threaded-render.d.ts +6 -1
  51. package/dist/types/notifications/topic-registry.d.ts +12 -9
  52. package/dist/types/runtime-mcp/types.d.ts +7 -0
  53. package/dist/types/sdk.d.ts +2 -0
  54. package/dist/types/session/agent-session.d.ts +16 -4
  55. package/dist/types/session/blob-store.d.ts +25 -0
  56. package/dist/types/session/session-manager.d.ts +57 -0
  57. package/dist/types/skc-runtime/launch-tmux.d.ts +1 -0
  58. package/dist/types/skc-runtime/psmux-detect.d.ts +78 -0
  59. package/dist/types/skc-runtime/ralplan-runtime.d.ts +1 -1
  60. package/dist/types/skc-runtime/team-runtime.d.ts +2 -0
  61. package/dist/types/skc-runtime/tmux-common.d.ts +30 -2
  62. package/dist/types/skc-runtime/tmux-sessions.d.ts +18 -0
  63. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  64. package/dist/types/system-prompt.d.ts +2 -0
  65. package/dist/types/task/executor.d.ts +9 -1
  66. package/dist/types/tools/composer-bash-policy.d.ts +14 -0
  67. package/dist/types/tools/fetch.d.ts +23 -0
  68. package/dist/types/tools/index.d.ts +4 -1
  69. package/dist/types/tools/telegram-send.d.ts +32 -0
  70. package/dist/types/utils/changelog.d.ts +1 -0
  71. package/dist/types/web/insane/bridge.d.ts +103 -0
  72. package/dist/types/web/insane/url-guard.d.ts +25 -0
  73. package/dist/types/web/scrapers/types.d.ts +5 -0
  74. package/dist/types/web/scrapers/utils.d.ts +7 -1
  75. package/dist/types/web/search/provider.d.ts +18 -1
  76. package/dist/types/web/search/providers/insane.d.ts +53 -0
  77. package/dist/types/web/search/providers/text-citations.d.ts +23 -0
  78. package/dist/types/web/search/types.d.ts +12 -4
  79. package/package.json +14 -10
  80. package/scripts/g004-tmux-smoke.ts +100 -0
  81. package/scripts/g005-daemon-smoke.ts +180 -0
  82. package/scripts/g011-daemon-path-smoke.ts +153 -0
  83. package/scripts/verify-insane-vendor.ts +132 -0
  84. package/src/cli/args.ts +1 -1
  85. package/src/cli/fast-help.ts +30 -2
  86. package/src/cli/mcp-cli.ts +272 -0
  87. package/src/cli/notify-cli.ts +152 -5
  88. package/src/cli/plugin-cli.ts +66 -3
  89. package/src/cli.ts +30 -12
  90. package/src/commands/mcp.ts +117 -0
  91. package/src/commands/plugin.ts +4 -0
  92. package/src/commands/session.ts +18 -0
  93. package/src/commands/team.ts +1 -1
  94. package/src/config/keybindings.ts +2 -2
  95. package/src/config/model-profile-activation.ts +55 -7
  96. package/src/config/settings-schema.ts +30 -1
  97. package/src/deep-interview/plaintext-gate-guard.ts +94 -0
  98. package/src/defaults/skc/extensions/grok-cli-vendor/biome.json +1 -1
  99. package/src/defaults/skc/skills/deep-interview/SKILL.md +7 -6
  100. package/src/defaults/skc/skills/ralplan/SKILL.md +11 -4
  101. package/src/defaults/skc/skills/team/SKILL.md +5 -3
  102. package/src/defaults/skc/skills/ultragoal/SKILL.md +41 -13
  103. package/src/defaults/skc-defaults.ts +2 -27
  104. package/src/export/html/index.ts +2 -2
  105. package/src/extensibility/extensions/runner.ts +1 -0
  106. package/src/extensibility/shared-events.ts +1 -0
  107. package/src/extensibility/skc-plugins/compiler.ts +351 -0
  108. package/src/extensibility/skc-plugins/constrained-hooks.ts +170 -0
  109. package/src/extensibility/skc-plugins/index.ts +9 -0
  110. package/src/extensibility/skc-plugins/injection.ts +109 -0
  111. package/src/extensibility/skc-plugins/installer.ts +434 -0
  112. package/src/extensibility/skc-plugins/loader.ts +3 -1
  113. package/src/extensibility/skc-plugins/mcp-policy.ts +239 -0
  114. package/src/extensibility/skc-plugins/observability.ts +84 -0
  115. package/src/extensibility/skc-plugins/paths.ts +1 -1
  116. package/src/extensibility/skc-plugins/prompt-appendix.ts +109 -0
  117. package/src/extensibility/skc-plugins/registry.ts +180 -0
  118. package/src/extensibility/skc-plugins/runtime-adapters.ts +234 -0
  119. package/src/extensibility/skc-plugins/schema.ts +250 -20
  120. package/src/extensibility/skc-plugins/session-validation.ts +147 -0
  121. package/src/extensibility/skc-plugins/types.ts +199 -3
  122. package/src/extensibility/skc-plugins/validation.ts +80 -0
  123. package/src/extensibility/skills.ts +15 -0
  124. package/src/hooks/skill-state.ts +57 -0
  125. package/src/internal-urls/docs-index.generated.ts +17 -13
  126. package/src/lsp/config.ts +16 -3
  127. package/src/lsp/defaults.json +7 -0
  128. package/src/lsp/types.ts +2 -0
  129. package/src/main.ts +14 -3
  130. package/src/modes/bridge/bridge-mode.ts +11 -0
  131. package/src/modes/components/assistant-message.ts +49 -1
  132. package/src/modes/components/custom-editor.ts +2 -0
  133. package/src/modes/components/footer.ts +2 -3
  134. package/src/modes/components/hook-editor.ts +1 -1
  135. package/src/modes/components/hook-selector.ts +67 -43
  136. package/src/modes/components/model-selector.ts +56 -11
  137. package/src/modes/components/status-line/git-utils.ts +25 -0
  138. package/src/modes/components/status-line.ts +10 -11
  139. package/src/modes/components/welcome.ts +2 -3
  140. package/src/modes/controllers/event-controller.ts +15 -0
  141. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  142. package/src/modes/controllers/selector-controller.ts +53 -11
  143. package/src/modes/interactive-mode.ts +50 -3
  144. package/src/modes/shared/agent-wire/scopes.ts +1 -1
  145. package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
  146. package/src/modes/theme/defaults/index.ts +2 -0
  147. package/src/modes/utils/context-usage.ts +2 -2
  148. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  149. package/src/notifications/attachment-registry.ts +23 -0
  150. package/src/notifications/chat-adapters.ts +147 -0
  151. package/src/notifications/config.ts +23 -2
  152. package/src/notifications/engine.ts +100 -0
  153. package/src/notifications/html-format.ts +38 -0
  154. package/src/notifications/index.ts +417 -45
  155. package/src/notifications/lifecycle-commands.ts +238 -0
  156. package/src/notifications/lifecycle-control-runtime.ts +405 -0
  157. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  158. package/src/notifications/managed-daemon.ts +163 -0
  159. package/src/notifications/operator-runtime.ts +171 -0
  160. package/src/notifications/rate-limit-pool.ts +19 -0
  161. package/src/notifications/recent-activity.ts +132 -0
  162. package/src/notifications/telegram-daemon.ts +984 -242
  163. package/src/notifications/telegram-reference.ts +25 -7
  164. package/src/notifications/threaded-inbound.ts +60 -4
  165. package/src/notifications/threaded-render.ts +20 -2
  166. package/src/notifications/topic-registry.ts +23 -9
  167. package/src/prompts/agents/executor.md +2 -2
  168. package/src/runtime-mcp/transports/stdio.ts +38 -4
  169. package/src/runtime-mcp/types.ts +7 -0
  170. package/src/sdk.ts +157 -10
  171. package/src/session/agent-session.ts +248 -125
  172. package/src/session/blob-store.ts +196 -8
  173. package/src/session/session-manager.ts +762 -12
  174. package/src/skc-runtime/launch-tmux.ts +85 -22
  175. package/src/skc-runtime/ledger-event-renderer.ts +1 -0
  176. package/src/skc-runtime/psmux-detect.ts +239 -0
  177. package/src/skc-runtime/ralplan-runtime.ts +2 -2
  178. package/src/skc-runtime/team-runtime.ts +56 -23
  179. package/src/skc-runtime/tmux-common.ts +88 -4
  180. package/src/skc-runtime/tmux-sessions.ts +111 -9
  181. package/src/skc-runtime/ultragoal-guard.ts +25 -8
  182. package/src/skc-runtime/ultragoal-runtime.ts +75 -15
  183. package/src/skc-runtime/workflow-manifest.generated.json +29 -0
  184. package/src/skc-runtime/workflow-manifest.ts +7 -2
  185. package/src/slash-commands/builtin-registry.ts +23 -3
  186. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  187. package/src/slash-commands/helpers/parse.ts +2 -1
  188. package/src/system-prompt.ts +9 -0
  189. package/src/task/executor.ts +31 -7
  190. package/src/task/index.ts +2 -0
  191. package/src/tools/ask.ts +5 -1
  192. package/src/tools/bash.ts +9 -0
  193. package/src/tools/composer-bash-policy.ts +96 -0
  194. package/src/tools/fetch.ts +94 -1
  195. package/src/tools/index.ts +6 -1
  196. package/src/tools/telegram-send.ts +137 -0
  197. package/src/utils/changelog.ts +8 -0
  198. package/src/web/insane/bridge.ts +350 -0
  199. package/src/web/insane/url-guard.ts +159 -0
  200. package/src/web/scrapers/types.ts +143 -45
  201. package/src/web/scrapers/utils.ts +70 -19
  202. package/src/web/search/provider.ts +77 -18
  203. package/src/web/search/providers/anthropic.ts +70 -3
  204. package/src/web/search/providers/codex.ts +1 -119
  205. package/src/web/search/providers/gemini.ts +99 -0
  206. package/src/web/search/providers/insane.ts +551 -0
  207. package/src/web/search/providers/openai-compatible.ts +66 -32
  208. package/src/web/search/providers/text-citations.ts +111 -0
  209. package/src/web/search/types.ts +13 -2
  210. package/vendor/insane-search/LICENSE +21 -0
  211. package/vendor/insane-search/MANIFEST.json +24 -0
  212. package/vendor/insane-search/engine/__init__.py +23 -0
  213. package/vendor/insane-search/engine/__main__.py +128 -0
  214. package/vendor/insane-search/engine/bias_check.py +183 -0
  215. package/vendor/insane-search/engine/executor.py +254 -0
  216. package/vendor/insane-search/engine/fetch_chain.py +725 -0
  217. package/vendor/insane-search/engine/learning.py +175 -0
  218. package/vendor/insane-search/engine/phase0.py +214 -0
  219. package/vendor/insane-search/engine/safety.py +91 -0
  220. package/vendor/insane-search/engine/templates/package.json +11 -0
  221. package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
  222. package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
  223. package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
  224. package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
  225. package/vendor/insane-search/engine/tests/test_u1.py +200 -0
  226. package/vendor/insane-search/engine/tests/test_u4.py +131 -0
  227. package/vendor/insane-search/engine/tests/test_u5.py +163 -0
  228. package/vendor/insane-search/engine/tests/test_u7.py +124 -0
  229. package/vendor/insane-search/engine/transport.py +211 -0
  230. package/vendor/insane-search/engine/url_transforms.py +98 -0
  231. package/vendor/insane-search/engine/validators.py +331 -0
  232. package/vendor/insane-search/engine/waf_detector.py +214 -0
  233. package/vendor/insane-search/engine/waf_profiles.yaml +162 -0
@@ -12,7 +12,7 @@
12
12
  * - `ask` (unattended/RPC): observes emitted workflow gates and resolves the real
13
13
  * gate on a remote reply via `ctx.workflowGate`.
14
14
  * - `turn_end` -> `action_needed` (kind `idle`, deduped per turn).
15
- * - `session_shutdown` -> stop the server + deregister the answer source.
15
+ * - `session_shutdown` -> `session_closed` frame, stop server, deregister answer source.
16
16
  *
17
17
  * Enable with Settings notifications config, `SKC_NOTIFICATIONS=1` (a token is
18
18
  * generated), or `SKC_NOTIFICATIONS_TOKEN`.
@@ -24,11 +24,13 @@ import * as fs from "node:fs";
24
24
  import * as os from "node:os";
25
25
  import * as path from "node:path";
26
26
  import { promisify } from "node:util";
27
+ import type { ImageContent, TextContent } from "@sayknow-cli/ai";
27
28
  import { NotificationServer } from "@sayknow-cli/natives";
28
29
  import { logger } from "@sayknow-cli/utils";
29
30
  import { Settings } from "../config/settings";
30
31
  import type { ExtensionCommandContext, ExtensionContext, ExtensionFactory } from "../extensibility/extensions";
31
32
  import { registerAskAnswerSource } from "../tools/ask-answer-registry";
33
+ import { registerTelegramFileSink } from "./attachment-registry";
32
34
  import {
33
35
  getNotificationConfig,
34
36
  isGloballyConfigured,
@@ -39,6 +41,193 @@ import {
39
41
  import { imageAttachmentsFromMessage, notificationActionPayload, summaryFromMessage } from "./helpers";
40
42
  import { ensureTelegramDaemonRunning } from "./telegram-daemon";
41
43
 
44
+ // ===========================================================================
45
+ // Session lifecycle control protocol (TypeScript mirror of the Rust wire
46
+ // contract in `crates/skc-notifications/src/lifecycle.rs`).
47
+ //
48
+ // These describe the frames exchanged over the daemon-owned, session-independent
49
+ // control endpoint for remote session create / close / resume. Field names are
50
+ // camelCase on the wire; `type`/`kind` discriminators are snake_case. The Rust
51
+ // ingress authenticates and forwards; the daemon (TypeScript) owns all policy,
52
+ // spawn orchestration, idempotency, rate limiting, audit, and UX.
53
+ // ===========================================================================
54
+
55
+ /** Where a `session_create` should run. Discriminated by `kind`. */
56
+ export type SessionCreateTarget =
57
+ | { kind: "existing_path"; path: string }
58
+ | { kind: "worktree"; repo: string; branch: string }
59
+ | { kind: "plain_dir"; path: string };
60
+
61
+ /** Identifies the session a `session_close` targets. */
62
+ export interface SessionCloseTarget {
63
+ sessionId: string;
64
+ /** Expected SKC-managed tmux session name (defense-in-depth match). */
65
+ tmuxSession?: string;
66
+ /** Expected `@skc-session-state-file` tag (defense-in-depth match). */
67
+ sessionStateFile?: string;
68
+ }
69
+
70
+ /** Identifies the session a `session_resume` targets. */
71
+ export interface SessionResumeTarget {
72
+ sessionIdOrPrefix: string;
73
+ /** Optional repo/working-dir hint to disambiguate matches. */
74
+ path?: string;
75
+ }
76
+
77
+ /** Create a new session. */
78
+ export interface SessionCreateFrame {
79
+ type: "session_create";
80
+ requestId: string;
81
+ /** Deterministic lifecycle marker preallocated by the daemon before spawn. */
82
+ lifecycleRequestId: string;
83
+ /** Session id the daemon preallocated and propagates to the child. */
84
+ intendedSessionId: string;
85
+ /** Telegram update id (idempotency key on the daemon side). */
86
+ updateId: number;
87
+ chatId: string;
88
+ /** Control-endpoint token authorizing this frame. */
89
+ token: string;
90
+ target: SessionCreateTarget;
91
+ /** Reference to the daemon-written, once-consumed startup-prompt file. */
92
+ startupPromptRef?: string;
93
+ }
94
+
95
+ /** Close (hard-kill, history preserved) a session. */
96
+ export interface SessionCloseFrame {
97
+ type: "session_close";
98
+ requestId: string;
99
+ updateId: number;
100
+ chatId: string;
101
+ token: string;
102
+ target: SessionCloseTarget;
103
+ /** Hard-kill even if a live pane is attached (SKC-managed only). */
104
+ force?: boolean;
105
+ }
106
+
107
+ /** Resume a session (reattach if alive, else cold-restart from history). */
108
+ export interface SessionResumeFrame {
109
+ type: "session_resume";
110
+ requestId: string;
111
+ updateId: number;
112
+ chatId: string;
113
+ token: string;
114
+ target: SessionResumeTarget;
115
+ startupPromptRef?: string;
116
+ }
117
+
118
+ /** Any client -> ingress lifecycle request frame. */
119
+ export type SessionLifecycleRequest = SessionCreateFrame | SessionCloseFrame | SessionResumeFrame;
120
+
121
+ /** Terminal status of a lifecycle request. */
122
+ export type LifecycleStatus = "ok" | "error";
123
+
124
+ /** A connected session's per-session endpoint, returned to the control client. */
125
+ export interface LifecycleEndpoint {
126
+ url: string;
127
+ token: string;
128
+ }
129
+
130
+ /** The Telegram topic/thread a session is surfaced in. */
131
+ export interface LifecycleTopic {
132
+ chatId: string;
133
+ threadId: string;
134
+ }
135
+
136
+ /** How a create request was correlated to its spawned session. */
137
+ export type MatchedBy = "spawn_marker" | "session_ready";
138
+
139
+ /** Response to a successful `session_create`. */
140
+ export interface SessionCreateResponseFrame {
141
+ type: "session_create_response";
142
+ requestId: string;
143
+ status: LifecycleStatus;
144
+ lifecycleRequestId: string;
145
+ sessionId: string;
146
+ matchedBy: MatchedBy;
147
+ endpoint: LifecycleEndpoint;
148
+ topic: LifecycleTopic;
149
+ target: SessionCreateTarget;
150
+ }
151
+
152
+ /** Response to a successful `session_close`. */
153
+ export interface SessionCloseResponseFrame {
154
+ type: "session_close_response";
155
+ requestId: string;
156
+ status: LifecycleStatus;
157
+ sessionId: string;
158
+ processGone: boolean;
159
+ historyPreserved: boolean;
160
+ endpointStale: boolean;
161
+ }
162
+
163
+ /** Whether a resume reattached to a live session or cold-restarted a dead one. */
164
+ export type ResumeMode = "reattached" | "cold_restarted";
165
+
166
+ /** Response to a successful `session_resume`. */
167
+ export interface SessionResumeResponseFrame {
168
+ type: "session_resume_response";
169
+ requestId: string;
170
+ status: LifecycleStatus;
171
+ sessionId: string;
172
+ mode: ResumeMode;
173
+ endpoint: LifecycleEndpoint;
174
+ topic: LifecycleTopic;
175
+ }
176
+
177
+ /** Machine-readable reason a lifecycle request failed. */
178
+ export type LifecycleErrorReason =
179
+ | "unauthorized"
180
+ | "rate_limited"
181
+ | "duplicate_conflict"
182
+ | "invalid_target"
183
+ | "ambiguous_target"
184
+ | "spawn_failed"
185
+ | "discovery_timeout"
186
+ | "readiness_timeout"
187
+ | "close_refused"
188
+ | "not_found"
189
+ | "terminal_uncertain";
190
+
191
+ /** A candidate returned with an `ambiguous_target` resume error. */
192
+ export interface ResumeCandidate {
193
+ sessionId: string;
194
+ path?: string;
195
+ /** Last-activity epoch-millis (session history file mtime), if known. */
196
+ mtimeMs?: number;
197
+ }
198
+
199
+ /** A structured lifecycle error frame. */
200
+ export interface SessionLifecycleErrorFrame {
201
+ type: "session_lifecycle_error";
202
+ requestId: string;
203
+ status: LifecycleStatus;
204
+ reason: LifecycleErrorReason;
205
+ message: string;
206
+ candidates?: ResumeCandidate[];
207
+ }
208
+
209
+ /** Any ingress -> client lifecycle response frame. */
210
+ export type SessionLifecycleResponse =
211
+ | SessionCreateResponseFrame
212
+ | SessionCloseResponseFrame
213
+ | SessionResumeResponseFrame
214
+ | SessionLifecycleErrorFrame;
215
+
216
+ /**
217
+ * Replayable per-session readiness signal (mirror of the Rust `session_ready`
218
+ * frame). Buffered and replayed to late clients so WS-open alone never implies
219
+ * the session is live and surfaced.
220
+ */
221
+ export interface SessionReadyFrame {
222
+ type: "session_ready";
223
+ sessionId: string;
224
+ lifecycleRequestId?: string;
225
+ startupPromptRef?: string;
226
+ repo?: string;
227
+ branch?: string;
228
+ title?: string;
229
+ }
230
+
42
231
  /** Resolve the git dir for `cwd`, handling worktrees where `.git` is a file. */
43
232
  function gitDir(cwd: string): string | undefined {
44
233
  const dot = path.join(cwd, ".git");
@@ -136,7 +325,10 @@ interface SessionRuntime {
136
325
  pendingInteractive: Map<string, PendingInteractiveAsk>;
137
326
  /** Deregisters this session's ask answer source. */
138
327
  disposeAnswerSource: () => void;
328
+ /** Deregisters this session's Telegram file sink. */
329
+ disposeFileSink: () => void;
139
330
  redact: boolean;
331
+ verbosity: "lean" | "verbose";
140
332
  sessionTag: string;
141
333
  /** Whether the agent loop is currently running (drives the typing indicator). */
142
334
  busy: boolean;
@@ -157,6 +349,16 @@ interface ResolvedSettings {
157
349
 
158
350
  const defaultConfig: NotificationConfig = {
159
351
  enabled: false,
352
+ botToken: undefined,
353
+ chatId: undefined,
354
+ discord: {
355
+ botToken: undefined,
356
+ channelId: undefined,
357
+ },
358
+ slack: {
359
+ botToken: undefined,
360
+ channelId: undefined,
361
+ },
160
362
  redact: false,
161
363
  verbosity: "lean",
162
364
  idleTimeoutMs: 60_000,
@@ -228,21 +430,81 @@ function mapAnswerToGate(
228
430
  return { selected: [] };
229
431
  }
230
432
 
433
+ /** Register the interactive `ask` answer source for a session (the ask tool
434
+ * races the local UI against a remote reply). Returns the deregister disposer. */
435
+ function registerInteractiveAnswerSource(
436
+ id: string,
437
+ server: NotificationServer,
438
+ pendingInteractive: Map<string, PendingInteractiveAsk>,
439
+ getRedact: () => boolean,
440
+ tag: string,
441
+ ): () => void {
442
+ return registerAskAnswerSource(id, {
443
+ awaitAnswer(question, options, signal) {
444
+ if (signal?.aborted) return Promise.resolve(undefined);
445
+ const askId = `ask:${crypto.randomUUID()}`;
446
+ try {
447
+ server.registerAsk(
448
+ JSON.stringify(
449
+ notificationActionPayload(
450
+ { id: askId, kind: "ask", sessionId: id, question, options },
451
+ { redact: getRedact(), sessionTag: tag },
452
+ ),
453
+ ),
454
+ true,
455
+ );
456
+ } catch (e) {
457
+ logger.warn(`notifications: registerAsk failed: ${String(e)}`);
458
+ return Promise.resolve(undefined);
459
+ }
460
+ return new Promise<string | undefined>(resolve => {
461
+ pendingInteractive.set(askId, { resolve, options });
462
+ signal?.addEventListener("abort", () => {
463
+ if (!pendingInteractive.delete(askId)) return;
464
+ // Local UI answered: mark the remote action resolved-locally.
465
+ try {
466
+ server.resolveLocal(askId, undefined);
467
+ } catch {}
468
+ resolve(undefined);
469
+ });
470
+ });
471
+ },
472
+ });
473
+ }
474
+
475
+ /** Extract the session id from a `<timestamp>_<uuid>.jsonl` session file path. */
476
+ function sessionIdFromFile(file: string | undefined): string | undefined {
477
+ if (!file) return undefined;
478
+ const base = path.basename(file).replace(/\.jsonl$/, "");
479
+ const underscore = base.indexOf("_");
480
+ return underscore >= 0 ? base.slice(underscore + 1) : undefined;
481
+ }
482
+
231
483
  export const createNotificationsExtension: ExtensionFactory = api => {
232
484
  const runtimes = new Map<string, SessionRuntime>();
233
485
  const disabledSessions = new Set<string>();
234
486
  const sessionId = (ctx: ExtensionContext): string => ctx.sessionManager.getSessionId();
487
+ const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
235
488
 
236
- function stopSession(id: string): boolean {
489
+ async function stopSession(id: string): Promise<boolean> {
237
490
  const rt = runtimes.get(id);
238
491
  if (!rt) return false;
239
492
  runtimes.delete(id);
240
493
  try {
241
494
  rt.disposeAnswerSource();
495
+ rt.disposeFileSink();
242
496
  } catch {}
243
497
  // Resolve any still-pending interactive asks so the ask tool is not left hanging.
244
498
  for (const pending of rt.pendingInteractive.values()) pending.resolve(undefined);
245
499
  rt.pendingInteractive.clear();
500
+ let closeFrameSent = false;
501
+ try {
502
+ rt.server.pushFrame(JSON.stringify({ type: "session_closed", sessionId: id }));
503
+ closeFrameSent = true;
504
+ } catch (e) {
505
+ logger.warn(`notifications: session_closed failed: ${String(e)}`);
506
+ }
507
+ if (closeFrameSent) await sleep(100);
246
508
  try {
247
509
  rt.server.stop();
248
510
  } catch (e) {
@@ -271,6 +533,8 @@ export const createNotificationsExtension: ExtensionFactory = api => {
271
533
  const pendingInteractive = new Map<string, PendingInteractiveAsk>();
272
534
  const tag = sessionTag(id);
273
535
  const redact = cfg.redact;
536
+ const verbosity = cfg.verbosity;
537
+ let runtime: SessionRuntime | undefined;
274
538
 
275
539
  // The SDK can always answer now (interactive via the answer source, or the
276
540
  // unattended gate), so the endpoint advertises a resolver.
@@ -317,23 +581,58 @@ export const createNotificationsExtension: ExtensionFactory = api => {
317
581
  // thread (forwarded by the daemon over the WS, fail-closed at the daemon).
318
582
  server.onInbound((err, inbound) => {
319
583
  if (err || !inbound) return;
320
- if (inbound.kind === "user_message" && inbound.text) {
584
+ if (inbound.kind === "user_message") {
321
585
  // Inject as a user turn (steers/continues the agent; the resulting
322
586
  // turn streams back via the turn_end handler even when not idle).
323
587
  // Record the update id so it can be acked as "consumed" on the next
324
588
  // turn_start, and steer (vs start a fresh turn) when already busy.
325
- const rt = runtimes.get(id);
326
- if (rt && typeof inbound.updateId === "number") rt.pendingInbound.add(inbound.updateId);
589
+ const text = inbound.text ?? "";
590
+ const images = inbound.images ?? [];
591
+ if (!text && images.length === 0) return;
592
+ if (runtime && typeof inbound.updateId === "number") runtime.pendingInbound.add(inbound.updateId);
593
+ const content: string | (TextContent | ImageContent)[] =
594
+ images.length > 0
595
+ ? [
596
+ ...(text ? [{ type: "text", text } as TextContent] : []),
597
+ ...images.map(
598
+ img =>
599
+ ({ type: "image", data: img.data, mimeType: img.mime ?? "image/jpeg" }) as ImageContent,
600
+ ),
601
+ ]
602
+ : text;
327
603
  try {
328
- api.sendUserMessage(inbound.text, rt?.busy ? { deliverAs: "steer" } : undefined);
604
+ api.sendUserMessage(content, runtime?.busy ? { deliverAs: "steer" } : undefined);
329
605
  } catch (e) {
330
606
  logger.warn(`notifications: sendUserMessage failed: ${String(e)}`);
331
607
  }
332
608
  return;
333
609
  }
334
610
  if (inbound.kind === "config_command") {
335
- const rt = runtimes.get(id);
336
- if (rt && typeof inbound.redact === "boolean") rt.redact = inbound.redact;
611
+ if (!runtime) return;
612
+ const update: {
613
+ type: "config_update";
614
+ sessionId: string;
615
+ verbosity?: "lean" | "verbose";
616
+ redact?: boolean;
617
+ } = {
618
+ type: "config_update",
619
+ sessionId: id,
620
+ };
621
+ if (inbound.verbosity === "lean" || inbound.verbosity === "verbose") {
622
+ runtime.verbosity = inbound.verbosity;
623
+ update.verbosity = inbound.verbosity;
624
+ }
625
+ if (typeof inbound.redact === "boolean") {
626
+ runtime.redact = inbound.redact;
627
+ update.redact = inbound.redact;
628
+ }
629
+ if (update.verbosity !== undefined || update.redact !== undefined) {
630
+ try {
631
+ runtime.server.pushFrame(JSON.stringify(update));
632
+ } catch (e) {
633
+ logger.warn(`notifications: config_update failed: ${String(e)}`);
634
+ }
635
+ }
337
636
  }
338
637
  });
339
638
 
@@ -341,48 +640,44 @@ export const createNotificationsExtension: ExtensionFactory = api => {
341
640
  const endpoint = await server.start();
342
641
 
343
642
  // Interactive answer source: the ask tool races the local UI against this.
344
- const disposeAnswerSource = registerAskAnswerSource(id, {
345
- awaitAnswer(question, options, signal) {
346
- if (signal?.aborted) return Promise.resolve(undefined);
347
- const askId = `ask:${crypto.randomUUID()}`;
348
- try {
349
- server.registerAsk(
350
- JSON.stringify(
351
- notificationActionPayload(
352
- { id: askId, kind: "ask", sessionId: id, question, options },
353
- { redact, sessionTag: tag },
354
- ),
355
- ),
356
- true,
357
- );
358
- } catch (e) {
359
- logger.warn(`notifications: registerAsk failed: ${String(e)}`);
360
- return Promise.resolve(undefined);
361
- }
362
- return new Promise<string | undefined>(resolve => {
363
- pendingInteractive.set(askId, { resolve, options });
364
- signal?.addEventListener("abort", () => {
365
- if (!pendingInteractive.delete(askId)) return;
366
- // Local UI answered: mark the remote action resolved-locally.
367
- try {
368
- server.resolveLocal(askId, undefined);
369
- } catch {}
370
- resolve(undefined);
371
- });
372
- });
373
- },
643
+ const disposeAnswerSource = registerInteractiveAnswerSource(
644
+ id,
645
+ server,
646
+ pendingInteractive,
647
+ () => runtime?.redact ?? redact,
648
+ tag,
649
+ );
650
+ const disposeFileSink = registerTelegramFileSink(id, async file => {
651
+ try {
652
+ const data = await fs.promises.readFile(file.path);
653
+ server.pushFrame(
654
+ JSON.stringify({
655
+ type: "file_attachment",
656
+ sessionId: id,
657
+ name: path.basename(file.path),
658
+ data: data.toString("base64"),
659
+ caption: file.caption,
660
+ }),
661
+ );
662
+ return { ok: true };
663
+ } catch (e) {
664
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
665
+ }
374
666
  });
375
667
 
376
- runtimes.set(id, {
668
+ runtime = {
377
669
  server,
378
670
  idleSeq: 0,
379
671
  pendingInteractive,
380
672
  disposeAnswerSource,
673
+ disposeFileSink,
381
674
  redact,
675
+ verbosity,
382
676
  sessionTag: tag,
383
677
  busy: false,
384
678
  pendingInbound: new Set<number>(),
385
- });
679
+ };
680
+ runtimes.set(id, runtime);
386
681
  logger.info(`notifications: serving session ${id} at ${endpoint.url} (unattended=${unattended})`);
387
682
 
388
683
  if (settingsAvailable && settings && isGloballyConfigured(cfg)) {
@@ -453,7 +748,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
453
748
 
454
749
  if (command === "off") {
455
750
  disabledSessions.add(id);
456
- const stopped = stopSession(id);
751
+ const stopped = await stopSession(id);
457
752
  ctx.ui.notify(
458
753
  stopped
459
754
  ? "Notifications disabled for this session."
@@ -501,8 +796,9 @@ export const createNotificationsExtension: ExtensionFactory = api => {
501
796
  const running = runtimes.has(id);
502
797
  const locallyDisabled = disabledSessions.has(id);
503
798
  const enabled = isEnabledForSession(id, resolved.cfg);
799
+ const runtime = runtimes.get(id);
504
800
  ctx.ui.notify(
505
- `Notifications ${running ? "running" : enabled ? "enabled" : "disabled"} for this session; redaction ${resolved.cfg.redact ? "on" : "off"}${locallyDisabled ? "; locally off" : ""}.`,
801
+ `Notifications ${running ? "running" : enabled ? "enabled" : "disabled"} for this session; redaction ${(runtime?.redact ?? resolved.cfg.redact) ? "on" : "off"}; verbosity ${runtime?.verbosity ?? resolved.cfg.verbosity}${locallyDisabled ? "; locally off" : ""}.`,
506
802
  "info",
507
803
  );
508
804
  },
@@ -512,6 +808,82 @@ export const createNotificationsExtension: ExtensionFactory = api => {
512
808
  await startSession(ctx);
513
809
  });
514
810
 
811
+ // A session id change within the same process needs reason-aware handling.
812
+ // `/new` and fork CONTINUE the same terminal thread (e.g. plan "approve and
813
+ // execute" clears into a fresh session), so re-key the existing runtime
814
+ // old→new WITHOUT recreating the NotificationServer: the server, its endpoint
815
+ // discovery file, and the daemon's forum topic are all keyed by the original
816
+ // session id and the daemon routes by socket, so the existing topic is reused
817
+ // and the next identity frame renames it in place instead of spawning a new
818
+ // thread. `resume`, by contrast, loads a DIFFERENT, already-persisted session
819
+ // that owns its own topic — tear the previous runtime down and start fresh
820
+ // under the resumed id so the daemon attaches to (or recreates) that
821
+ // session's own discovery + topic rather than hijacking this terminal's.
822
+ api.on("session_switch", async (event, ctx) => {
823
+ const newId = sessionId(ctx);
824
+ const prevId = sessionIdFromFile(event.previousSessionFile);
825
+ if (!prevId || prevId === newId) return;
826
+
827
+ if (event.reason === "resume") {
828
+ stopSession(prevId);
829
+ await startSession(ctx);
830
+ return;
831
+ }
832
+
833
+ // `/new` / fork: re-key in place and rename the existing topic.
834
+ if (disabledSessions.delete(prevId)) disabledSessions.add(newId);
835
+ const rt = runtimes.get(prevId);
836
+ if (!rt || runtimes.has(newId)) return;
837
+ runtimes.delete(prevId);
838
+ runtimes.set(newId, rt);
839
+ // Re-bind the interactive ask answer source: the ask tool resolves the
840
+ // source by the current session id, which just changed.
841
+ try {
842
+ rt.disposeAnswerSource();
843
+ rt.disposeFileSink();
844
+ } catch {}
845
+ rt.disposeAnswerSource = registerInteractiveAnswerSource(
846
+ newId,
847
+ rt.server,
848
+ rt.pendingInteractive,
849
+ () => rt.redact,
850
+ rt.sessionTag,
851
+ );
852
+ rt.disposeFileSink = registerTelegramFileSink(newId, async file => {
853
+ try {
854
+ const data = await fs.promises.readFile(file.path);
855
+ rt.server.pushFrame(
856
+ JSON.stringify({
857
+ type: "file_attachment",
858
+ sessionId: newId,
859
+ name: path.basename(file.path),
860
+ data: data.toString("base64"),
861
+ caption: file.caption,
862
+ }),
863
+ );
864
+ return { ok: true };
865
+ } catch (e) {
866
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
867
+ }
868
+ });
869
+ // Rename the existing topic now when the new session already has a name; a
870
+ // fresh unnamed session is renamed on its next agent_end re-assert, which
871
+ // avoids a transient rename to bare "repo/branch".
872
+ if (ctx.sessionManager.getSessionName()) {
873
+ try {
874
+ rt.server.pushFrame(
875
+ JSON.stringify({
876
+ type: "identity_header",
877
+ sessionId: newId,
878
+ ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
879
+ }),
880
+ );
881
+ } catch (e) {
882
+ logger.warn(`notifications: identity_header (switch) failed: ${String(e)}`);
883
+ }
884
+ }
885
+ });
886
+
515
887
  // Drive the live typing indicator: mark busy when the agent loop starts so
516
888
  // the daemon shows "typing…" in the thread while the agent is thinking,
517
889
  // before any turn output exists. Cleared on `agent_end` below.
@@ -593,7 +965,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
593
965
  // On idle, stream a context update with metadata (token/model usage +
594
966
  // working-tree diff) unless redaction is on. The agent's last message is
595
967
  // NOT repeated here — it is already streamed once via `turn_stream`.
596
- if (!rt.redact) {
968
+ if (!rt.redact && rt.verbosity === "verbose") {
597
969
  const usage = (
598
970
  ctx as { getContextUsage?: () => { tokens: number | null; contextWindow: number } | undefined }
599
971
  ).getContextUsage?.();
@@ -694,7 +1066,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
694
1066
  }
695
1067
  });
696
1068
 
697
- api.on("session_shutdown", (_event, ctx) => {
698
- stopSession(sessionId(ctx));
1069
+ api.on("session_shutdown", async (_event, ctx) => {
1070
+ await stopSession(sessionId(ctx));
699
1071
  });
700
1072
  };