@sayknow-cli/coding-agent 0.3.8 → 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 (57) hide show
  1. package/dist/types/config/settings-schema.d.ts +14 -0
  2. package/dist/types/modes/components/thinking-selector.d.ts +2 -2
  3. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  4. package/dist/types/modes/interactive-mode.d.ts +2 -0
  5. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  6. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  7. package/dist/types/modes/types.d.ts +2 -0
  8. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  9. package/dist/types/notifications/config-commands.d.ts +9 -0
  10. package/dist/types/notifications/config.d.ts +16 -0
  11. package/dist/types/notifications/index.d.ts +2 -0
  12. package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
  13. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  14. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  15. package/dist/types/notifications/rich-draft.d.ts +68 -0
  16. package/dist/types/notifications/rich-render.d.ts +90 -0
  17. package/dist/types/notifications/telegram-daemon.d.ts +22 -0
  18. package/dist/types/notifications/telegram-reference.d.ts +7 -0
  19. package/dist/types/notifications/threaded-render.d.ts +8 -0
  20. package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
  21. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  22. package/package.json +7 -7
  23. package/src/cli/notify-cli.ts +11 -9
  24. package/src/cli/update-cli.ts +63 -24
  25. package/src/config/keybindings.ts +1 -1
  26. package/src/config/settings-schema.ts +8 -0
  27. package/src/internal-urls/docs-index.generated.ts +5 -5
  28. package/src/lsp/client.ts +1 -0
  29. package/src/modes/components/footer.ts +7 -2
  30. package/src/modes/components/thinking-selector.ts +10 -8
  31. package/src/modes/controllers/event-controller.ts +11 -2
  32. package/src/modes/controllers/extension-ui-controller.ts +8 -1
  33. package/src/modes/controllers/input-controller.ts +12 -17
  34. package/src/modes/controllers/selector-controller.ts +41 -0
  35. package/src/modes/interactive-mode.ts +5 -0
  36. package/src/modes/rpc/rpc-mode.ts +3 -9
  37. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  38. package/src/modes/types.ts +2 -0
  39. package/src/modes/utils/injected-user-submission.ts +94 -0
  40. package/src/notifications/config-commands.ts +20 -0
  41. package/src/notifications/config.ts +27 -0
  42. package/src/notifications/index.ts +28 -5
  43. package/src/notifications/lifecycle-commands.ts +37 -16
  44. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  45. package/src/notifications/reply-sent-store.ts +134 -0
  46. package/src/notifications/rich-draft.ts +107 -0
  47. package/src/notifications/rich-render.ts +142 -0
  48. package/src/notifications/telegram-daemon.ts +346 -89
  49. package/src/notifications/telegram-reference.ts +17 -0
  50. package/src/notifications/threaded-render.ts +28 -1
  51. package/src/prompts/tools/search-tool-bm25.md +5 -0
  52. package/src/sdk.ts +36 -8
  53. package/src/session/agent-session.ts +6 -4
  54. package/src/skc-runtime/tmux-common.ts +1 -0
  55. package/src/skc-runtime/ultragoal-runtime.ts +10 -1
  56. package/src/slash-commands/builtin-registry.ts +78 -1
  57. package/src/utils/pasted-image-path.ts +170 -0
package/src/lsp/client.ts CHANGED
@@ -835,6 +835,7 @@ async function shutdownClientInstance(client: LspClient): Promise<void> {
835
835
  await Promise.race([shutdown, Bun.sleep(5_000)]);
836
836
  await client.owner?.dispose();
837
837
  await client.owner?.awaitExit({ timeoutMs: 1_000 });
838
+ await Promise.race([client.proc.exited.catch(() => undefined), Bun.sleep(1_000)]);
838
839
  }
839
840
 
840
841
  export async function shutdownClient(key: string): Promise<void> {
@@ -16,6 +16,7 @@ import { resolveCurrentBranch } from "./status-line/git-utils";
16
16
  export class FooterComponent implements Component {
17
17
  #cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
18
18
  #gitWatcher: fs.FSWatcher | null = null;
19
+ #gitWatcherGeneration = 0;
19
20
  #onBranchChange: (() => void) | null = null;
20
21
  #autoCompactEnabled: boolean = true;
21
22
  #extensionStatuses: Map<string, string> = new Map();
@@ -51,7 +52,10 @@ export class FooterComponent implements Component {
51
52
  }
52
53
 
53
54
  #setupGitWatcher(): void {
54
- // Clean up existing watcher
55
+ // Clean up existing watcher. The generation guard below keeps a stale
56
+ // in-flight resolve — one that dispose() or a newer setup already
57
+ // superseded — from installing a watcher nothing would ever close.
58
+ const generation = ++this.#gitWatcherGeneration;
55
59
  if (this.#gitWatcher) {
56
60
  this.#gitWatcher.close();
57
61
  this.#gitWatcher = null;
@@ -60,7 +64,7 @@ export class FooterComponent implements Component {
60
64
  void git.head
61
65
  .resolve(getProjectDir())
62
66
  .then(head => {
63
- if (!head) {
67
+ if (!head || generation !== this.#gitWatcherGeneration) {
64
68
  return;
65
69
  }
66
70
 
@@ -84,6 +88,7 @@ export class FooterComponent implements Component {
84
88
  * Clean up the file watcher
85
89
  */
86
90
  dispose(): void {
91
+ this.#gitWatcherGeneration++;
87
92
  if (this.#gitWatcher) {
88
93
  this.#gitWatcher.close();
89
94
  this.#gitWatcher = null;
@@ -1,7 +1,7 @@
1
- import type { Effort } from "@sayknow-cli/ai";
1
+ import { ThinkingLevel, type ThinkingLevel as ThinkingLevelValue } from "@sayknow-cli/agent-core";
2
2
  import { Container, type SelectItem, SelectList } from "@sayknow-cli/tui";
3
3
  import { getSelectListTheme } from "../../modes/theme/theme";
4
- import { getThinkingLevelMetadata } from "../../thinking";
4
+ import { getThinkingLevelMetadata, type ThinkingLevelValue as ThinkingMetadataValue } from "../../thinking-metadata";
5
5
  import { DynamicBorder } from "./dynamic-border";
6
6
 
7
7
  /**
@@ -11,14 +11,16 @@ export class ThinkingSelectorComponent extends Container {
11
11
  #selectList: SelectList;
12
12
 
13
13
  constructor(
14
- currentLevel: Effort,
15
- availableLevels: Effort[],
16
- onSelect: (level: Effort) => void,
14
+ currentLevel: ThinkingLevelValue | undefined,
15
+ availableLevels: ThinkingLevelValue[],
16
+ onSelect: (level: ThinkingLevelValue) => void,
17
17
  onCancel: () => void,
18
18
  ) {
19
19
  super();
20
20
 
21
- const thinkingLevels: SelectItem[] = availableLevels.map(getThinkingLevelMetadata);
21
+ const thinkingLevels: SelectItem[] = availableLevels.map(level =>
22
+ getThinkingLevelMetadata(level as ThinkingMetadataValue),
23
+ );
22
24
 
23
25
  // Add top border
24
26
  this.addChild(new DynamicBorder());
@@ -27,13 +29,13 @@ export class ThinkingSelectorComponent extends Container {
27
29
  this.#selectList = new SelectList(thinkingLevels, thinkingLevels.length, getSelectListTheme());
28
30
 
29
31
  // Preselect current level
30
- const currentIndex = thinkingLevels.findIndex(item => item.value === currentLevel);
32
+ const currentIndex = thinkingLevels.findIndex(item => item.value === (currentLevel ?? ThinkingLevel.Off));
31
33
  if (currentIndex !== -1) {
32
34
  this.#selectList.setSelectedIndex(currentIndex);
33
35
  }
34
36
 
35
37
  this.#selectList.onSelect = item => {
36
- onSelect(item.value as Effort);
38
+ onSelect(item.value as ThinkingLevelValue);
37
39
  };
38
40
 
39
41
  this.#selectList.onCancel = () => {
@@ -16,6 +16,7 @@ import { ToolExecutionComponent } from "../../modes/components/tool-execution";
16
16
  import { TtsrNotificationComponent } from "../../modes/components/ttsr-notification";
17
17
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
18
18
  import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
19
+ import { completionNotifyDisabledByEnv } from "../../notifications/config";
19
20
  import { summaryFromMessage } from "../../notifications/helpers";
20
21
  import type { PlanApprovalDetails } from "../../plan-mode/approved-plan";
21
22
  import type { AgentSessionEvent } from "../../session/agent-session";
@@ -23,6 +24,7 @@ import { isSilentAbort, readPendingDisplayTag } from "../../session/messages";
23
24
  import type { ResolveToolDetails } from "../../tools/resolve";
24
25
  import { interruptHint } from "../shared";
25
26
  import { buildAbortDisplayMessage } from "../utils/abort-message";
27
+ import { consumeInjectedOptimisticSignature } from "../utils/injected-user-submission";
26
28
  import { ringTerminalBell } from "../utils/terminal-bell";
27
29
  import { argsWithPartialJson } from "../utils/ui-helpers";
28
30
 
@@ -291,12 +293,17 @@ export class EventController {
291
293
  const signature = `${textContent}\u0000${imageCount}`;
292
294
 
293
295
  this.#resetReadGroup();
294
- const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
296
+ const wasSingleOptimistic = this.ctx.optimisticUserMessageSignature === signature;
297
+ const wasInjectedOptimistic = !wasSingleOptimistic && consumeInjectedOptimisticSignature(this.ctx, signature);
298
+ const wasOptimistic = wasSingleOptimistic || wasInjectedOptimistic;
295
299
  const wasLocallySubmitted = this.ctx.locallySubmittedUserSignatures.delete(signature) || wasOptimistic;
296
300
  if (!wasOptimistic) {
297
301
  this.ctx.addMessageToChat(event.message);
298
302
  }
299
- if (wasOptimistic) {
303
+ // Only clear the single local slot when IT matched; an injected match is
304
+ // consumed from the counting Map above and must not clobber a coexisting
305
+ // pending local optimistic signature.
306
+ if (wasSingleOptimistic) {
300
307
  this.ctx.optimisticUserMessageSignature = undefined;
301
308
  }
302
309
 
@@ -960,6 +967,8 @@ export class EventController {
960
967
  }
961
968
 
962
969
  sendCompletionNotification(): void {
970
+ // Per-run hard opt-out (env, config-untouched, child-inheritable): SKC_NOTIFY=off.
971
+ if (completionNotifyDisabledByEnv(process.env)) return;
963
972
  const isBackgrounded = this.ctx.isBackgrounded !== false;
964
973
  const notify = settings.get("completion.notify");
965
974
  if (notify === "off") return;
@@ -24,6 +24,7 @@ import { HookSelectorComponent } from "../../modes/components/hook-selector";
24
24
  import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, theme } from "../../modes/theme/theme";
25
25
  import type { InteractiveModeContext } from "../../modes/types";
26
26
  import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
27
+ import { applyInjectedUserSubmission } from "../utils/injected-user-submission";
27
28
  import { classifyHookSelectorBellEvent, ringTerminalBell } from "../utils/terminal-bell";
28
29
 
29
30
  const MAX_WIDGET_LINES = 10;
@@ -954,7 +955,13 @@ export class ExtensionUiController {
954
955
  }
955
956
 
956
957
  #sendExtensionUserMessage: SendUserMessageHandler = (content, options) => {
957
- this.ctx.session.sendUserMessage(content, options).catch((err: unknown) => {
958
+ // Compute queued BEFORE send: prompt() may flip session.isStreaming synchronously.
959
+ const queued = Boolean(options?.deliverAs) || this.ctx.session.isStreaming;
960
+ // Call send first so the busy/queued path finds the session queue populated
961
+ // (queueSteer/queueFollowUp push synchronously) before refreshing pending display.
962
+ const send = this.ctx.session.sendUserMessage(content, options);
963
+ applyInjectedUserSubmission(this.ctx, { content, queued });
964
+ send.catch((err: unknown) => {
958
965
  this.ctx.showError(`Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`);
959
966
  });
960
967
  };
@@ -18,6 +18,7 @@ import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
18
18
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
19
19
  import { ensureSupportedImageInput, ImageInputTooLargeError, loadImageInput } from "../../utils/image-loading";
20
20
  import { resizeImage } from "../../utils/image-resize";
21
+ import { resolvePastedImagePath } from "../../utils/pasted-image-path";
21
22
  import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";
22
23
  import { type QueuedMessageMoveDirection, QueuedMessageSelectorComponent } from "../components/queued-message-selector";
23
24
 
@@ -26,8 +27,6 @@ interface Expandable {
26
27
  }
27
28
 
28
29
  const INTERACTIVE_ABORT_CLEANUP_TIMEOUT_MS = 5_000;
29
- const CLIPBOARD_TEMP_IMAGE_FILE_PATTERN = /^clipboard-\d{4}-\d{2}-\d{2}-\d{6}-[A-Za-z0-9]+\.(?:png|jpe?g|gif|webp)$/i;
30
- const MACOS_CLIPBOARD_TEMP_DIR_PATTERN = /^\/var\/folders\/[^/]+\/[^/]+\/T$/;
31
30
 
32
31
  function isExpandable(obj: unknown): obj is Expandable {
33
32
  return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
@@ -1061,10 +1060,15 @@ export class InputController {
1061
1060
  }
1062
1061
 
1063
1062
  handleTextPaste(text: string): boolean | Promise<boolean> {
1064
- const imagePath = this.#getPastedImagePathCandidate(text);
1063
+ const imagePath = resolvePastedImagePath(text, { cwd: this.ctx.sessionManager.getCwd() });
1065
1064
  return imagePath ? this.#attachPastedImagePath(imagePath) : false;
1066
1065
  }
1067
1066
 
1067
+ /**
1068
+ * Returns `false` on every failure path so the editor replays the original
1069
+ * bracketed paste — the raw path text must never be lost when attachment
1070
+ * is impossible (unsupported content, oversized image, load error).
1071
+ */
1068
1072
  async #attachPastedImagePath(imagePath: string): Promise<boolean> {
1069
1073
  try {
1070
1074
  const image = await loadImageInput({
@@ -1073,8 +1077,8 @@ export class InputController {
1073
1077
  autoResize: this.ctx.settings.get("images.autoResize"),
1074
1078
  });
1075
1079
  if (!image) {
1076
- this.ctx.showStatus("Unsupported pasted clipboard image file");
1077
- return true;
1080
+ this.ctx.showStatus("Unsupported pasted image file");
1081
+ return false;
1078
1082
  }
1079
1083
 
1080
1084
  this.ctx.pendingImages.push({
@@ -1089,22 +1093,13 @@ export class InputController {
1089
1093
  } catch (error) {
1090
1094
  if (error instanceof ImageInputTooLargeError) {
1091
1095
  this.ctx.showStatus(error.message);
1092
- return true;
1096
+ return false;
1093
1097
  }
1094
- this.ctx.showStatus("Failed to attach pasted clipboard image");
1095
- return true;
1098
+ this.ctx.showStatus("Failed to attach pasted image");
1099
+ return false;
1096
1100
  }
1097
1101
  }
1098
1102
 
1099
- #getPastedImagePathCandidate(text: string): string | undefined {
1100
- const resolvedPath = path.resolve(text.trim());
1101
- const parentDir = path.dirname(resolvedPath);
1102
- const isClipboardTempPath =
1103
- (parentDir === "/tmp" || MACOS_CLIPBOARD_TEMP_DIR_PATTERN.test(parentDir)) &&
1104
- CLIPBOARD_TEMP_IMAGE_FILE_PATTERN.test(path.basename(resolvedPath));
1105
- return isClipboardTempPath ? resolvedPath : undefined;
1106
- }
1107
-
1108
1103
  #nextImagePlaceholder(): string {
1109
1104
  return `[image ${this.ctx.pendingImages.length}]`;
1110
1105
  }
@@ -81,6 +81,7 @@ import { SessionSelectorComponent } from "../components/session-selector";
81
81
  import { SettingsSelectorComponent } from "../components/settings-selector";
82
82
  import type { StatusLineSettings } from "../components/status-line";
83
83
  import { ThemeSelectorComponent } from "../components/theme-selector";
84
+ import { ThinkingSelectorComponent } from "../components/thinking-selector";
84
85
  import { ToolExecutionComponent } from "../components/tool-execution";
85
86
  import { TreeSelectorComponent } from "../components/tree-selector";
86
87
  import { UserMessageSelectorComponent } from "../components/user-message-selector";
@@ -328,6 +329,46 @@ export class SelectorController {
328
329
  });
329
330
  }
330
331
 
332
+ showEffortSelector(): void {
333
+ const availableLevels = [
334
+ ThinkingLevel.Inherit,
335
+ ThinkingLevel.Off,
336
+ ...this.ctx.session.getAvailableThinkingLevels(),
337
+ ];
338
+
339
+ this.showSelector(done => {
340
+ const selector = new ThinkingSelectorComponent(
341
+ this.ctx.session.thinkingLevel,
342
+ availableLevels,
343
+ level => {
344
+ done();
345
+
346
+ const configuredDefault = this.ctx.settings.get("defaultThinkingLevel");
347
+ const levelToApply = level === ThinkingLevel.Inherit ? configuredDefault : level;
348
+ this.ctx.session.setThinkingLevel(levelToApply, false);
349
+ const effectiveLevel = this.ctx.session.thinkingLevel ?? ThinkingLevel.Off;
350
+ const requestedLabel =
351
+ level === ThinkingLevel.Inherit ? `${level} (configured default: ${configuredDefault})` : level;
352
+ const clampedSuffix =
353
+ effectiveLevel === levelToApply ? "" : ` Requested ${levelToApply}; effective ${effectiveLevel}.`;
354
+
355
+ this.ctx.statusLine.invalidate();
356
+ this.ctx.updateEditorBorderColor();
357
+ this.ctx.updateEditorTopBorder();
358
+ this.ctx.ui.requestRender();
359
+ this.ctx.showStatus(
360
+ `Reasoning effort set to ${requestedLabel}. Effective effort: ${effectiveLevel}.${clampedSuffix}`,
361
+ );
362
+ },
363
+ () => {
364
+ done();
365
+ this.ctx.ui.requestRender();
366
+ },
367
+ );
368
+ return { component: selector, focus: selector.getSelectList() };
369
+ });
370
+ }
371
+
331
372
  showSettingsSelector(): void {
332
373
  getAvailableThemes().then(availableThemes => {
333
374
  this.showSelector(done => {
@@ -331,6 +331,7 @@ export class InteractiveMode implements InteractiveModeContext {
331
331
  onInputCallback?: (input: SubmittedUserInput) => void;
332
332
  optimisticUserMessageSignature: string | undefined = undefined;
333
333
  locallySubmittedUserSignatures: Set<string> = new Set();
334
+ optimisticInjectedSignatures: Map<string, number> = new Map();
334
335
  #pendingSubmittedInput: SubmittedUserInput | undefined;
335
336
  #pendingSubmissionDispose: (() => void) | undefined;
336
337
  lastSigintTime = 0;
@@ -2561,6 +2562,10 @@ export class InteractiveMode implements InteractiveModeContext {
2561
2562
  this.#selectorController.showModelSelector(options);
2562
2563
  }
2563
2564
 
2565
+ showEffortSelector(): void {
2566
+ this.#selectorController.showEffortSelector();
2567
+ }
2568
+
2564
2569
  showProviderOnboarding(): void {
2565
2570
  this.#selectorController.showProviderOnboarding();
2566
2571
  }
@@ -173,15 +173,9 @@ function auditOutcomeFor(event: string): "accepted" | "rejected" | "denied" | "e
173
173
  return "info";
174
174
  }
175
175
 
176
- export class RpcListenRefusedError extends Error {
177
- constructor(socketPath: string) {
178
- super(
179
- `RPC --listen refused: a live server is already listening on ${socketPath}. ` +
180
- "Stop it first or choose a different --listen path.",
181
- );
182
- this.name = "RpcListenRefusedError";
183
- }
184
- }
176
+ // Thrown by prepareRpcSocketPath when the --listen path has a live owner;
177
+ // re-exported here because main.ts catches it at the RPC launch boundary.
178
+ export { RpcListenRefusedError } from "./rpc-socket-security";
185
179
 
186
180
  /**
187
181
  * Probe whether a unix-domain socket path has a live server accepting
@@ -9,6 +9,18 @@ export class RpcSocketSecurityError extends Error {
9
9
  }
10
10
  }
11
11
 
12
+ // The launch boundary in main.ts catches exactly this class to turn a
13
+ // duplicate --listen into a clean stderr diagnostic + non-zero exit (issue 19).
14
+ export class RpcListenRefusedError extends Error {
15
+ constructor(socketPath: string) {
16
+ super(
17
+ `RPC --listen refused: a live server is already listening on ${socketPath}. ` +
18
+ "Stop it first or choose a different --listen path.",
19
+ );
20
+ this.name = "RpcListenRefusedError";
21
+ }
22
+ }
23
+
12
24
  const unsafeBits = 0o077;
13
25
 
14
26
  function currentUid(): number | undefined {
@@ -53,7 +65,7 @@ export async function prepareRpcSocketPath(socketPath: string): Promise<void> {
53
65
  if (!existing.isSocket()) throw new RpcSocketSecurityError(`RPC socket path is not a socket: ${socketPath}`);
54
66
  assertPrivateMode(existing.mode, socketPath);
55
67
  if (await probeUnixSocketAlive(socketPath)) {
56
- throw new RpcSocketSecurityError(`RPC socket path is live: ${socketPath}`);
68
+ throw new RpcListenRefusedError(socketPath);
57
69
  }
58
70
  await fs.unlink(socketPath);
59
71
  }
@@ -116,6 +116,7 @@ export interface InteractiveModeContext {
116
116
  onInputCallback?: (input: SubmittedUserInput) => void;
117
117
  optimisticUserMessageSignature: string | undefined;
118
118
  locallySubmittedUserSignatures: Set<string>;
119
+ optimisticInjectedSignatures: Map<string, number>;
119
120
  lastSigintTime: number;
120
121
  lastEscapeTime: number;
121
122
  lastComposerClearEscapeTime: number;
@@ -244,6 +245,7 @@ export interface InteractiveModeContext {
244
245
  showExtensionsDashboard(): void;
245
246
  showAgentsDashboard(): void;
246
247
  showModelSelector(options?: { temporaryOnly?: boolean }): void;
248
+ showEffortSelector(): void;
247
249
  showProviderOnboarding(): void;
248
250
  showPluginSelector(mode?: "install" | "uninstall"): void;
249
251
  showUserMessageSelector(): void;
@@ -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
+ }
@@ -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,6 +41,12 @@ 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
 
@@ -59,6 +71,20 @@ export function isGloballyConfigured(cfg: NotificationConfig): boolean {
59
71
  );
60
72
  }
61
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
+
62
88
  /** Resolve whether the notifications extension should be registered at SDK startup. */
63
89
  export function shouldRegisterNotificationsExtension(input: {
64
90
  env: NodeJS.ProcessEnv;
@@ -71,6 +97,7 @@ export function shouldRegisterNotificationsExtension(input: {
71
97
  currentAgentType?: string;
72
98
  }): boolean {
73
99
  if ((input.taskDepth ?? 0) > 0 || input.parentTaskPrefix || input.currentAgentType) return false;
100
+ if (completionNotifyDisabledByEnv(input.env)) return false;
74
101
  if (input.env.SKC_NOTIFICATIONS === "0") return false;
75
102
  if (input.env.SKC_NOTIFICATIONS === "1" || input.env.SKC_NOTIFICATIONS_TOKEN) return true;
76
103
  return input.cfg ? isGloballyConfigured(input.cfg) : false;
@@ -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. */
@@ -356,6 +358,9 @@ interface SessionRuntime {
356
358
  liveRef?: string;
357
359
  lastLiveAt?: number;
358
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;
359
364
  /** Cancels the postmortem cleanup that emits `session_closed` on process teardown. */
360
365
  cancelPostmortemCleanup: () => void;
361
366
  }
@@ -383,6 +388,8 @@ const defaultConfig: NotificationConfig = {
383
388
  redact: false,
384
389
  verbosity: "lean",
385
390
  idleTimeoutMs: 60_000,
391
+ rich: { enabled: true },
392
+ richDraft: { enabled: false },
386
393
  };
387
394
 
388
395
  export function notificationsEnabled(): boolean {
@@ -997,7 +1004,10 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
997
1004
  api.on("turn_start", (_event, ctx) => {
998
1005
  const id = sessionId(ctx);
999
1006
  const rt = runtimes.get(id);
1000
- 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;
1001
1011
  for (const updateId of rt.pendingInbound) {
1002
1012
  try {
1003
1013
  rt.server.pushFrame(JSON.stringify({ type: "inbound_ack", sessionId: id, updateId, state: "consumed" }));
@@ -1093,15 +1103,25 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
1093
1103
  // rate-limit pool before sending to Telegram.
1094
1104
  // Push the in-flight turn's assistant text as a finalized turn_stream, deduped
1095
1105
  // against what was already flushed for this turn (the pre-ask lead-in).
1096
- const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined): void => {
1106
+ const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined, finalAnswer: boolean): void => {
1097
1107
  if (!text || text === rt.preAskFlushedText) return;
1098
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
+ }
1099
1118
  try {
1100
1119
  rt.server.pushFrame(
1101
1120
  JSON.stringify({
1102
1121
  type: "turn_stream",
1103
1122
  sessionId: id,
1104
1123
  phase: "finalized",
1124
+ finalAnswer,
1105
1125
  text,
1106
1126
  ...(rt.liveRef ? { messageRef: rt.liveRef } : {}),
1107
1127
  }),
@@ -1122,7 +1142,7 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
1122
1142
  const id = sessionId(ctx);
1123
1143
  const rt = runtimes.get(id);
1124
1144
  if (!rt || rt.redact) return;
1125
- flushTurnText(rt, id, rt.currentTurnText);
1145
+ flushTurnText(rt, id, rt.currentTurnText, false);
1126
1146
  });
1127
1147
 
1128
1148
  api.on("turn_end", (event, ctx) => {
@@ -1130,12 +1150,15 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
1130
1150
  const rt = runtimes.get(id);
1131
1151
  if (!rt) return;
1132
1152
  const text = rt.redact ? undefined : summaryFromMessage(event.message, turnTextMax());
1133
- if (text) flushTurnText(rt, id, text);
1153
+ if (text) flushTurnText(rt, id, text, true);
1134
1154
  // Reset per-turn streaming state so the next turn starts fresh and a later
1135
1155
  // turn with identical text is not falsely deduped.
1136
1156
  rt.currentTurnText = undefined;
1137
1157
  rt.preAskFlushedText = undefined;
1138
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;
1139
1162
  rt.lastLiveAt = undefined;
1140
1163
  rt.lastLiveText = undefined;
1141
1164
  });
@@ -1147,7 +1170,7 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
1147
1170
  api.on("message_update", (event, ctx) => {
1148
1171
  const id = sessionId(ctx);
1149
1172
  const rt = runtimes.get(id);
1150
- if (!rt?.stream || rt.redact) return;
1173
+ if (!rt?.stream || rt.redact || rt.turnClosed) return;
1151
1174
  if ((event.message as { role?: unknown }).role !== "assistant") return;
1152
1175
  if (rt.liveRef === undefined) {
1153
1176
  rt.turnSeq = (rt.turnSeq ?? 0) + 1;