@sayknow-cli/coding-agent 0.3.5 → 0.3.7

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 (238) hide show
  1. package/README.md +76 -0
  2. package/dist/types/capability/mcp.d.ts +2 -0
  3. package/dist/types/cli/local-provider-smoke.d.ts +59 -0
  4. package/dist/types/cli/notify-cli.d.ts +1 -1
  5. package/dist/types/cli/telegram-cli.d.ts +13 -0
  6. package/dist/types/cli/update-cli.d.ts +24 -1
  7. package/dist/types/commands/local-provider.d.ts +34 -0
  8. package/dist/types/commands/telegram.d.ts +22 -0
  9. package/dist/types/config/keybindings.d.ts +12 -6
  10. package/dist/types/config/model-registry.d.ts +5 -0
  11. package/dist/types/config/models-config-schema.d.ts +5 -0
  12. package/dist/types/config/settings-schema.d.ts +284 -4
  13. package/dist/types/config/settings.d.ts +7 -0
  14. package/dist/types/config/telegram-autostart.d.ts +14 -0
  15. package/dist/types/config/telegram-env-bridge.d.ts +18 -0
  16. package/dist/types/coordinator-mcp/server.d.ts +8 -0
  17. package/dist/types/defaults/skc-defaults.d.ts +7 -0
  18. package/dist/types/discovery/cursor.d.ts +4 -1
  19. package/dist/types/discovery/index.d.ts +0 -1
  20. package/dist/types/discovery/windsurf.d.ts +3 -1
  21. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  22. package/dist/types/i18n/messages/en.d.ts +1 -0
  23. package/dist/types/modes/components/custom-editor.d.ts +6 -1
  24. package/dist/types/modes/components/custom-model-preset-wizard.d.ts +1 -1
  25. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  26. package/dist/types/modes/components/model-selector.d.ts +2 -0
  27. package/dist/types/modes/components/settings-selector.d.ts +5 -1
  28. package/dist/types/modes/components/skill-message.d.ts +1 -0
  29. package/dist/types/modes/components/status-line/types.d.ts +5 -7
  30. package/dist/types/modes/components/status-line.d.ts +15 -3
  31. package/dist/types/modes/components/two-column-body.d.ts +9 -0
  32. package/dist/types/modes/components/welcome.d.ts +9 -1
  33. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  34. package/dist/types/modes/controllers/input-controller.d.ts +6 -1
  35. package/dist/types/modes/controllers/selector-controller.d.ts +10 -1
  36. package/dist/types/modes/interactive-mode.d.ts +2 -0
  37. package/dist/types/modes/prompt-action-autocomplete.d.ts +3 -0
  38. package/dist/types/modes/rpc/rpc-types.d.ts +1 -0
  39. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +41 -7
  40. package/dist/types/modes/slash-command-visibility.d.ts +1 -0
  41. package/dist/types/modes/theme/theme.d.ts +1 -1
  42. package/dist/types/modes/tmux-scroll.d.ts +18 -0
  43. package/dist/types/modes/types.d.ts +5 -1
  44. package/dist/types/modes/utils/terminal-bell.d.ts +3 -0
  45. package/dist/types/notifications/daemon-paths.d.ts +9 -0
  46. package/dist/types/notifications/html-format.d.ts +2 -0
  47. package/dist/types/notifications/recent-activity.d.ts +5 -1
  48. package/dist/types/notifications/telegram-daemon-cli.d.ts +21 -4
  49. package/dist/types/notifications/telegram-daemon-control.d.ts +8 -0
  50. package/dist/types/notifications/telegram-daemon.d.ts +4 -10
  51. package/dist/types/notifications/telegram-reference.d.ts +4 -0
  52. package/dist/types/notifications/threaded-render.d.ts +1 -0
  53. package/dist/types/runtime-mcp/config-writer.d.ts +9 -0
  54. package/dist/types/runtime-mcp/config.d.ts +2 -0
  55. package/dist/types/runtime-mcp/loader.d.ts +2 -0
  56. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  57. package/dist/types/runtime-mcp/types.d.ts +6 -0
  58. package/dist/types/sdk.d.ts +1 -1
  59. package/dist/types/skc-runtime/launch-tmux.d.ts +2 -2
  60. package/dist/types/skc-runtime/ralplan-runtime.d.ts +3 -2
  61. package/dist/types/skc-runtime/restricted-role-agent-bash.d.ts +1 -0
  62. package/dist/types/skc-runtime/session-state-sidecar.d.ts +5 -0
  63. package/dist/types/skc-runtime/state-writer.d.ts +1 -0
  64. package/dist/types/skc-runtime/team-runtime.d.ts +1 -1
  65. package/dist/types/skc-runtime/tmux-common.d.ts +12 -0
  66. package/dist/types/skc-runtime/ultragoal-guard.d.ts +7 -1
  67. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +0 -4
  68. package/dist/types/slash-commands/types.d.ts +2 -0
  69. package/dist/types/system-prompt.d.ts +7 -0
  70. package/dist/types/tools/skill.d.ts +1 -1
  71. package/dist/types/utils/cmux-workspace.d.ts +52 -0
  72. package/dist/types/web/search/index.d.ts +4 -2
  73. package/dist/types/web/search/provider.d.ts +14 -0
  74. package/dist/types/web/search/providers/utils.d.ts +42 -13
  75. package/dist/types/web/search/render.d.ts +1 -0
  76. package/dist/types/workflow/workflow-intent-diff.d.ts +19 -0
  77. package/package.json +8 -8
  78. package/scripts/build-binary.ts +3 -0
  79. package/scripts/verify-insane-vendor.ts +0 -5
  80. package/src/capability/mcp.ts +2 -0
  81. package/src/cli/local-provider-smoke.ts +632 -0
  82. package/src/cli/notify-cli.ts +4 -2
  83. package/src/cli/telegram-cli.ts +156 -0
  84. package/src/cli/update-cli.ts +179 -13
  85. package/src/cli/web-search-cli.ts +5 -7
  86. package/src/cli.ts +20 -1
  87. package/src/commands/launch.ts +47 -2
  88. package/src/commands/local-provider.ts +62 -0
  89. package/src/commands/notify.ts +1 -1
  90. package/src/commands/ralplan.ts +1 -0
  91. package/src/commands/telegram.ts +47 -0
  92. package/src/config/keybindings.ts +15 -6
  93. package/src/config/mcp-schema.json +4 -0
  94. package/src/config/model-registry.ts +55 -1
  95. package/src/config/models-config-schema.ts +9 -0
  96. package/src/config/settings-schema.ts +256 -4
  97. package/src/config/settings.ts +11 -0
  98. package/src/config/telegram-autostart.ts +107 -0
  99. package/src/config/telegram-env-bridge.ts +92 -0
  100. package/src/coordinator-mcp/server.ts +276 -34
  101. package/src/defaults/skc/skills/ralplan/SKILL.md +29 -20
  102. package/src/defaults/skc/skills/ultragoal/SKILL.md +37 -112
  103. package/src/defaults/skc-defaults.ts +16 -0
  104. package/src/discovery/builtin.ts +12 -0
  105. package/src/discovery/cursor.ts +5 -84
  106. package/src/discovery/gemini.ts +3 -89
  107. package/src/discovery/index.ts +4 -1
  108. package/src/discovery/mcp-json.ts +11 -0
  109. package/src/discovery/opencode.ts +5 -122
  110. package/src/discovery/windsurf.ts +4 -82
  111. package/src/edit/read-file.ts +9 -1
  112. package/src/extensibility/extensions/runner.ts +2 -1
  113. package/src/extensibility/extensions/types.ts +11 -0
  114. package/src/extensibility/skills.ts +30 -2
  115. package/src/hooks/skill-state.ts +33 -73
  116. package/src/i18n/messages/de.ts +1 -0
  117. package/src/i18n/messages/en.ts +1 -0
  118. package/src/i18n/messages/es.ts +1 -0
  119. package/src/i18n/messages/fr.ts +1 -0
  120. package/src/i18n/messages/ja.settings.ts +28 -0
  121. package/src/i18n/messages/ja.ts +1 -0
  122. package/src/i18n/messages/ko.settings.ts +28 -0
  123. package/src/i18n/messages/ko.ts +1 -0
  124. package/src/i18n/messages/zh.settings.ts +28 -0
  125. package/src/i18n/messages/zh.ts +1 -0
  126. package/src/internal-urls/docs-index.generated.ts +12 -7
  127. package/src/main.ts +5 -2
  128. package/src/migrate/executor.ts +23 -6
  129. package/src/modes/acp/acp-agent.ts +7 -6
  130. package/src/modes/components/agent-dashboard.ts +3 -35
  131. package/src/modes/components/custom-editor.ts +26 -0
  132. package/src/modes/components/custom-model-preset-wizard.ts +30 -189
  133. package/src/modes/components/extensions/extension-dashboard.ts +2 -48
  134. package/src/modes/components/hook-selector.ts +21 -3
  135. package/src/modes/components/model-selector.ts +246 -11
  136. package/src/modes/components/session-observer-overlay.ts +3 -3
  137. package/src/modes/components/session-selector.ts +19 -9
  138. package/src/modes/components/settings-selector.ts +551 -11
  139. package/src/modes/components/skill-message.ts +18 -10
  140. package/src/modes/components/status-line/presets.ts +11 -0
  141. package/src/modes/components/status-line/segments.ts +16 -19
  142. package/src/modes/components/status-line/types.ts +6 -2
  143. package/src/modes/components/status-line.ts +281 -117
  144. package/src/modes/components/tree-selector.ts +28 -8
  145. package/src/modes/components/two-column-body.ts +36 -0
  146. package/src/modes/components/welcome.ts +165 -30
  147. package/src/modes/controllers/command-controller.ts +26 -2
  148. package/src/modes/controllers/event-controller.ts +102 -4
  149. package/src/modes/controllers/extension-ui-controller.ts +21 -2
  150. package/src/modes/controllers/input-controller.ts +214 -47
  151. package/src/modes/controllers/selector-controller.ts +35 -33
  152. package/src/modes/interactive-mode.ts +36 -30
  153. package/src/modes/prompt-action-autocomplete.ts +48 -8
  154. package/src/modes/rpc/rpc-types.ts +1 -0
  155. package/src/modes/shared/agent-wire/deep-interview-gate.ts +72 -15
  156. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +20 -0
  157. package/src/modes/slash-command-visibility.ts +9 -0
  158. package/src/modes/theme/theme.ts +5 -1
  159. package/src/modes/tmux-scroll.ts +92 -0
  160. package/src/modes/types.ts +8 -1
  161. package/src/modes/utils/hotkeys-markdown.ts +5 -2
  162. package/src/modes/utils/terminal-bell.ts +48 -0
  163. package/src/modes/utils/ui-helpers.ts +76 -3
  164. package/src/notifications/daemon-paths.ts +22 -0
  165. package/src/notifications/html-format.ts +75 -1
  166. package/src/notifications/index.ts +32 -2
  167. package/src/notifications/lifecycle-control-runtime.ts +12 -3
  168. package/src/notifications/recent-activity.ts +32 -8
  169. package/src/notifications/telegram-daemon-cli.ts +125 -13
  170. package/src/notifications/telegram-daemon-control.ts +9 -0
  171. package/src/notifications/telegram-daemon.ts +88 -46
  172. package/src/notifications/telegram-reference.ts +36 -14
  173. package/src/notifications/threaded-render.ts +14 -6
  174. package/src/prompts/agents/architect.md +7 -4
  175. package/src/prompts/agents/critic.md +15 -6
  176. package/src/prompts/agents/planner.md +8 -3
  177. package/src/prompts/system/system-prompt.md +20 -2
  178. package/src/prompts/tools/bash.md +1 -1
  179. package/src/runtime-mcp/config-writer.ts +28 -0
  180. package/src/runtime-mcp/config.ts +7 -0
  181. package/src/runtime-mcp/loader.ts +3 -0
  182. package/src/runtime-mcp/manager.ts +3 -0
  183. package/src/runtime-mcp/types.ts +6 -0
  184. package/src/sdk.ts +10 -11
  185. package/src/session/agent-session.ts +103 -13
  186. package/src/session/session-manager.ts +58 -18
  187. package/src/skc-runtime/launch-tmux.ts +34 -8
  188. package/src/skc-runtime/launch-worktree.ts +16 -2
  189. package/src/skc-runtime/ralplan-runtime.ts +22 -6
  190. package/src/skc-runtime/restricted-role-agent-bash.ts +1 -0
  191. package/src/skc-runtime/session-state-sidecar.ts +167 -24
  192. package/src/skc-runtime/state-runtime.ts +8 -5
  193. package/src/skc-runtime/state-writer.ts +5 -5
  194. package/src/skc-runtime/team-runtime.ts +21 -6
  195. package/src/skc-runtime/tmux-common.ts +16 -0
  196. package/src/skc-runtime/tmux-sessions.ts +13 -9
  197. package/src/skc-runtime/ultragoal-guard.ts +150 -24
  198. package/src/skc-runtime/ultragoal-runtime.ts +16 -124
  199. package/src/skc-runtime/workflow-manifest.generated.json +0 -8
  200. package/src/skc-runtime/workflow-manifest.ts +0 -1
  201. package/src/slash-commands/builtin-registry.ts +136 -23
  202. package/src/slash-commands/types.ts +2 -0
  203. package/src/system-prompt.ts +8 -0
  204. package/src/task/executor.ts +5 -0
  205. package/src/tools/ast-grep.ts +10 -7
  206. package/src/tools/bash.ts +39 -14
  207. package/src/tools/index.ts +8 -1
  208. package/src/tools/skill.ts +1 -1
  209. package/src/utils/cmux-workspace.ts +233 -0
  210. package/src/utils/title-generator.ts +2 -0
  211. package/src/web/kagi.ts +1 -1
  212. package/src/web/parallel.ts +2 -2
  213. package/src/web/search/index.ts +137 -52
  214. package/src/web/search/provider.ts +80 -0
  215. package/src/web/search/providers/anthropic.ts +1 -1
  216. package/src/web/search/providers/brave.ts +1 -1
  217. package/src/web/search/providers/codex.ts +1 -1
  218. package/src/web/search/providers/duckduckgo.ts +1 -1
  219. package/src/web/search/providers/exa.ts +1 -1
  220. package/src/web/search/providers/gemini.ts +7 -3
  221. package/src/web/search/providers/jina.ts +1 -1
  222. package/src/web/search/providers/kimi.ts +6 -1
  223. package/src/web/search/providers/openai-compatible.ts +1 -1
  224. package/src/web/search/providers/parallel.ts +1 -1
  225. package/src/web/search/providers/perplexity.ts +2 -2
  226. package/src/web/search/providers/searxng.ts +1 -1
  227. package/src/web/search/providers/synthetic.ts +1 -1
  228. package/src/web/search/providers/tavily.ts +1 -1
  229. package/src/web/search/providers/utils.ts +67 -17
  230. package/src/web/search/providers/xai.ts +1 -1
  231. package/src/web/search/providers/zai.ts +1 -1
  232. package/src/web/search/render.ts +3 -0
  233. package/src/workflow/workflow-intent-diff.ts +199 -0
  234. package/vendor/insane-search/MANIFEST.json +1 -3
  235. package/vendor/insane-search/engine/tests/test_u1.py +0 -28
  236. package/vendor/insane-search/engine/transport.py +2 -3
  237. package/vendor/insane-search/engine/validators.py +0 -14
  238. package/src/defaults/skc/rules/ponytail.md +0 -68
@@ -3,6 +3,8 @@ 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
5
  import { tMessage } from "../../i18n";
6
+ import { resolveSubskillActivationForSkillInvocation } from "../../extensibility/skc-plugins";
7
+ import { buildSkillPromptMessage, parseSkillInvocations } from "../../extensibility/skills";
6
8
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
7
9
  import { BashExecutionComponent } from "../../modes/components/bash-execution";
8
10
  import { BranchSummaryMessageComponent } from "../../modes/components/branch-summary-message";
@@ -616,9 +618,80 @@ export class UiHelpers {
616
618
  this.ctx.updatePendingMessagesDisplay();
617
619
  this.ctx.showStatus("Queued message for after compaction");
618
620
  }
621
+ #hasSkillInvocations(text: string): boolean {
622
+ return parseSkillInvocations(text, this.ctx.skillCommands ?? new Map()).length > 0;
623
+ }
624
+
625
+ #isCompactionCommandMessage(text: string): boolean {
626
+ return this.#hasSkillInvocations(text) || this.isKnownSlashCommand(text);
627
+ }
628
+
629
+ async #deliverQueuedSkillMessage(message: CompactionQueuedMessage): Promise<boolean> {
630
+ const invocations = parseSkillInvocations(message.text, this.ctx.skillCommands ?? new Map());
631
+ if (invocations.length === 0) {
632
+ return false;
633
+ }
634
+
635
+ for (let index = 0; index < invocations.length; index += 1) {
636
+ const invocation = invocations[index];
637
+ if (!invocation) continue;
638
+
639
+ const activationResult = await resolveSubskillActivationForSkillInvocation({
640
+ cwd: this.ctx.sessionManager.getCwd(),
641
+ sessionId: this.ctx.session.sessionId,
642
+ skillName: invocation.skill.name,
643
+ args: invocation.args,
644
+ });
645
+ const built = await buildSkillPromptMessage(invocation.skill, activationResult.cleanedArgs, {
646
+ subskillActivation: activationResult.activation,
647
+ subskillActivationSet: activationResult.activeSubskillsToPersist,
648
+ cwd: this.ctx.sessionManager.getCwd(),
649
+ sessionId: this.ctx.session.sessionId,
650
+ });
651
+ const details: SkillPromptDetails = built.details;
652
+ const displayText = `/${invocation.commandName}${activationResult.cleanedArgs ? ` ${activationResult.cleanedArgs}` : ""}`;
653
+
654
+ if (this.ctx.session.isStreaming) {
655
+ const tag = this.ctx.session.enqueueCustomMessageDisplay(displayText, message.mode);
656
+ details.__pendingDisplayTag = tag;
657
+ }
658
+
659
+ const isLast = index === invocations.length - 1;
660
+ if (!this.ctx.session.isStreaming && !isLast) {
661
+ await this.ctx.session.sendCustomMessage({
662
+ customType: SKILL_PROMPT_MESSAGE_TYPE,
663
+ content: built.message,
664
+ display: true,
665
+ details,
666
+ attribution: "user",
667
+ });
668
+ continue;
669
+ }
619
670
 
671
+ await this.ctx.session.promptCustomMessage(
672
+ {
673
+ customType: SKILL_PROMPT_MESSAGE_TYPE,
674
+ content: built.message,
675
+ display: true,
676
+ details,
677
+ attribution: "user",
678
+ },
679
+ { streamingBehavior: message.mode },
680
+ );
681
+ }
682
+
683
+ if (this.ctx.session.isStreaming) {
684
+ this.ctx.updatePendingMessagesDisplay();
685
+ this.ctx.ui.requestRender();
686
+ }
687
+
688
+ return true;
689
+ }
620
690
  async #deliverQueuedMessage(message: CompactionQueuedMessage): Promise<void> {
621
- if (this.ctx.isKnownSlashCommand(message.text)) {
691
+ if (await this.#deliverQueuedSkillMessage(message)) {
692
+ return;
693
+ }
694
+ if (this.isKnownSlashCommand(message.text)) {
622
695
  await this.ctx.session.prompt(message.text);
623
696
  return;
624
697
  }
@@ -677,14 +750,14 @@ export class UiHelpers {
677
750
 
678
751
  let firstPromptIndex = -1;
679
752
  for (let i = 0; i < queuedMessages.length; i++) {
680
- if (!this.ctx.isKnownSlashCommand(queuedMessages[i].text)) {
753
+ if (!this.#isCompactionCommandMessage(queuedMessages[i].text)) {
681
754
  firstPromptIndex = i;
682
755
  break;
683
756
  }
684
757
  }
685
758
  if (firstPromptIndex === -1) {
686
759
  for (const message of queuedMessages) {
687
- await this.ctx.session.prompt(message.text);
760
+ await this.#deliverQueuedMessage(message);
688
761
  }
689
762
  return;
690
763
  }
@@ -0,0 +1,22 @@
1
+ import * as path from "node:path";
2
+
3
+ export interface DaemonPaths {
4
+ dir: string;
5
+ lock: string;
6
+ state: string;
7
+ roots: string;
8
+ steal: string;
9
+ aliases: string;
10
+ }
11
+
12
+ export function daemonPaths(agentDir: string): DaemonPaths {
13
+ const dir = path.join(agentDir, "notifications");
14
+ return {
15
+ dir,
16
+ lock: path.join(dir, "telegram-daemon.lock"),
17
+ state: path.join(dir, "telegram-daemon.state.json"),
18
+ roots: path.join(dir, "telegram-daemon.roots.json"),
19
+ steal: path.join(dir, "telegram-daemon.steal"),
20
+ aliases: path.join(dir, "telegram-callback-aliases.json"),
21
+ };
22
+ }
@@ -248,6 +248,8 @@ interface Token {
248
248
  value: string;
249
249
  /** Tag name if this token opens a tag, else undefined. */
250
250
  open?: string;
251
+ /** Exact opening tag text, including attributes, for safe chunk reopening. */
252
+ openTag?: string;
251
253
  /** Tag name if this token closes a tag, else undefined. */
252
254
  close?: string;
253
255
  }
@@ -266,8 +268,10 @@ function tokenize(html: string): Token[] {
266
268
  const openMatch = /^<([a-z-]+)(?:\s[^>]*)?>$/i.exec(raw);
267
269
  const token: Token = { value: raw };
268
270
  if (close && ALLOWED_TAGS.has(close[1]!.toLowerCase())) token.close = close[1]!.toLowerCase();
269
- else if (openMatch && ALLOWED_TAGS.has(openMatch[1]!.toLowerCase()))
271
+ else if (openMatch && ALLOWED_TAGS.has(openMatch[1]!.toLowerCase())) {
270
272
  token.open = openMatch[1]!.toLowerCase();
273
+ token.openTag = raw;
274
+ }
271
275
  tokens.push(token);
272
276
  i = end + 1;
273
277
  continue;
@@ -330,6 +334,76 @@ export function truncateTelegramHtml(message: string, max = TELEGRAM_MESSAGE_LIM
330
334
  return out + closersFor(stack) + effectiveMarker;
331
335
  }
332
336
 
337
+ interface OpenTag {
338
+ name: string;
339
+ tag: string;
340
+ }
341
+
342
+ /** Split a finished Telegram HTML message into ordered chunks without splitting tags or entities. */
343
+ export function splitTelegramHtml(message: string, max = TELEGRAM_MESSAGE_LIMIT): string[] {
344
+ if (message.length <= max) return [message];
345
+ const chunks: string[] = [];
346
+ const tokens = tokenize(message);
347
+ const stack: OpenTag[] = [];
348
+ let out = "";
349
+ let chunkHasBody = false;
350
+
351
+ const closersFor = (s: OpenTag[]): string =>
352
+ s
353
+ .map(t => `</${t.name}>`)
354
+ .reverse()
355
+ .join("");
356
+ const openersFor = (s: OpenTag[]): string => s.map(t => t.tag).join("");
357
+ const minimumChunkLengthFor = (s: OpenTag[]): number => openersFor(s).length + closersFor(s).length + 1;
358
+ const flush = (): void => {
359
+ if (!chunkHasBody) return;
360
+ chunks.push(out + closersFor(stack));
361
+ out = openersFor(stack);
362
+ chunkHasBody = false;
363
+ };
364
+
365
+ const updateStackForClose = (name: string): boolean => {
366
+ const idx = stack.findLastIndex(t => t.name === name);
367
+ if (idx === -1) return false;
368
+ stack.splice(idx, 1);
369
+ return true;
370
+ };
371
+
372
+ for (const token of tokens) {
373
+ if (token.open) {
374
+ const nextStack = [...stack, { name: token.open, tag: token.openTag ?? token.value }];
375
+ if (minimumChunkLengthFor(nextStack) > max) continue;
376
+ if (chunkHasBody && out.length + token.value.length + closersFor(nextStack).length > max) flush();
377
+ if (out.length + token.value.length + closersFor(nextStack).length > max) continue;
378
+ out += token.value;
379
+ chunkHasBody = true;
380
+ stack.push({ name: token.open, tag: token.openTag ?? token.value });
381
+ continue;
382
+ }
383
+
384
+ if (token.close) {
385
+ const idx = stack.findLastIndex(t => t.name === token.close);
386
+ if (idx === -1) continue;
387
+ const nextStack = stack.toSpliced(idx, 1);
388
+ if (chunkHasBody && out.length + token.value.length + closersFor(nextStack).length > max) flush();
389
+ if (out.length + token.value.length + closersFor(nextStack).length > max) {
390
+ updateStackForClose(token.close);
391
+ continue;
392
+ }
393
+ out += token.value;
394
+ chunkHasBody = true;
395
+ updateStackForClose(token.close);
396
+ continue;
397
+ }
398
+
399
+ if (chunkHasBody && out.length + token.value.length + closersFor(stack).length > max) flush();
400
+ out += token.value;
401
+ chunkHasBody = true;
402
+ }
403
+ if (chunkHasBody) chunks.push(out + closersFor(stack));
404
+ return chunks;
405
+ }
406
+
333
407
  /** Finalize an optional message: undefined passthrough, else safe-truncate. */
334
408
  export function finalizeTelegramHtml(message?: string): string | undefined {
335
409
  if (message === undefined) return undefined;
@@ -26,7 +26,7 @@ import * as path from "node:path";
26
26
  import { promisify } from "node:util";
27
27
  import type { ImageContent, TextContent } from "@sayknow-cli/ai";
28
28
  import { NotificationServer } from "@sayknow-cli/natives";
29
- import { logger } from "@sayknow-cli/utils";
29
+ import { logger, postmortem } from "@sayknow-cli/utils";
30
30
  import { Settings } from "../config/settings";
31
31
  import type { ExtensionCommandContext, ExtensionContext, ExtensionFactory } from "../extensibility/extensions";
32
32
  import { registerAskAnswerSource } from "../tools/ask-answer-registry";
@@ -297,6 +297,15 @@ function buildIdentity(
297
297
  return { repo, branch, machine: os.hostname(), title: sessionName };
298
298
  }
299
299
 
300
+ /** Compact cwd label for remote session identity; never emits the full host path by default. */
301
+ function compactCwd(cwd: string): string | undefined {
302
+ const home = os.homedir();
303
+ const resolved = path.resolve(cwd);
304
+ if (resolved === home) return "~";
305
+ const base = path.basename(resolved);
306
+ return base || path.parse(resolved).root || undefined;
307
+ }
308
+
300
309
  const execFileAsync = promisify(execFile);
301
310
 
302
311
  /** Best-effort working-tree diff stat for the context update (no throw). */
@@ -339,6 +348,8 @@ interface SessionRuntime {
339
348
  /** Assistant text already flushed before an ask this turn (turn-scoped dedupe
340
349
  * so turn_end does not re-emit the pre-ask lead-in). Reset each turn. */
341
350
  preAskFlushedText?: string;
351
+ /** Cancels the postmortem cleanup that emits `session_closed` on process teardown. */
352
+ cancelPostmortemCleanup: () => void;
342
353
  }
343
354
 
344
355
  interface ResolvedSettings {
@@ -491,6 +502,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
491
502
  if (!rt) return false;
492
503
  runtimes.delete(id);
493
504
  try {
505
+ rt.cancelPostmortemCleanup();
494
506
  rt.disposeAnswerSource();
495
507
  rt.disposeFileSink();
496
508
  } catch {}
@@ -671,6 +683,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
671
683
  pendingInteractive,
672
684
  disposeAnswerSource,
673
685
  disposeFileSink,
686
+ cancelPostmortemCleanup: () => {},
674
687
  redact,
675
688
  verbosity,
676
689
  sessionTag: tag,
@@ -678,6 +691,15 @@ export const createNotificationsExtension: ExtensionFactory = api => {
678
691
  pendingInbound: new Set<number>(),
679
692
  };
680
693
  runtimes.set(id, runtime);
694
+ // A native terminal close (SIGHUP), SIGTERM, Ctrl+C exit, or fatal error
695
+ // skips AgentSession.dispose(), so the `session_shutdown` extension event
696
+ // never fires and the daemon-side topic would be orphaned. postmortem
697
+ // awaits registered cleanups on those paths, so send the graceful
698
+ // `session_closed` frame from there too. stopSession() cancels this
699
+ // registration on every other teardown path, so it never double-fires.
700
+ runtime.cancelPostmortemCleanup = postmortem.register(`notifications-session-closed:${id}`, async () => {
701
+ await stopSession(id);
702
+ });
681
703
  logger.info(`notifications: serving session ${id} at ${endpoint.url} (unattended=${unattended})`);
682
704
 
683
705
  if (settingsAvailable && settings && isGloballyConfigured(cfg)) {
@@ -839,9 +861,15 @@ export const createNotificationsExtension: ExtensionFactory = api => {
839
861
  // Re-bind the interactive ask answer source: the ask tool resolves the
840
862
  // source by the current session id, which just changed.
841
863
  try {
864
+ rt.cancelPostmortemCleanup();
842
865
  rt.disposeAnswerSource();
843
866
  rt.disposeFileSink();
844
867
  } catch {}
868
+ // Follow the id change so a later process teardown closes the re-keyed
869
+ // session (the old closure captured the retired id).
870
+ rt.cancelPostmortemCleanup = postmortem.register(`notifications-session-closed:${newId}`, async () => {
871
+ await stopSession(newId);
872
+ });
845
873
  rt.disposeAnswerSource = registerInteractiveAnswerSource(
846
874
  newId,
847
875
  rt.server,
@@ -973,7 +1001,8 @@ export const createNotificationsExtension: ExtensionFactory = api => {
973
1001
  const tokenUsage = usage && usage.tokens != null ? `${usage.tokens}/${usage.contextWindow}` : undefined;
974
1002
  const modelId = model?.id;
975
1003
  void readGitDiffStat(ctx.cwd).then(diff => {
976
- if (!diff && !tokenUsage && !modelId) return;
1004
+ const cwd = compactCwd(ctx.cwd);
1005
+ if (!diff && !tokenUsage && !modelId && !cwd) return;
977
1006
  try {
978
1007
  rt.server.pushFrame(
979
1008
  JSON.stringify({
@@ -982,6 +1011,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
982
1011
  tokenUsage,
983
1012
  model: modelId,
984
1013
  diff,
1014
+ cwd,
985
1015
  }),
986
1016
  );
987
1017
  } catch (e) {
@@ -238,6 +238,7 @@ export function daemonResumeSession(env: NodeJS.ProcessEnv = process.env, opts:
238
238
  // restart, so an unknown or ambiguous prefix fails closed instead of
239
239
  // blindly spawning `skc --resume <prefix>` against a non-authoritative id.
240
240
  let resumeId = target.sessionIdOrPrefix;
241
+ let resumeCwd = target.path;
241
242
  if (opts.sessionsRoot) {
242
243
  const saved = listRecentSessions({ sessionsRoot: opts.sessionsRoot, limit: 1000 });
243
244
  const prefixed = saved.filter(
@@ -249,11 +250,19 @@ export function daemonResumeSession(env: NodeJS.ProcessEnv = process.env, opts:
249
250
  if (resolved.length > 1) {
250
251
  return { ambiguous: resolved.map(s => ({ sessionId: s.sessionId, path: s.path })) };
251
252
  }
252
- resumeId = resolved[0]!.sessionId;
253
+ const selected = resolved[0]!;
254
+ resumeId = selected.sessionId;
255
+ resumeCwd = selected.path;
256
+ }
257
+ const resolvedResumeCwd = resumeCwd ? path.resolve(resumeCwd) : undefined;
258
+ const resumeCwdStat = resolvedResumeCwd ? fs.statSync(resolvedResumeCwd, { throwIfNoEntry: false }) : undefined;
259
+ if (!resumeCwdStat?.isDirectory()) {
260
+ throw new Error(`skc_lifecycle_resume_cwd_unavailable: ${resolvedResumeCwd ?? "(missing)"}`);
253
261
  }
254
262
  const tmux = resolveSkcTmuxCommand(env);
255
263
  const name = tmuxSessionNameFor(resumeId);
256
- const command = `exec env SKC_TMUX_LAUNCHED=1 SKC_NOTIFICATIONS=1 skc --resume ${shellQuote(resumeId)}`;
264
+ const cwdPrefix = resolvedResumeCwd ? `cd ${shellQuote(resolvedResumeCwd)} && ` : "";
265
+ const command = `${cwdPrefix}exec env SKC_TMUX_LAUNCHED=1 SKC_NOTIFICATIONS=1 skc --resume ${shellQuote(resumeId)}`;
257
266
  const r = Bun.spawnSync([tmux, "new-session", "-d", "-s", name, "sh", "-c", command], {
258
267
  stdout: "pipe",
259
268
  stderr: "pipe",
@@ -261,7 +270,7 @@ export function daemonResumeSession(env: NodeJS.ProcessEnv = process.env, opts:
261
270
  });
262
271
  if (r.exitCode !== 0) throw new Error(r.stderr.toString().trim() || "skc_lifecycle_resume_failed");
263
272
  const tgt = buildSkcTmuxExactOptionTarget(name);
264
- for (const cmd of buildSkcTmuxProfileCommands(tgt, env, { sessionId: resumeId })) {
273
+ for (const cmd of buildSkcTmuxProfileCommands(tgt, env, { sessionId: resumeId, project: resolvedResumeCwd })) {
265
274
  Bun.spawnSync([tmux, ...cmd.args], { stdout: "pipe", stderr: "pipe", env });
266
275
  }
267
276
  return {
@@ -25,6 +25,8 @@ export interface RecentSessionEntry {
25
25
  mtimeMs: number;
26
26
  /** True when a terminal breadcrumb points at this session file. */
27
27
  currentTerminal?: boolean;
28
+ /** True when this history is an internal helper/sub-agent session. */
29
+ internal?: boolean;
28
30
  }
29
31
 
30
32
  export interface RecentActivityDeps {
@@ -34,17 +36,18 @@ export interface RecentActivityDeps {
34
36
  breadcrumbPaths?: string[];
35
37
  /** Max entries to return (default 20). */
36
38
  limit?: number;
39
+ /** Include internal helper/sub-agent sessions (default true). */
40
+ includeInternal?: boolean;
37
41
  /** Injection seam for tests. */
38
- readFirstLine?: (file: string) => string | undefined;
42
+ readInitialLines?: (file: string, maxLines: number) => string[];
39
43
  }
40
44
 
41
- function defaultReadFirstLine(file: string): string | undefined {
45
+ function defaultReadInitialLines(file: string, maxLines: number): string[] {
42
46
  try {
43
- const buf = fs.readFileSync(file, "utf8");
44
- const nl = buf.indexOf("\n");
45
- return nl === -1 ? buf : buf.slice(0, nl);
47
+ const lines = fs.readFileSync(file, "utf8").split("\n");
48
+ return lines.slice(0, maxLines);
46
49
  } catch {
47
- return undefined;
50
+ return [];
48
51
  }
49
52
  }
50
53
 
@@ -65,6 +68,22 @@ function headerMeta(line: string | undefined): { id?: string; path?: string; bra
65
68
  }
66
69
  }
67
70
 
71
+ /** Detect task-tool helper sessions from the durable early session_init metadata entry. */
72
+ function isInternalSession(lines: readonly string[]): boolean {
73
+ for (const line of lines.slice(1)) {
74
+ if (!line.trim()) continue;
75
+ try {
76
+ const obj = JSON.parse(line) as unknown;
77
+ if (typeof obj === "object" && obj !== null && (obj as { type?: unknown }).type === "session_init") {
78
+ return true;
79
+ }
80
+ } catch {
81
+ // Ignore malformed JSONL entries; classification is best-effort.
82
+ }
83
+ }
84
+ return false;
85
+ }
86
+
68
87
  /**
69
88
  * The authoritative session id for a history file: the header `id` when present,
70
89
  * else the filename stem with a leading `<timestamp>_` prefix stripped (matching
@@ -85,7 +104,8 @@ function sessionIdForFile(stem: string, headerId: string | undefined): string {
85
104
  */
86
105
  export function listRecentSessions(deps: RecentActivityDeps): RecentSessionEntry[] {
87
106
  const limit = deps.limit ?? 20;
88
- const readFirstLine = deps.readFirstLine ?? defaultReadFirstLine;
107
+ const includeInternal = deps.includeInternal ?? true;
108
+ const readInitialLines = deps.readInitialLines ?? defaultReadInitialLines;
89
109
  const breadcrumbs = new Set((deps.breadcrumbPaths ?? []).map(p => path.resolve(p)));
90
110
 
91
111
  let projectDirs: string[];
@@ -114,7 +134,10 @@ export function listRecentSessions(deps: RecentActivityDeps): RecentSessionEntry
114
134
  } catch {
115
135
  continue;
116
136
  }
117
- const meta = headerMeta(readFirstLine(file));
137
+ const initialLines = readInitialLines(file, 8);
138
+ const meta = headerMeta(initialLines[0]);
139
+ const internal = isInternalSession(initialLines);
140
+ if (internal && !includeInternal) continue;
118
141
  entries.push({
119
142
  sessionId: sessionIdForFile(name.slice(0, -".jsonl".length), meta.id),
120
143
  path: meta.path,
@@ -123,6 +146,7 @@ export function listRecentSessions(deps: RecentActivityDeps): RecentSessionEntry
123
146
  sessionStateFile: file,
124
147
  mtimeMs,
125
148
  currentTerminal: breadcrumbs.has(path.resolve(file)) || undefined,
149
+ internal: internal || undefined,
126
150
  });
127
151
  }
128
152
  }
@@ -1,14 +1,23 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { Settings } from "../config/settings";
3
+ import { YAML } from "bun";
4
+ import type { Settings } from "../config/settings";
4
5
  import { getNotificationConfig, isGloballyConfigured } from "./config";
5
- import { daemonPaths, TelegramNotificationDaemon } from "./telegram-daemon";
6
- import { clearTelegramControlRequest, readTelegramControlRequest } from "./telegram-daemon-control";
6
+ import { daemonPaths } from "./daemon-paths";
7
+ import type { TelegramDaemonOptions } from "./telegram-daemon";
8
+
9
+ type TelegramDaemonRunner = {
10
+ run(): Promise<void>;
11
+ requestStop(reason?: "reload" | "signal" | "stop"): void;
12
+ };
13
+
14
+ type TelegramDaemonConstructor = new (opts: TelegramDaemonOptions) => TelegramDaemonRunner;
7
15
 
8
16
  export interface RunDaemonInternalDeps {
9
- SettingsImpl?: Pick<typeof Settings, "init">;
10
- DaemonImpl?: typeof TelegramNotificationDaemon;
17
+ SettingsImpl?: { init: (options?: { agentDir?: string }) => Promise<Pick<Settings, "get" | "getAgentDir">> };
18
+ DaemonImpl?: TelegramDaemonConstructor;
11
19
  processPid?: number;
20
+ pidAlive?: (pid: number) => boolean;
12
21
  }
13
22
 
14
23
  function argValue(argv: string[], name: string): string | undefined {
@@ -16,9 +25,105 @@ function argValue(argv: string[], name: string): string | undefined {
16
25
  return i >= 0 ? argv[i + 1] : undefined;
17
26
  }
18
27
 
28
+ function getByPath(obj: unknown, pathSegments: string[]): unknown {
29
+ let current = obj;
30
+ for (const segment of pathSegments) {
31
+ if (!current || typeof current !== "object" || Array.isArray(current)) return undefined;
32
+ current = (current as Record<string, unknown>)[segment];
33
+ }
34
+ return current;
35
+ }
36
+
37
+ function asString(value: unknown): string | undefined {
38
+ return typeof value === "string" && value.length > 0 ? value : undefined;
39
+ }
40
+
41
+ function asBoolean(value: unknown, fallback: boolean): boolean {
42
+ return typeof value === "boolean" ? value : fallback;
43
+ }
44
+
45
+ function asIdleTimeoutMs(value: unknown): number {
46
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 60_000;
47
+ }
48
+
49
+ export function createLightweightDaemonSettings(input: {
50
+ agentDir: string;
51
+ rawConfig?: unknown;
52
+ }): Pick<Settings, "get" | "getAgentDir"> {
53
+ const rawConfig = input.rawConfig && typeof input.rawConfig === "object" ? input.rawConfig : {};
54
+ return {
55
+ get(pathName: string): unknown {
56
+ const value = getByPath(rawConfig, pathName.split("."));
57
+ switch (pathName) {
58
+ case "notifications.enabled":
59
+ return asBoolean(value, false);
60
+ case "notifications.telegram.botToken":
61
+ case "notifications.telegram.chatId":
62
+ case "notifications.discord.botToken":
63
+ case "notifications.discord.channelId":
64
+ case "notifications.slack.botToken":
65
+ case "notifications.slack.channelId":
66
+ return asString(value);
67
+ case "notifications.redact":
68
+ return asBoolean(value, false);
69
+ case "notifications.verbosity":
70
+ return value === "verbose" ? "verbose" : "lean";
71
+ case "notifications.daemon.idleTimeoutMs":
72
+ return asIdleTimeoutMs(value);
73
+ default:
74
+ return undefined;
75
+ }
76
+ },
77
+ getAgentDir(): string {
78
+ return input.agentDir;
79
+ },
80
+ } as Pick<Settings, "get" | "getAgentDir">;
81
+ }
82
+
83
+ export async function loadLightweightDaemonSettings(agentDir: string): Promise<Pick<Settings, "get" | "getAgentDir">> {
84
+ const configPath = path.join(agentDir, "config.yml");
85
+ let rawConfig: unknown = {};
86
+ try {
87
+ rawConfig = YAML.parse(await fs.promises.readFile(configPath, "utf8"));
88
+ } catch (error) {
89
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
90
+ }
91
+ return createLightweightDaemonSettings({ agentDir, rawConfig });
92
+ }
93
+
94
+ async function resolveDaemonSettings(
95
+ agentDir: string,
96
+ deps: RunDaemonInternalDeps,
97
+ ): Promise<Pick<Settings, "get" | "getAgentDir">> {
98
+ if (deps.SettingsImpl) return await deps.SettingsImpl.init({ agentDir });
99
+ return await loadLightweightDaemonSettings(agentDir);
100
+ }
101
+
102
+ export function ownerPidFromOwnerId(ownerId: string): number | undefined {
103
+ const match = /^(\d+)(?:-|$)/.exec(ownerId);
104
+ if (!match) return undefined;
105
+ const pid = Number(match[1]);
106
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
107
+ }
108
+
109
+ function defaultPidAlive(pid: number): boolean {
110
+ try {
111
+ process.kill(pid, 0);
112
+ return true;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ function ownerProcessIsAlive(ownerId: string, deps: RunDaemonInternalDeps): boolean {
119
+ const ownerPid = ownerPidFromOwnerId(ownerId);
120
+ if (ownerPid === undefined) return true;
121
+ return (deps.pidAlive ?? defaultPidAlive)(ownerPid);
122
+ }
123
+
19
124
  export async function runDaemonSmoke(opts: { agentDir?: string } = {}): Promise<void> {
20
125
  const agentDir = opts.agentDir ?? fs.mkdtempSync(path.join(process.cwd(), ".telegram-daemon-smoke-"));
21
- const settings = Settings.isolated({});
126
+ const settings = createLightweightDaemonSettings({ agentDir, rawConfig: {} });
22
127
  const paths = daemonPaths(agentDir);
23
128
  await fs.promises.mkdir(paths.dir, { recursive: true, mode: 0o700 });
24
129
  const tempLock = `${paths.lock}.smoke.${process.pid}`;
@@ -34,12 +139,19 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
34
139
  if (smoke) return runDaemonSmoke({ agentDir });
35
140
  const ownerId = argValue(argv, "--owner-id");
36
141
  if (!ownerId) throw new Error("missing --owner-id");
37
- const settings = await (deps.SettingsImpl ?? Settings).init(agentDir ? { agentDir } : {});
38
- const cfg = getNotificationConfig(settings);
142
+ if (!ownerProcessIsAlive(ownerId, deps)) {
143
+ process.stderr.write(`SKC notify daemon exiting: owner process from --owner-id ${ownerId} is not alive.\n`);
144
+ return;
145
+ }
146
+ const resolvedAgentDir = agentDir ?? process.env.SKC_CODING_AGENT_DIR ?? path.join(process.cwd(), ".skc", "agent");
147
+ const settings = await resolveDaemonSettings(resolvedAgentDir, deps);
148
+ const cfg = getNotificationConfig(settings as Settings);
39
149
  if (!isGloballyConfigured(cfg) || !cfg.botToken || !cfg.chatId) return;
40
- const Daemon = deps.DaemonImpl ?? TelegramNotificationDaemon;
150
+ const { clearTelegramControlRequest, readTelegramControlRequest } = await import("./telegram-daemon-control");
151
+ const Daemon: TelegramDaemonConstructor =
152
+ deps.DaemonImpl ?? (await import("./telegram-daemon")).TelegramNotificationDaemon;
41
153
  const daemon = new Daemon({
42
- settings,
154
+ settings: settings as Settings,
43
155
  ownerId,
44
156
  botToken: cfg.botToken,
45
157
  chatId: cfg.chatId,
@@ -47,15 +159,15 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
47
159
  pid: deps.processPid ?? process.pid,
48
160
  control: {
49
161
  shouldStop: async owner => {
50
- const req = await readTelegramControlRequest(settings);
162
+ const req = await readTelegramControlRequest(settings as Settings);
51
163
  return Boolean(req && (!req.ownerId || req.ownerId === owner));
52
164
  },
53
165
  clear: async owner => {
54
- const req = await readTelegramControlRequest(settings);
166
+ const req = await readTelegramControlRequest(settings as Settings);
55
167
  // Only clear a request that targets this daemon owner, so an exiting
56
168
  // daemon never erases a newer request meant for a different owner.
57
169
  if (req && (!req.ownerId || req.ownerId === owner)) {
58
- await clearTelegramControlRequest(settings, req.requestId);
170
+ await clearTelegramControlRequest(settings as Settings, req.requestId);
59
171
  }
60
172
  },
61
173
  },
@@ -97,6 +97,14 @@ export interface TelegramDaemonControlDeps {
97
97
  sendSignal?: (pid: number, signal: NodeJS.Signals) => void;
98
98
  spawn?: TelegramDaemonDeps["spawn"];
99
99
  execPath?: string;
100
+ /**
101
+ * Stable process id encoded into freshly-spawned daemon owner ids.
102
+ *
103
+ * The daemon-internal entrypoint rejects numeric owner ids whose process is
104
+ * already gone. `skc daemon reload` is a short-lived CLI process, so using its
105
+ * own pid can race the child startup and make the replacement exit immediately.
106
+ */
107
+ ownerPid?: number;
100
108
  randomId?: () => string;
101
109
  sleep?: (ms: number) => Promise<void>;
102
110
  waitStepMs?: number;
@@ -188,6 +196,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
188
196
  fs: this.deps.fs,
189
197
  now: this.deps.now,
190
198
  pidAlive: this.deps.pidAlive,
199
+ pid: this.deps.ownerPid ?? process.ppid,
191
200
  spawn: this.deps.spawn,
192
201
  execPath: this.deps.execPath,
193
202
  randomId: this.deps.randomId,