@sayknow-cli/coding-agent 0.3.7 → 0.3.9

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 (272) hide show
  1. package/dist/types/append-only-mode.d.ts +31 -0
  2. package/dist/types/capability/rule.d.ts +8 -0
  3. package/dist/types/cli/completion-cli.d.ts +41 -0
  4. package/dist/types/cli/config-cli.d.ts +1 -0
  5. package/dist/types/cli/fixture-report.d.ts +20 -0
  6. package/dist/types/cli/fixture-report.test.d.ts +1 -0
  7. package/dist/types/cli/nofile-limit.d.ts +11 -0
  8. package/dist/types/cli/notify-cli.d.ts +7 -0
  9. package/dist/types/cli/update-cli.d.ts +22 -0
  10. package/dist/types/cli.d.ts +124 -1
  11. package/dist/types/commands/completion.d.ts +29 -0
  12. package/dist/types/commands/config.d.ts +3 -0
  13. package/dist/types/commands/deep-interview.d.ts +3 -0
  14. package/dist/types/config/file-lock.d.ts +7 -6
  15. package/dist/types/config/keybindings.d.ts +2 -2
  16. package/dist/types/config/model-profile-activation.d.ts +6 -0
  17. package/dist/types/config/model-profiles.d.ts +1 -0
  18. package/dist/types/config/settings-schema.d.ts +119 -1
  19. package/dist/types/config/settings.d.ts +8 -0
  20. package/dist/types/discovery/index.d.ts +1 -0
  21. package/dist/types/export/html/template.generated.d.ts +1 -1
  22. package/dist/types/export/ttsr.d.ts +14 -3
  23. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  24. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  25. package/dist/types/goals/runtime.d.ts +1 -0
  26. package/dist/types/hooks/native-skill-hook.d.ts +12 -0
  27. package/dist/types/main.d.ts +4 -1
  28. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  29. package/dist/types/modes/components/model-selector.d.ts +4 -0
  30. package/dist/types/modes/components/queued-message-selector.d.ts +12 -0
  31. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  32. package/dist/types/modes/components/status-line.d.ts +9 -0
  33. package/dist/types/modes/components/thinking-selector.d.ts +2 -2
  34. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  35. package/dist/types/modes/controllers/event-controller.d.ts +5 -0
  36. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  37. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  38. package/dist/types/modes/interactive-mode.d.ts +4 -0
  39. package/dist/types/modes/print-mode.d.ts +13 -1
  40. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -1
  41. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  42. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  43. package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
  44. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
  45. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
  46. package/dist/types/modes/types.d.ts +3 -0
  47. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  48. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  49. package/dist/types/notifications/config-commands.d.ts +9 -0
  50. package/dist/types/notifications/config.d.ts +30 -1
  51. package/dist/types/notifications/daemon-paths.d.ts +1 -0
  52. package/dist/types/notifications/index.d.ts +8 -3
  53. package/dist/types/notifications/lifecycle-commands.d.ts +12 -3
  54. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  55. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  56. package/dist/types/notifications/rich-draft.d.ts +68 -0
  57. package/dist/types/notifications/rich-render.d.ts +90 -0
  58. package/dist/types/notifications/telegram-daemon.d.ts +45 -3
  59. package/dist/types/notifications/telegram-reference.d.ts +7 -5
  60. package/dist/types/notifications/threaded-render.d.ts +14 -0
  61. package/dist/types/runtime-api-key.d.ts +7 -0
  62. package/dist/types/sdk.d.ts +3 -0
  63. package/dist/types/session/agent-session.d.ts +61 -6
  64. package/dist/types/session/agent-storage.d.ts +1 -0
  65. package/dist/types/session/file-mention-pruning.d.ts +8 -0
  66. package/dist/types/session/messages.d.ts +4 -0
  67. package/dist/types/session/session-manager.d.ts +17 -1
  68. package/dist/types/skc-runtime/deep-interview-ambiguity.d.ts +39 -0
  69. package/dist/types/skc-runtime/deep-interview-recorder.d.ts +10 -1
  70. package/dist/types/skc-runtime/deep-interview-state.d.ts +17 -2
  71. package/dist/types/skc-runtime/session-state-sidecar.d.ts +7 -0
  72. package/dist/types/skc-runtime/state-writer.d.ts +3 -0
  73. package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
  74. package/dist/types/skc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
  75. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +120 -13
  76. package/dist/types/skill-state/active-state.d.ts +18 -1
  77. package/dist/types/system-prompt.d.ts +18 -2
  78. package/dist/types/task/executor.d.ts +3 -0
  79. package/dist/types/task/fork-context-budget.d.ts +7 -0
  80. package/dist/types/task/token-log.d.ts +30 -0
  81. package/dist/types/task/token-log.test.d.ts +1 -0
  82. package/dist/types/tools/bisect.d.ts +81 -0
  83. package/dist/types/tools/computer.d.ts +2 -2
  84. package/dist/types/tools/index.d.ts +1 -0
  85. package/dist/types/tools/search.d.ts +2 -2
  86. package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
  87. package/dist/types/utils/file-mentions.d.ts +14 -0
  88. package/dist/types/utils/git.d.ts +32 -0
  89. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  90. package/dist/types/web/scrapers/youtube.d.ts +8 -0
  91. package/dist/types/workflow/workflow-intent-diff.d.ts +8 -2
  92. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
  93. package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
  94. package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
  95. package/package.json +8 -8
  96. package/src/append-only-mode.ts +58 -0
  97. package/src/autoresearch/prompt.md +1 -1
  98. package/src/autoresearch/tools/log-experiment.ts +1 -1
  99. package/src/capability/rule.ts +9 -0
  100. package/src/cli/completion-cli.ts +234 -0
  101. package/src/cli/config-cli.ts +61 -9
  102. package/src/cli/fast-help.ts +1 -0
  103. package/src/cli/fixture-report.test.ts +230 -0
  104. package/src/cli/fixture-report.ts +171 -0
  105. package/src/cli/nofile-limit.ts +48 -0
  106. package/src/cli/notify-cli.ts +103 -17
  107. package/src/cli/telegram-cli.ts +1 -1
  108. package/src/cli/update-cli.ts +148 -33
  109. package/src/cli.ts +29 -1
  110. package/src/commands/completion.ts +84 -0
  111. package/src/commands/config.ts +2 -0
  112. package/src/commands/contribution-prep.ts +1 -1
  113. package/src/commands/deep-interview.ts +2 -1
  114. package/src/config/file-lock.ts +73 -34
  115. package/src/config/keybindings.ts +3 -3
  116. package/src/config/model-profile-activation.ts +65 -0
  117. package/src/config/model-profiles.ts +31 -2
  118. package/src/config/settings-schema.ts +76 -1
  119. package/src/config/settings.ts +22 -0
  120. package/src/defaults/skc/skills/team/SKILL.md +17 -14
  121. package/src/defaults/skc/skills/ultragoal/SKILL.md +59 -4
  122. package/src/defaults/skc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
  123. package/src/defaults/skc-defaults.ts +9 -0
  124. package/src/discovery/helpers.ts +10 -0
  125. package/src/discovery/index.ts +1 -0
  126. package/src/export/html/template.generated.ts +1 -1
  127. package/src/export/html/template.js +60 -2
  128. package/src/export/ttsr.ts +96 -18
  129. package/src/extensibility/extensions/runner.ts +183 -219
  130. package/src/extensibility/extensions/types.ts +9 -0
  131. package/src/extensibility/skc-plugins/runtime-adapters.ts +156 -44
  132. package/src/goals/runtime.ts +10 -2
  133. package/src/hindsight/backend.ts +1 -3
  134. package/src/hooks/native-skill-hook.ts +124 -4
  135. package/src/internal-urls/docs-index.generated.ts +17 -8
  136. package/src/lsp/client.ts +1 -0
  137. package/src/main.ts +95 -43
  138. package/src/memories/index.ts +12 -1
  139. package/src/modes/components/assistant-message.ts +241 -82
  140. package/src/modes/components/custom-editor.ts +1 -0
  141. package/src/modes/components/footer.ts +7 -2
  142. package/src/modes/components/model-selector.ts +54 -11
  143. package/src/modes/components/queued-message-selector.ts +92 -0
  144. package/src/modes/components/settings-selector.ts +4 -0
  145. package/src/modes/components/status-line.ts +167 -54
  146. package/src/modes/components/thinking-selector.ts +10 -8
  147. package/src/modes/components/tool-execution.ts +44 -19
  148. package/src/modes/components/welcome.ts +181 -44
  149. package/src/modes/controllers/command-controller.ts +40 -4
  150. package/src/modes/controllers/event-controller.ts +77 -49
  151. package/src/modes/controllers/extension-ui-controller.ts +12 -11
  152. package/src/modes/controllers/input-controller.ts +192 -35
  153. package/src/modes/controllers/selector-controller.ts +168 -24
  154. package/src/modes/interactive-mode.ts +56 -16
  155. package/src/modes/print-mode.ts +43 -4
  156. package/src/modes/prompt-action-autocomplete.ts +21 -20
  157. package/src/modes/rpc/rpc-mode.ts +32 -12
  158. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  159. package/src/modes/rpc/rpc-types.ts +9 -0
  160. package/src/modes/shared/agent-wire/command-validation.ts +4 -0
  161. package/src/modes/shared/agent-wire/event-contract.ts +22 -0
  162. package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
  163. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  164. package/src/modes/types.ts +3 -0
  165. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  166. package/src/modes/utils/injected-user-submission.ts +94 -0
  167. package/src/modes/utils/ui-helpers.ts +20 -7
  168. package/src/notifications/config-commands.ts +20 -0
  169. package/src/notifications/config.ts +52 -5
  170. package/src/notifications/daemon-paths.ts +2 -0
  171. package/src/notifications/index.ts +144 -18
  172. package/src/notifications/lifecycle-commands.ts +74 -23
  173. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  174. package/src/notifications/lifecycle-orchestrator.ts +19 -6
  175. package/src/notifications/reply-sent-store.ts +134 -0
  176. package/src/notifications/rich-draft.ts +107 -0
  177. package/src/notifications/rich-render.ts +142 -0
  178. package/src/notifications/telegram-daemon-control.ts +5 -3
  179. package/src/notifications/telegram-daemon.ts +606 -98
  180. package/src/notifications/telegram-reference.ts +17 -16
  181. package/src/notifications/threaded-render.ts +45 -2
  182. package/src/prompts/agents/architect.md +19 -10
  183. package/src/prompts/agents/critic.md +16 -18
  184. package/src/prompts/agents/executor.md +14 -11
  185. package/src/prompts/agents/planner.md +18 -13
  186. package/src/prompts/ci-green-request.md +3 -8
  187. package/src/prompts/goals/goal-continuation.md +0 -5
  188. package/src/prompts/goals/goal-mode-active.md +0 -5
  189. package/src/prompts/system/agent-creation-architect.md +2 -2
  190. package/src/prompts/system/plan-mode-active.md +1 -1
  191. package/src/prompts/system/plan-mode-subagent.md +3 -3
  192. package/src/prompts/system/project-prompt.md +0 -13
  193. package/src/prompts/system/subagent-system-prompt.md +3 -2
  194. package/src/prompts/system/system-prompt.md +29 -17
  195. package/src/prompts/system/volatile-project-context.md +10 -0
  196. package/src/prompts/system/web-search.md +4 -7
  197. package/src/prompts/tools/apply-patch.md +6 -0
  198. package/src/prompts/tools/ast-edit.md +1 -0
  199. package/src/prompts/tools/bash.md +1 -1
  200. package/src/prompts/tools/bisect.md +22 -0
  201. package/src/prompts/tools/browser.md +1 -1
  202. package/src/prompts/tools/computer.md +2 -2
  203. package/src/prompts/tools/cron.md +0 -2
  204. package/src/prompts/tools/eval.md +2 -2
  205. package/src/prompts/tools/find.md +1 -1
  206. package/src/prompts/tools/github.md +6 -6
  207. package/src/prompts/tools/goal.md +1 -3
  208. package/src/prompts/tools/image-gen.md +12 -5
  209. package/src/prompts/tools/irc.md +5 -4
  210. package/src/prompts/tools/job.md +2 -2
  211. package/src/prompts/tools/lsp.md +1 -1
  212. package/src/prompts/tools/monitor.md +2 -2
  213. package/src/prompts/tools/patch.md +2 -2
  214. package/src/prompts/tools/replace.md +2 -21
  215. package/src/prompts/tools/rewind.md +1 -1
  216. package/src/prompts/tools/search-tool-bm25.md +6 -11
  217. package/src/prompts/tools/search.md +1 -1
  218. package/src/prompts/tools/skill.md +3 -2
  219. package/src/prompts/tools/ssh.md +2 -1
  220. package/src/prompts/tools/subagent.md +2 -2
  221. package/src/prompts/tools/task.md +4 -5
  222. package/src/prompts/tools/todo-write.md +1 -1
  223. package/src/prompts/tools/vim.md +1 -0
  224. package/src/prompts/tools/web-search.md +2 -0
  225. package/src/prompts/tools/write.md +8 -2
  226. package/src/runtime-api-key.ts +16 -0
  227. package/src/sdk.ts +88 -36
  228. package/src/session/agent-session.ts +1013 -141
  229. package/src/session/agent-storage.ts +4 -0
  230. package/src/session/file-mention-pruning.ts +64 -0
  231. package/src/session/messages.ts +4 -0
  232. package/src/session/session-manager.ts +103 -4
  233. package/src/skc-runtime/deep-interview-ambiguity.ts +206 -0
  234. package/src/skc-runtime/deep-interview-recorder.ts +62 -8
  235. package/src/skc-runtime/deep-interview-state.ts +44 -2
  236. package/src/skc-runtime/session-state-sidecar.ts +263 -20
  237. package/src/skc-runtime/state-runtime.ts +12 -2
  238. package/src/skc-runtime/state-writer.ts +15 -0
  239. package/src/skc-runtime/tmux-common.ts +1 -0
  240. package/src/skc-runtime/ultragoal-guard.ts +55 -66
  241. package/src/skc-runtime/ultragoal-receipt-freshness.ts +315 -0
  242. package/src/skc-runtime/ultragoal-runtime.ts +1672 -159
  243. package/src/skc-runtime/workflow-manifest.generated.json +113 -2
  244. package/src/skc-runtime/workflow-manifest.ts +74 -1
  245. package/src/skill-state/active-state.ts +141 -5
  246. package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
  247. package/src/slash-commands/builtin-registry.ts +296 -72
  248. package/src/system-prompt.ts +57 -16
  249. package/src/task/executor.ts +132 -24
  250. package/src/task/fork-context-advisory.ts +2 -7
  251. package/src/task/fork-context-budget.ts +9 -0
  252. package/src/task/index.ts +13 -6
  253. package/src/task/output-manager.ts +20 -8
  254. package/src/task/receipt.ts +26 -7
  255. package/src/task/token-log.test.ts +188 -0
  256. package/src/task/token-log.ts +179 -0
  257. package/src/tools/bisect.ts +367 -0
  258. package/src/tools/computer.ts +246 -43
  259. package/src/tools/index.ts +3 -0
  260. package/src/tools/output-meta.ts +116 -4
  261. package/src/tools/search.ts +8 -2
  262. package/src/tools/skill.ts +15 -0
  263. package/src/tools/telegram-send.ts +6 -0
  264. package/src/tools/ultragoal-ask-guard.ts +39 -5
  265. package/src/utils/file-mentions.ts +39 -7
  266. package/src/utils/git.ts +73 -0
  267. package/src/utils/pasted-image-path.ts +170 -0
  268. package/src/web/scrapers/youtube.ts +24 -12
  269. package/src/workflow/workflow-intent-diff.ts +29 -6
  270. package/src/workflow/workflow-intent-report-consumer.ts +221 -0
  271. package/src/workflow/workflow-intent-report-schema.ts +129 -0
  272. package/src/workflow/workflow-intent-report.ts +135 -0
@@ -0,0 +1,94 @@
1
+ import type { ImageContent, TextContent } from "@sayknow-cli/ai";
2
+ import type { InteractiveModeContext } from "../types";
3
+
4
+ /**
5
+ * Normalize the content passed to an extension `sendUserMessage` call into a
6
+ * plain text string plus its image attachments. Mirrors the normalization in
7
+ * `AgentSession.sendUserMessage` (text parts joined with "\n") so the resulting
8
+ * text matches the eventual user `message_start` payload.
9
+ */
10
+ export function normalizeInjectedUserContent(content: string | (TextContent | ImageContent)[]): {
11
+ text: string;
12
+ images: ImageContent[];
13
+ imageCount: number;
14
+ } {
15
+ if (typeof content === "string") {
16
+ return { text: content, images: [], imageCount: 0 };
17
+ }
18
+ const textParts: string[] = [];
19
+ const images: ImageContent[] = [];
20
+ for (const part of content) {
21
+ if (part.type === "text") textParts.push(part.text);
22
+ else images.push(part);
23
+ }
24
+ const text = textParts.join("\n");
25
+ return { text, images, imageCount: images.length };
26
+ }
27
+
28
+ /**
29
+ * Record a remotely/programmatically injected user message (e.g. Telegram
30
+ * inbound routed through the extension API) into the interactive TUI, so it is
31
+ * captured in prompt history and shown immediately instead of only appearing
32
+ * once the eventual `message_start` event lands.
33
+ *
34
+ * Local TUI submissions never reach this path (they go through
35
+ * `session.prompt(...)` / `startPendingSubmission`), so this cannot double-add
36
+ * local prompt history.
37
+ *
38
+ * - Always adds the injected text to editor prompt history.
39
+ * - Idle injections optimistically render the user message and record a pending
40
+ * injected optimistic signature (a counting Map, so multiple idle injections
41
+ * before the first `message_start` do not clobber each other); the later user
42
+ * `message_start` consumes one count and skips both the duplicate chat add and
43
+ * the defensive editor clear (so a locally typed draft is preserved).
44
+ * - Busy/queued injections refresh the pending-message display, which the
45
+ * caller has already populated by invoking `session.sendUserMessage(...)`
46
+ * before this helper.
47
+ *
48
+ * This helper never clears the editor text.
49
+ */
50
+ export function applyInjectedUserSubmission(
51
+ ctx: InteractiveModeContext,
52
+ input: { content: string | (TextContent | ImageContent)[]; queued: boolean },
53
+ ): void {
54
+ const { text, images, imageCount } = normalizeInjectedUserContent(input.content);
55
+ ctx.editor.addToHistory(text);
56
+
57
+ if (input.queued) {
58
+ ctx.updatePendingMessagesDisplay();
59
+ ctx.ui.requestRender();
60
+ return;
61
+ }
62
+
63
+ incrementInjectedOptimisticSignature(ctx, `${text}\u0000${imageCount}`);
64
+ ctx.addMessageToChat({
65
+ role: "user",
66
+ content: [{ type: "text", text }, ...images],
67
+ attribution: "user",
68
+ timestamp: Date.now(),
69
+ });
70
+ ctx.ui.requestRender();
71
+ }
72
+
73
+ /**
74
+ * Record one pending optimistic render for an injected user message.
75
+ *
76
+ * Injected sends are fire-and-forget and multiple idle injections can be
77
+ * rendered before the first `message_start` arrives, so a counting Map (not a
78
+ * single slot) tracks how many optimistic renders are outstanding per signature.
79
+ */
80
+ export function incrementInjectedOptimisticSignature(ctx: InteractiveModeContext, signature: string): void {
81
+ ctx.optimisticInjectedSignatures.set(signature, (ctx.optimisticInjectedSignatures.get(signature) ?? 0) + 1);
82
+ }
83
+
84
+ /**
85
+ * Consume one pending injected optimistic render for `signature`. Decrements the
86
+ * count (deleting the key at zero) and returns whether one was outstanding.
87
+ */
88
+ export function consumeInjectedOptimisticSignature(ctx: InteractiveModeContext, signature: string): boolean {
89
+ const count = ctx.optimisticInjectedSignatures.get(signature) ?? 0;
90
+ if (count <= 0) return false;
91
+ if (count === 1) ctx.optimisticInjectedSignatures.delete(signature);
92
+ else ctx.optimisticInjectedSignatures.set(signature, count - 1);
93
+ return true;
94
+ }
@@ -2,9 +2,9 @@ import type { AgentMessage } from "@sayknow-cli/agent-core";
2
2
  import type { AssistantMessage, ImageContent, Message } from "@sayknow-cli/ai";
3
3
  import { type Component, Spacer, Text, TruncatedText } from "@sayknow-cli/tui";
4
4
  import { settings } from "../../config/settings";
5
- import { tMessage } from "../../i18n";
6
5
  import { resolveSubskillActivationForSkillInvocation } from "../../extensibility/skc-plugins";
7
6
  import { buildSkillPromptMessage, parseSkillInvocations } from "../../extensibility/skills";
7
+ import { tMessage } from "../../i18n";
8
8
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
9
9
  import { BashExecutionComponent } from "../../modes/components/bash-execution";
10
10
  import { BranchSummaryMessageComponent } from "../../modes/components/branch-summary-message";
@@ -37,6 +37,19 @@ interface RenderInitialMessagesOptions {
37
37
  preserveExistingChat?: boolean;
38
38
  }
39
39
 
40
+ export function argsWithPartialJson(args: unknown, partialJson: unknown): unknown {
41
+ if (typeof partialJson !== "string" || !args || typeof args !== "object" || Array.isArray(args)) return args;
42
+ // Non-enumerable so the transient streaming buffer reaches renderers via direct
43
+ // property read but never serializes into persisted/wire message content.
44
+ Object.defineProperty(args, "__partialJson", {
45
+ value: partialJson,
46
+ enumerable: false,
47
+ configurable: true,
48
+ writable: true,
49
+ });
50
+ return args;
51
+ }
52
+
40
53
  type QueuedMessages = {
41
54
  steering: string[];
42
55
  followUp: string[];
@@ -394,10 +407,10 @@ export class UiHelpers {
394
407
 
395
408
  readGroup = null;
396
409
  const tool = this.ctx.session.getToolByName(content.name);
397
- const renderArgs =
398
- "partialJson" in content
399
- ? { ...content.arguments, __partialJson: content.partialJson }
400
- : content.arguments;
410
+ const renderArgs = argsWithPartialJson(
411
+ content.arguments,
412
+ "partialJson" in content ? content.partialJson : undefined,
413
+ );
401
414
  const component = new ToolExecutionComponent(
402
415
  content.name,
403
416
  renderArgs,
@@ -605,8 +618,8 @@ export class UiHelpers {
605
618
  const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`);
606
619
  this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
607
620
  }
608
- const dequeueKey = this.ctx.keybindings.getDisplayString("app.message.dequeue") || "Alt+Up";
609
- const hintText = theme.fg("dim", `${theme.tree.hook} ${dequeueKey} to edit`);
621
+ const dequeueKey = this.ctx.keybindings.getDisplayString("app.message.dequeue") || "Alt+Up/Alt+Down";
622
+ const hintText = theme.fg("dim", `${theme.tree.hook} ${dequeueKey} to select/edit/reorder`);
610
623
  this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
611
624
  }
612
625
  }
@@ -48,3 +48,23 @@ export function parseInThreadConfigCommand(text: string): ConfigCommandChange |
48
48
  return undefined;
49
49
  }
50
50
  }
51
+
52
+ /**
53
+ * Parse a `/rich on|off` toggle. Returns `true`/`false` for a recognised
54
+ * on/off argument, or `undefined` otherwise (not a `/rich` command, or `/rich`
55
+ * with a missing/invalid argument). This is intentionally SEPARATE from
56
+ * `parseInThreadConfigCommand`: `/verbose`/`/redact` are producer/session config
57
+ * forwarded over the WS, whereas rich is Telegram-daemon delivery policy handled
58
+ * daemon-locally, so it never becomes a `config_command` frame or a user turn.
59
+ */
60
+ export function parseRichToggleCommand(text: string): boolean | undefined {
61
+ const trimmed = text.trim();
62
+ if (!trimmed.startsWith("/")) return undefined;
63
+ const [rawCommand, ...rest] = trimmed.slice(1).split(/\s+/);
64
+ // Accept the "/rich@botname" form Telegram appends in group chats.
65
+ if (rawCommand?.toLowerCase().split("@")[0] !== "rich") return undefined;
66
+ const arg = rest[0]?.toLowerCase();
67
+ if (arg === "on" || arg === "true" || arg === "1") return true;
68
+ if (arg === "off" || arg === "false" || arg === "0") return false;
69
+ return undefined;
70
+ }
@@ -16,6 +16,12 @@ export interface NotificationConfig {
16
16
  redact: boolean;
17
17
  verbosity: "lean" | "verbose";
18
18
  idleTimeoutMs: number;
19
+ rich: {
20
+ enabled: boolean;
21
+ };
22
+ richDraft: {
23
+ enabled: boolean;
24
+ };
19
25
  }
20
26
 
21
27
  /** Read typed config from Settings. */
@@ -35,24 +41,63 @@ export function getNotificationConfig(settings: Settings): NotificationConfig {
35
41
  redact: settings.get("notifications.redact"),
36
42
  verbosity: settings.get("notifications.verbosity") === "verbose" ? "verbose" : "lean",
37
43
  idleTimeoutMs: settings.get("notifications.daemon.idleTimeoutMs"),
44
+ rich: {
45
+ enabled: settings.get("notifications.telegram.rich.enabled"),
46
+ },
47
+ richDraft: {
48
+ enabled: settings.get("notifications.telegram.richDraft.enabled"),
49
+ },
38
50
  };
39
51
  }
40
52
 
53
+ export function hasNonBlankValue(value: string | undefined): boolean {
54
+ return typeof value === "string" && value.trim().length > 0;
55
+ }
56
+
57
+ /** Is Telegram configured with usable non-blank boundary credentials? */
58
+ export function isTelegramConfigured(
59
+ cfg: NotificationConfig,
60
+ ): cfg is NotificationConfig & { botToken: string; chatId: string } {
61
+ return cfg.enabled && hasNonBlankValue(cfg.botToken) && hasNonBlankValue(cfg.chatId);
62
+ }
63
+
41
64
  /** Is global config sufficient for auto-on (enabled + at least one configured adapter)? */
42
65
  export function isGloballyConfigured(cfg: NotificationConfig): boolean {
43
66
  return (
44
67
  cfg.enabled &&
45
- ((Boolean(cfg.botToken) && Boolean(cfg.chatId)) ||
46
- (Boolean(cfg.discord.botToken) && Boolean(cfg.discord.channelId)) ||
47
- (Boolean(cfg.slack.botToken) && Boolean(cfg.slack.channelId)))
68
+ (isTelegramConfigured(cfg) ||
69
+ (hasNonBlankValue(cfg.discord.botToken) && hasNonBlankValue(cfg.discord.channelId)) ||
70
+ (hasNonBlankValue(cfg.slack.botToken) && hasNonBlankValue(cfg.slack.channelId)))
48
71
  );
49
72
  }
50
73
 
74
+ /**
75
+ * Per-run opt-out for completion notifications, honored before settings lookups.
76
+ *
77
+ * `SKC_NOTIFY=off` (also `0` / `false`, case-insensitive) suppresses the
78
+ * completion notification surface for this process only. `config.yml` is
79
+ * untouched and child processes inherit the env var, which lets non-interactive
80
+ * fleet runs (`skc -p --no-session`) stay silent even when a user-level/global
81
+ * completion notification configuration is enabled.
82
+ */
83
+ export function completionNotifyDisabledByEnv(env: NodeJS.ProcessEnv): boolean {
84
+ const v = env.SKC_NOTIFY?.trim().toLowerCase();
85
+ return v === "off" || v === "0" || v === "false";
86
+ }
87
+
51
88
  /** Resolve whether the notifications extension should be registered at SDK startup. */
52
89
  export function shouldRegisterNotificationsExtension(input: {
53
90
  env: NodeJS.ProcessEnv;
54
91
  cfg?: NotificationConfig;
92
+ /** Task recursion depth; helper/subagent sessions must not spawn remote surfaces. */
93
+ taskDepth?: number;
94
+ /** Parent subagent id/prefix; present for helper/subagent sessions even when depth is omitted. */
95
+ parentTaskPrefix?: string;
96
+ /** Role-agent type/name; present for task sessions even if depth metadata is lost. */
97
+ currentAgentType?: string;
55
98
  }): boolean {
99
+ if ((input.taskDepth ?? 0) > 0 || input.parentTaskPrefix || input.currentAgentType) return false;
100
+ if (completionNotifyDisabledByEnv(input.env)) return false;
56
101
  if (input.env.SKC_NOTIFICATIONS === "0") return false;
57
102
  if (input.env.SKC_NOTIFICATIONS === "1" || input.env.SKC_NOTIFICATIONS_TOKEN) return true;
58
103
  return input.cfg ? isGloballyConfigured(input.cfg) : false;
@@ -81,6 +126,7 @@ export function isSessionNotificationsEnabled(input: {
81
126
  /** Mask a bot token for display: first 4 chars + "…" + "(len N)"; "(unset)" when undefined/empty. Never reveal full token. */
82
127
  export function maskToken(token: string | undefined): string {
83
128
  if (!token) return "(unset)";
129
+ if (token.length <= 4) return `…(len ${token.length})`;
84
130
  return `${token.slice(0, 4)}…(len ${token.length})`;
85
131
  }
86
132
 
@@ -112,7 +158,8 @@ export interface RedactableAction {
112
158
  * When redact is false, return the action unchanged.
113
159
  *
114
160
  * Redaction still applies to streamed content frames (turn_stream, context_update,
115
- * image_attachment) which are suppressed at their emit sites, not here.
161
+ * image_attachment) which are suppressed at their emit sites, not here. Explicit
162
+ * `telegram_send` file attachments are rejected before the file is read or forwarded.
116
163
  */
117
164
  export function buildRedactedAction(
118
165
  action: RedactableAction,
@@ -123,6 +170,6 @@ export function buildRedactedAction(
123
170
  // Asks stay fully readable/answerable even under redaction.
124
171
  if (action.kind === "ask") return action;
125
172
 
126
- const { summary: _summary, question: _question, ...base } = action;
173
+ const { summary: _summary, question: _question, options: _options, ...base } = action;
127
174
  return base;
128
175
  }
@@ -7,6 +7,7 @@ export interface DaemonPaths {
7
7
  roots: string;
8
8
  steal: string;
9
9
  aliases: string;
10
+ seenUpdates: string;
10
11
  }
11
12
 
12
13
  export function daemonPaths(agentDir: string): DaemonPaths {
@@ -18,5 +19,6 @@ export function daemonPaths(agentDir: string): DaemonPaths {
18
19
  roots: path.join(dir, "telegram-daemon.roots.json"),
19
20
  steal: path.join(dir, "telegram-daemon.steal"),
20
21
  aliases: path.join(dir, "telegram-callback-aliases.json"),
22
+ seenUpdates: path.join(dir, "telegram-seen-updates.json"),
21
23
  };
22
24
  }
@@ -28,13 +28,13 @@ import type { ImageContent, TextContent } from "@sayknow-cli/ai";
28
28
  import { NotificationServer } from "@sayknow-cli/natives";
29
29
  import { logger, postmortem } from "@sayknow-cli/utils";
30
30
  import { Settings } from "../config/settings";
31
- import type { ExtensionCommandContext, ExtensionContext, ExtensionFactory } from "../extensibility/extensions";
31
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../extensibility/extensions";
32
32
  import { registerAskAnswerSource } from "../tools/ask-answer-registry";
33
33
  import { registerTelegramFileSink } from "./attachment-registry";
34
34
  import {
35
35
  getNotificationConfig,
36
- isGloballyConfigured,
37
36
  isSessionNotificationsEnabled,
37
+ isTelegramConfigured,
38
38
  type NotificationConfig,
39
39
  sessionTag,
40
40
  } from "./config";
@@ -90,6 +90,8 @@ export interface SessionCreateFrame {
90
90
  target: SessionCreateTarget;
91
91
  /** Reference to the daemon-written, once-consumed startup-prompt file. */
92
92
  startupPromptRef?: string;
93
+ /** Model profile preset to activate for the spawned session (--mpreset). */
94
+ modelPreset?: string;
93
95
  }
94
96
 
95
97
  /** Close (hard-kill, history preserved) a session. */
@@ -100,7 +102,7 @@ export interface SessionCloseFrame {
100
102
  chatId: string;
101
103
  token: string;
102
104
  target: SessionCloseTarget;
103
- /** Hard-kill even if a live pane is attached (SKC-managed only). */
105
+ /** Required force-only close flag; false/omitted is rejected by daemon policy. */
104
106
  force?: boolean;
105
107
  }
106
108
 
@@ -336,6 +338,8 @@ interface SessionRuntime {
336
338
  disposeAnswerSource: () => void;
337
339
  /** Deregisters this session's Telegram file sink. */
338
340
  disposeFileSink: () => void;
341
+ /** Deregisters this session's unattended workflow-gate listener. */
342
+ disposeGateListener: () => void;
339
343
  redact: boolean;
340
344
  verbosity: "lean" | "verbose";
341
345
  sessionTag: string;
@@ -348,6 +352,15 @@ interface SessionRuntime {
348
352
  /** Assistant text already flushed before an ask this turn (turn-scoped dedupe
349
353
  * so turn_end does not re-emit the pre-ask lead-in). Reset each turn. */
350
354
  preAskFlushedText?: string;
355
+ /** Live streaming: opt-in flag, monotonic per-turn ref, and emit throttle state. */
356
+ stream: boolean;
357
+ turnSeq?: number;
358
+ liveRef?: string;
359
+ lastLiveAt?: number;
360
+ lastLiveText?: string;
361
+ /** True between turn_end and the next turn_start: drops late async message_update
362
+ * frames so a stale live edit can never be emitted after the finalized turn. */
363
+ turnClosed?: boolean;
351
364
  /** Cancels the postmortem cleanup that emits `session_closed` on process teardown. */
352
365
  cancelPostmortemCleanup: () => void;
353
366
  }
@@ -358,6 +371,8 @@ interface ResolvedSettings {
358
371
  settingsAvailable: boolean;
359
372
  }
360
373
 
374
+ const TELEGRAM_FILE_REDACTION_ERROR = "Telegram file attachments are disabled while notifications redaction is on.";
375
+
361
376
  const defaultConfig: NotificationConfig = {
362
377
  enabled: false,
363
378
  botToken: undefined,
@@ -373,13 +388,44 @@ const defaultConfig: NotificationConfig = {
373
388
  redact: false,
374
389
  verbosity: "lean",
375
390
  idleTimeoutMs: 60_000,
391
+ rich: { enabled: true },
392
+ richDraft: { enabled: false },
376
393
  };
377
394
 
378
395
  export function notificationsEnabled(): boolean {
379
396
  return process.env.SKC_NOTIFICATIONS === "1" || Boolean(process.env.SKC_NOTIFICATIONS_TOKEN);
380
397
  }
381
398
 
382
- function resolveSettings(): ResolvedSettings {
399
+ // Live streaming (opt-in): emit throttled non-finalized `turn_stream` frames as
400
+ // the assistant message streams so remote clients can edit ONE message live. The
401
+ // finalized frame (turn_end) carries the same messageRef and stays authoritative,
402
+ // so a dropped live frame self-heals. Off unless SKC_NOTIFICATIONS_STREAM=1.
403
+ function streamingEnabled(): boolean {
404
+ return process.env.SKC_NOTIFICATIONS_STREAM === "1";
405
+ }
406
+ function streamIntervalMs(): number {
407
+ return Math.max(200, Number(process.env.SKC_NOTIFICATIONS_STREAM_INTERVAL_MS) || 500);
408
+ }
409
+ // Max chars of a turn's assistant text carried by the FINALIZED turn_stream (and
410
+ // the pre-ask capture). Default 3500 keeps the mirror a glanceable per-turn
411
+ // summary; a client that splits long messages (the Telegram daemon does so via
412
+ // splitTelegramHtml, scheduling each chunk through the shared rate-limit pool so
413
+ // the fan-out never bypasses the per-chat limit) can raise it with
414
+ // SKC_NOTIFICATIONS_TURN_MAX to deliver full turns. The value is clamped to a
415
+ // finite [280, TURN_TEXT_MAX_CEILING] range: a non-finite or non-positive env
416
+ // (unset, NaN, Infinity, <= 0) falls back to the default, so the cap can never
417
+ // be unbounded. Live frames are intentionally NOT raised — they stay one
418
+ // editable preview message rather than fanning a long in-progress turn across
419
+ // sends.
420
+ const TURN_TEXT_MAX_CEILING = 40_000;
421
+ function turnTextMax(): number {
422
+ const raw = Number(process.env.SKC_NOTIFICATIONS_TURN_MAX);
423
+ if (!Number.isFinite(raw) || raw <= 0) return 3500;
424
+ return Math.min(TURN_TEXT_MAX_CEILING, Math.max(280, raw));
425
+ }
426
+ function resolveSettings(settingsOverride?: Settings): ResolvedSettings {
427
+ if (settingsOverride)
428
+ return { settings: settingsOverride, cfg: getNotificationConfig(settingsOverride), settingsAvailable: true };
383
429
  try {
384
430
  const settings = Settings.instance;
385
431
  return { settings, cfg: getNotificationConfig(settings), settingsAvailable: true };
@@ -491,7 +537,7 @@ function sessionIdFromFile(file: string | undefined): string | undefined {
491
537
  return underscore >= 0 ? base.slice(underscore + 1) : undefined;
492
538
  }
493
539
 
494
- export const createNotificationsExtension: ExtensionFactory = api => {
540
+ export function createNotificationsExtension(api: ExtensionAPI, options: { settings?: Settings } = {}): void {
495
541
  const runtimes = new Map<string, SessionRuntime>();
496
542
  const disabledSessions = new Set<string>();
497
543
  const sessionId = (ctx: ExtensionContext): string => ctx.sessionManager.getSessionId();
@@ -503,9 +549,16 @@ export const createNotificationsExtension: ExtensionFactory = api => {
503
549
  runtimes.delete(id);
504
550
  try {
505
551
  rt.cancelPostmortemCleanup();
552
+ } catch {}
553
+ try {
506
554
  rt.disposeAnswerSource();
555
+ } catch {}
556
+ try {
507
557
  rt.disposeFileSink();
508
558
  } catch {}
559
+ try {
560
+ rt.disposeGateListener();
561
+ } catch {}
509
562
  // Resolve any still-pending interactive asks so the ask tool is not left hanging.
510
563
  for (const pending of rt.pendingInteractive.values()) pending.resolve(undefined);
511
564
  rt.pendingInteractive.clear();
@@ -529,10 +582,14 @@ export const createNotificationsExtension: ExtensionFactory = api => {
529
582
  return isSessionNotificationsEnabled({ cfg, env: process.env, sessionDisabled: disabledSessions.has(id) });
530
583
  }
531
584
 
585
+ function isNotificationEligibleContext(ctx: ExtensionContext): boolean {
586
+ return ctx.sessionMetadata?.kind !== "sub";
587
+ }
588
+
532
589
  async function startSession(ctx: ExtensionContext): Promise<"started" | "already" | "disabled" | "failed"> {
533
590
  const id = sessionId(ctx);
534
- const { settings, cfg, settingsAvailable } = resolveSettings();
535
- if (!isEnabledForSession(id, cfg)) return "disabled";
591
+ const { settings, cfg, settingsAvailable } = resolveSettings(options.settings);
592
+ if (!isNotificationEligibleContext(ctx) || !isEnabledForSession(id, cfg)) return "disabled";
536
593
  if (runtimes.has(id)) return "already";
537
594
 
538
595
  const stateRoot = path.join(ctx.cwd, ".skc", "state");
@@ -660,6 +717,10 @@ export const createNotificationsExtension: ExtensionFactory = api => {
660
717
  tag,
661
718
  );
662
719
  const disposeFileSink = registerTelegramFileSink(id, async file => {
720
+ if (runtime?.redact ?? redact) {
721
+ return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
722
+ }
723
+
663
724
  try {
664
725
  const data = await fs.promises.readFile(file.path);
665
726
  server.pushFrame(
@@ -683,9 +744,11 @@ export const createNotificationsExtension: ExtensionFactory = api => {
683
744
  pendingInteractive,
684
745
  disposeAnswerSource,
685
746
  disposeFileSink,
747
+ disposeGateListener: () => {},
686
748
  cancelPostmortemCleanup: () => {},
687
749
  redact,
688
750
  verbosity,
751
+ stream: streamingEnabled(),
689
752
  sessionTag: tag,
690
753
  busy: false,
691
754
  pendingInbound: new Set<number>(),
@@ -702,7 +765,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
702
765
  });
703
766
  logger.info(`notifications: serving session ${id} at ${endpoint.url} (unattended=${unattended})`);
704
767
 
705
- if (settingsAvailable && settings && isGloballyConfigured(cfg)) {
768
+ if (settingsAvailable && settings && isTelegramConfigured(cfg)) {
706
769
  try {
707
770
  await ensureTelegramDaemonRunning({ settings, cwd: ctx.cwd, sessionId: id });
708
771
  } catch (e) {
@@ -726,7 +789,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
726
789
 
727
790
  // Unattended: a real ask emits a workflow gate; register it repliable by gate_id.
728
791
  if (unattended && gate?.onGateEmitted) {
729
- gate.onGateEmitted(g => {
792
+ runtime.disposeGateListener = gate.onGateEmitted(g => {
730
793
  const options = (g.options ?? []).map(o => String((o as { label?: unknown }).label ?? ""));
731
794
  gateOptions.set(g.gate_id, options);
732
795
  const promptCtx = g.context as { prompt?: unknown; title?: unknown } | undefined;
@@ -761,7 +824,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
761
824
  async handler(args: string, ctx: ExtensionCommandContext): Promise<void> {
762
825
  const id = sessionId(ctx);
763
826
  const command = args.trim().split(/\s+/, 1)[0]?.toLowerCase() || "status";
764
- const resolved = resolveSettings();
827
+ const resolved = resolveSettings(options.settings);
765
828
  const enabledWithoutLocalOff = isSessionNotificationsEnabled({
766
829
  cfg: resolved.cfg,
767
830
  env: process.env,
@@ -781,6 +844,10 @@ export const createNotificationsExtension: ExtensionFactory = api => {
781
844
  }
782
845
 
783
846
  if (command === "on") {
847
+ if (!isNotificationEligibleContext(ctx)) {
848
+ ctx.ui.notify("Notifications are disabled for subagent sessions.", "warning");
849
+ return;
850
+ }
784
851
  if (process.env.SKC_NOTIFICATIONS === "0") {
785
852
  ctx.ui.notify(
786
853
  "Notifications remain disabled: SKC_NOTIFICATIONS=0 is an authoritative opt-out.",
@@ -878,6 +945,10 @@ export const createNotificationsExtension: ExtensionFactory = api => {
878
945
  rt.sessionTag,
879
946
  );
880
947
  rt.disposeFileSink = registerTelegramFileSink(newId, async file => {
948
+ if (rt.redact) {
949
+ return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
950
+ }
951
+
881
952
  try {
882
953
  const data = await fs.promises.readFile(file.path);
883
954
  rt.server.pushFrame(
@@ -933,7 +1004,10 @@ export const createNotificationsExtension: ExtensionFactory = api => {
933
1004
  api.on("turn_start", (_event, ctx) => {
934
1005
  const id = sessionId(ctx);
935
1006
  const rt = runtimes.get(id);
936
- if (!rt || rt.pendingInbound.size === 0) return;
1007
+ if (!rt) return;
1008
+ // A new turn is live: re-open the live-stream window (see turnClosed).
1009
+ rt.turnClosed = false;
1010
+ if (rt.pendingInbound.size === 0) return;
937
1011
  for (const updateId of rt.pendingInbound) {
938
1012
  try {
939
1013
  rt.server.pushFrame(JSON.stringify({ type: "inbound_ack", sessionId: id, updateId, state: "consumed" }));
@@ -1029,11 +1103,29 @@ export const createNotificationsExtension: ExtensionFactory = api => {
1029
1103
  // rate-limit pool before sending to Telegram.
1030
1104
  // Push the in-flight turn's assistant text as a finalized turn_stream, deduped
1031
1105
  // against what was already flushed for this turn (the pre-ask lead-in).
1032
- const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined): void => {
1106
+ const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined, finalAnswer: boolean): void => {
1033
1107
  if (!text || text === rt.preAskFlushedText) return;
1034
1108
  rt.preAskFlushedText = text;
1109
+ // Decision A: a stream-enabled turn must finalize as an in-place edit of ONE
1110
+ // live message, never a fresh (rich-promotable) send. If live frames were
1111
+ // async-queued and none landed before this flush, allocate the per-turn ref
1112
+ // now so the finalized frame always carries a messageRef → the daemon keeps it
1113
+ // editable (HTML edit) and never rich-promotes a streamed final.
1114
+ if (finalAnswer && rt.stream && rt.liveRef === undefined) {
1115
+ rt.turnSeq = (rt.turnSeq ?? 0) + 1;
1116
+ rt.liveRef = String(rt.turnSeq);
1117
+ }
1035
1118
  try {
1036
- rt.server.pushFrame(JSON.stringify({ type: "turn_stream", sessionId: id, phase: "finalized", text }));
1119
+ rt.server.pushFrame(
1120
+ JSON.stringify({
1121
+ type: "turn_stream",
1122
+ sessionId: id,
1123
+ phase: "finalized",
1124
+ finalAnswer,
1125
+ text,
1126
+ ...(rt.liveRef ? { messageRef: rt.liveRef } : {}),
1127
+ }),
1128
+ );
1037
1129
  } catch (e) {
1038
1130
  logger.warn(`notifications: pushFrame (turn) failed: ${String(e)}`);
1039
1131
  }
@@ -1050,19 +1142,53 @@ export const createNotificationsExtension: ExtensionFactory = api => {
1050
1142
  const id = sessionId(ctx);
1051
1143
  const rt = runtimes.get(id);
1052
1144
  if (!rt || rt.redact) return;
1053
- flushTurnText(rt, id, rt.currentTurnText);
1145
+ flushTurnText(rt, id, rt.currentTurnText, false);
1054
1146
  });
1055
1147
 
1056
1148
  api.on("turn_end", (event, ctx) => {
1057
1149
  const id = sessionId(ctx);
1058
1150
  const rt = runtimes.get(id);
1059
1151
  if (!rt) return;
1060
- const text = rt.redact ? undefined : summaryFromMessage(event.message, 3500);
1061
- if (text) flushTurnText(rt, id, text);
1152
+ const text = rt.redact ? undefined : summaryFromMessage(event.message, turnTextMax());
1153
+ if (text) flushTurnText(rt, id, text, true);
1062
1154
  // Reset per-turn streaming state so the next turn starts fresh and a later
1063
1155
  // turn with identical text is not falsely deduped.
1064
1156
  rt.currentTurnText = undefined;
1065
1157
  rt.preAskFlushedText = undefined;
1158
+ rt.liveRef = undefined;
1159
+ // Close the live-stream window: any message_update queued after turn_end is
1160
+ // dropped so it can never emit a stale live edit past the finalized turn.
1161
+ rt.turnClosed = true;
1162
+ rt.lastLiveAt = undefined;
1163
+ rt.lastLiveText = undefined;
1164
+ });
1165
+
1166
+ // Live streaming (opt-in): push throttled in-progress assistant text as
1167
+ // non-finalized turn_stream frames so remote clients edit one message as the
1168
+ // turn streams. The finalized frame (turn_end) carries the same messageRef and
1169
+ // lands the authoritative text. Suppressed under redaction.
1170
+ api.on("message_update", (event, ctx) => {
1171
+ const id = sessionId(ctx);
1172
+ const rt = runtimes.get(id);
1173
+ if (!rt?.stream || rt.redact || rt.turnClosed) return;
1174
+ if ((event.message as { role?: unknown }).role !== "assistant") return;
1175
+ if (rt.liveRef === undefined) {
1176
+ rt.turnSeq = (rt.turnSeq ?? 0) + 1;
1177
+ rt.liveRef = String(rt.turnSeq);
1178
+ }
1179
+ const now = Date.now();
1180
+ if (now - (rt.lastLiveAt ?? 0) < streamIntervalMs()) return;
1181
+ const text = summaryFromMessage(event.message, 3500);
1182
+ if (!text || text === rt.lastLiveText) return;
1183
+ rt.lastLiveAt = now;
1184
+ rt.lastLiveText = text;
1185
+ try {
1186
+ rt.server.pushFrame(
1187
+ JSON.stringify({ type: "turn_stream", sessionId: id, phase: "live", text, messageRef: rt.liveRef }),
1188
+ );
1189
+ } catch (e) {
1190
+ logger.warn(`notifications: pushFrame (live) failed: ${String(e)}`);
1191
+ }
1066
1192
  });
1067
1193
 
1068
1194
  // Stream agent-produced images (computer/browser/tool screenshots) as
@@ -1076,7 +1202,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
1076
1202
  // flush can emit it before the ask prompt. Role-scoped: message_end also
1077
1203
  // fires for the user prompt, which must never be mirrored back as turn output.
1078
1204
  if ((event.message as { role?: unknown }).role === "assistant") {
1079
- const turnText = summaryFromMessage(event.message, 3500);
1205
+ const turnText = summaryFromMessage(event.message, turnTextMax());
1080
1206
  if (turnText) rt.currentTurnText = turnText;
1081
1207
  }
1082
1208
  for (const img of imageAttachmentsFromMessage(event.message)) {
@@ -1099,4 +1225,4 @@ export const createNotificationsExtension: ExtensionFactory = api => {
1099
1225
  api.on("session_shutdown", async (_event, ctx) => {
1100
1226
  await stopSession(sessionId(ctx));
1101
1227
  });
1102
- };
1228
+ }