@sayknow-cli/coding-agent 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/bin/skc.js +4 -0
  2. package/dist/types/cli/mcp-cli.d.ts +25 -0
  3. package/dist/types/cli/notify-cli.d.ts +2 -0
  4. package/dist/types/cli/plugin-cli.d.ts +2 -0
  5. package/dist/types/cli.d.ts +6 -0
  6. package/dist/types/commands/mcp.d.ts +70 -0
  7. package/dist/types/commands/plugin.d.ts +6 -0
  8. package/dist/types/commands/session.d.ts +6 -0
  9. package/dist/types/config/keybindings.d.ts +2 -2
  10. package/dist/types/config/model-profile-activation.d.ts +8 -1
  11. package/dist/types/config/settings-schema.d.ts +39 -2
  12. package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
  13. package/dist/types/defaults/skc-defaults.d.ts +2 -13
  14. package/dist/types/extensibility/shared-events.d.ts +1 -0
  15. package/dist/types/extensibility/skc-plugins/compiler.d.ts +19 -0
  16. package/dist/types/extensibility/skc-plugins/constrained-hooks.d.ts +29 -0
  17. package/dist/types/extensibility/skc-plugins/index.d.ts +9 -0
  18. package/dist/types/extensibility/skc-plugins/injection.d.ts +9 -0
  19. package/dist/types/extensibility/skc-plugins/installer.d.ts +13 -0
  20. package/dist/types/extensibility/skc-plugins/mcp-policy.d.ts +26 -0
  21. package/dist/types/extensibility/skc-plugins/observability.d.ts +27 -0
  22. package/dist/types/extensibility/skc-plugins/prompt-appendix.d.ts +16 -0
  23. package/dist/types/extensibility/skc-plugins/registry.d.ts +32 -0
  24. package/dist/types/extensibility/skc-plugins/runtime-adapters.d.ts +64 -0
  25. package/dist/types/extensibility/skc-plugins/session-validation.d.ts +42 -0
  26. package/dist/types/extensibility/skc-plugins/types.d.ts +158 -2
  27. package/dist/types/extensibility/skc-plugins/validation.d.ts +8 -1
  28. package/dist/types/lsp/types.d.ts +2 -0
  29. package/dist/types/main.d.ts +2 -0
  30. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  31. package/dist/types/modes/components/model-selector.d.ts +8 -0
  32. package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
  33. package/dist/types/modes/theme/defaults/index.d.ts +99 -0
  34. package/dist/types/notifications/attachment-registry.d.ts +17 -0
  35. package/dist/types/notifications/chat-adapters.d.ts +9 -0
  36. package/dist/types/notifications/config.d.ts +9 -1
  37. package/dist/types/notifications/engine.d.ts +59 -0
  38. package/dist/types/notifications/html-format.d.ts +11 -0
  39. package/dist/types/notifications/index.d.ts +149 -1
  40. package/dist/types/notifications/lifecycle-commands.d.ts +60 -0
  41. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  42. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  43. package/dist/types/notifications/managed-daemon.d.ts +48 -0
  44. package/dist/types/notifications/operator-runtime.d.ts +52 -0
  45. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  46. package/dist/types/notifications/recent-activity.d.ts +35 -0
  47. package/dist/types/notifications/telegram-daemon.d.ts +133 -16
  48. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  49. package/dist/types/notifications/threaded-inbound.d.ts +19 -0
  50. package/dist/types/notifications/threaded-render.d.ts +6 -1
  51. package/dist/types/notifications/topic-registry.d.ts +12 -9
  52. package/dist/types/runtime-mcp/types.d.ts +7 -0
  53. package/dist/types/sdk.d.ts +2 -0
  54. package/dist/types/session/agent-session.d.ts +16 -4
  55. package/dist/types/session/blob-store.d.ts +25 -0
  56. package/dist/types/session/session-manager.d.ts +57 -0
  57. package/dist/types/skc-runtime/launch-tmux.d.ts +1 -0
  58. package/dist/types/skc-runtime/psmux-detect.d.ts +78 -0
  59. package/dist/types/skc-runtime/ralplan-runtime.d.ts +1 -1
  60. package/dist/types/skc-runtime/team-runtime.d.ts +2 -0
  61. package/dist/types/skc-runtime/tmux-common.d.ts +30 -2
  62. package/dist/types/skc-runtime/tmux-sessions.d.ts +18 -0
  63. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  64. package/dist/types/system-prompt.d.ts +2 -0
  65. package/dist/types/task/executor.d.ts +9 -1
  66. package/dist/types/tools/composer-bash-policy.d.ts +14 -0
  67. package/dist/types/tools/fetch.d.ts +23 -0
  68. package/dist/types/tools/index.d.ts +4 -1
  69. package/dist/types/tools/telegram-send.d.ts +32 -0
  70. package/dist/types/utils/changelog.d.ts +1 -0
  71. package/dist/types/web/insane/bridge.d.ts +103 -0
  72. package/dist/types/web/insane/url-guard.d.ts +25 -0
  73. package/dist/types/web/scrapers/types.d.ts +5 -0
  74. package/dist/types/web/scrapers/utils.d.ts +7 -1
  75. package/dist/types/web/search/provider.d.ts +18 -1
  76. package/dist/types/web/search/providers/insane.d.ts +53 -0
  77. package/dist/types/web/search/providers/text-citations.d.ts +23 -0
  78. package/dist/types/web/search/types.d.ts +12 -4
  79. package/package.json +14 -10
  80. package/scripts/g004-tmux-smoke.ts +100 -0
  81. package/scripts/g005-daemon-smoke.ts +180 -0
  82. package/scripts/g011-daemon-path-smoke.ts +153 -0
  83. package/scripts/verify-insane-vendor.ts +132 -0
  84. package/src/cli/args.ts +1 -1
  85. package/src/cli/fast-help.ts +30 -2
  86. package/src/cli/mcp-cli.ts +272 -0
  87. package/src/cli/notify-cli.ts +152 -5
  88. package/src/cli/plugin-cli.ts +66 -3
  89. package/src/cli.ts +30 -12
  90. package/src/commands/mcp.ts +117 -0
  91. package/src/commands/plugin.ts +4 -0
  92. package/src/commands/session.ts +18 -0
  93. package/src/commands/team.ts +1 -1
  94. package/src/config/keybindings.ts +2 -2
  95. package/src/config/model-profile-activation.ts +55 -7
  96. package/src/config/settings-schema.ts +30 -1
  97. package/src/deep-interview/plaintext-gate-guard.ts +94 -0
  98. package/src/defaults/skc/extensions/grok-cli-vendor/biome.json +1 -1
  99. package/src/defaults/skc/skills/deep-interview/SKILL.md +7 -6
  100. package/src/defaults/skc/skills/ralplan/SKILL.md +11 -4
  101. package/src/defaults/skc/skills/team/SKILL.md +5 -3
  102. package/src/defaults/skc/skills/ultragoal/SKILL.md +41 -13
  103. package/src/defaults/skc-defaults.ts +2 -27
  104. package/src/export/html/index.ts +2 -2
  105. package/src/extensibility/extensions/runner.ts +1 -0
  106. package/src/extensibility/shared-events.ts +1 -0
  107. package/src/extensibility/skc-plugins/compiler.ts +351 -0
  108. package/src/extensibility/skc-plugins/constrained-hooks.ts +170 -0
  109. package/src/extensibility/skc-plugins/index.ts +9 -0
  110. package/src/extensibility/skc-plugins/injection.ts +109 -0
  111. package/src/extensibility/skc-plugins/installer.ts +434 -0
  112. package/src/extensibility/skc-plugins/loader.ts +3 -1
  113. package/src/extensibility/skc-plugins/mcp-policy.ts +239 -0
  114. package/src/extensibility/skc-plugins/observability.ts +84 -0
  115. package/src/extensibility/skc-plugins/paths.ts +1 -1
  116. package/src/extensibility/skc-plugins/prompt-appendix.ts +109 -0
  117. package/src/extensibility/skc-plugins/registry.ts +180 -0
  118. package/src/extensibility/skc-plugins/runtime-adapters.ts +234 -0
  119. package/src/extensibility/skc-plugins/schema.ts +250 -20
  120. package/src/extensibility/skc-plugins/session-validation.ts +147 -0
  121. package/src/extensibility/skc-plugins/types.ts +199 -3
  122. package/src/extensibility/skc-plugins/validation.ts +80 -0
  123. package/src/extensibility/skills.ts +15 -0
  124. package/src/hooks/skill-state.ts +57 -0
  125. package/src/internal-urls/docs-index.generated.ts +17 -13
  126. package/src/lsp/config.ts +16 -3
  127. package/src/lsp/defaults.json +7 -0
  128. package/src/lsp/types.ts +2 -0
  129. package/src/main.ts +14 -3
  130. package/src/modes/bridge/bridge-mode.ts +11 -0
  131. package/src/modes/components/assistant-message.ts +49 -1
  132. package/src/modes/components/custom-editor.ts +2 -0
  133. package/src/modes/components/footer.ts +2 -3
  134. package/src/modes/components/hook-editor.ts +1 -1
  135. package/src/modes/components/hook-selector.ts +67 -43
  136. package/src/modes/components/model-selector.ts +56 -11
  137. package/src/modes/components/status-line/git-utils.ts +25 -0
  138. package/src/modes/components/status-line.ts +10 -11
  139. package/src/modes/components/welcome.ts +2 -3
  140. package/src/modes/controllers/event-controller.ts +15 -0
  141. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  142. package/src/modes/controllers/selector-controller.ts +53 -11
  143. package/src/modes/interactive-mode.ts +50 -3
  144. package/src/modes/shared/agent-wire/scopes.ts +1 -1
  145. package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
  146. package/src/modes/theme/defaults/index.ts +2 -0
  147. package/src/modes/utils/context-usage.ts +2 -2
  148. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  149. package/src/notifications/attachment-registry.ts +23 -0
  150. package/src/notifications/chat-adapters.ts +147 -0
  151. package/src/notifications/config.ts +23 -2
  152. package/src/notifications/engine.ts +100 -0
  153. package/src/notifications/html-format.ts +38 -0
  154. package/src/notifications/index.ts +417 -45
  155. package/src/notifications/lifecycle-commands.ts +238 -0
  156. package/src/notifications/lifecycle-control-runtime.ts +405 -0
  157. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  158. package/src/notifications/managed-daemon.ts +163 -0
  159. package/src/notifications/operator-runtime.ts +171 -0
  160. package/src/notifications/rate-limit-pool.ts +19 -0
  161. package/src/notifications/recent-activity.ts +132 -0
  162. package/src/notifications/telegram-daemon.ts +984 -242
  163. package/src/notifications/telegram-reference.ts +25 -7
  164. package/src/notifications/threaded-inbound.ts +60 -4
  165. package/src/notifications/threaded-render.ts +20 -2
  166. package/src/notifications/topic-registry.ts +23 -9
  167. package/src/prompts/agents/executor.md +2 -2
  168. package/src/runtime-mcp/transports/stdio.ts +38 -4
  169. package/src/runtime-mcp/types.ts +7 -0
  170. package/src/sdk.ts +157 -10
  171. package/src/session/agent-session.ts +248 -125
  172. package/src/session/blob-store.ts +196 -8
  173. package/src/session/session-manager.ts +762 -12
  174. package/src/skc-runtime/launch-tmux.ts +85 -22
  175. package/src/skc-runtime/ledger-event-renderer.ts +1 -0
  176. package/src/skc-runtime/psmux-detect.ts +239 -0
  177. package/src/skc-runtime/ralplan-runtime.ts +2 -2
  178. package/src/skc-runtime/team-runtime.ts +56 -23
  179. package/src/skc-runtime/tmux-common.ts +88 -4
  180. package/src/skc-runtime/tmux-sessions.ts +111 -9
  181. package/src/skc-runtime/ultragoal-guard.ts +25 -8
  182. package/src/skc-runtime/ultragoal-runtime.ts +75 -15
  183. package/src/skc-runtime/workflow-manifest.generated.json +29 -0
  184. package/src/skc-runtime/workflow-manifest.ts +7 -2
  185. package/src/slash-commands/builtin-registry.ts +23 -3
  186. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  187. package/src/slash-commands/helpers/parse.ts +2 -1
  188. package/src/system-prompt.ts +9 -0
  189. package/src/task/executor.ts +31 -7
  190. package/src/task/index.ts +2 -0
  191. package/src/tools/ask.ts +5 -1
  192. package/src/tools/bash.ts +9 -0
  193. package/src/tools/composer-bash-policy.ts +96 -0
  194. package/src/tools/fetch.ts +94 -1
  195. package/src/tools/index.ts +6 -1
  196. package/src/tools/telegram-send.ts +137 -0
  197. package/src/utils/changelog.ts +8 -0
  198. package/src/web/insane/bridge.ts +350 -0
  199. package/src/web/insane/url-guard.ts +159 -0
  200. package/src/web/scrapers/types.ts +143 -45
  201. package/src/web/scrapers/utils.ts +70 -19
  202. package/src/web/search/provider.ts +77 -18
  203. package/src/web/search/providers/anthropic.ts +70 -3
  204. package/src/web/search/providers/codex.ts +1 -119
  205. package/src/web/search/providers/gemini.ts +99 -0
  206. package/src/web/search/providers/insane.ts +551 -0
  207. package/src/web/search/providers/openai-compatible.ts +66 -32
  208. package/src/web/search/providers/text-citations.ts +111 -0
  209. package/src/web/search/types.ts +13 -2
  210. package/vendor/insane-search/LICENSE +21 -0
  211. package/vendor/insane-search/MANIFEST.json +24 -0
  212. package/vendor/insane-search/engine/__init__.py +23 -0
  213. package/vendor/insane-search/engine/__main__.py +128 -0
  214. package/vendor/insane-search/engine/bias_check.py +183 -0
  215. package/vendor/insane-search/engine/executor.py +254 -0
  216. package/vendor/insane-search/engine/fetch_chain.py +725 -0
  217. package/vendor/insane-search/engine/learning.py +175 -0
  218. package/vendor/insane-search/engine/phase0.py +214 -0
  219. package/vendor/insane-search/engine/safety.py +91 -0
  220. package/vendor/insane-search/engine/templates/package.json +11 -0
  221. package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
  222. package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
  223. package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
  224. package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
  225. package/vendor/insane-search/engine/tests/test_u1.py +200 -0
  226. package/vendor/insane-search/engine/tests/test_u4.py +131 -0
  227. package/vendor/insane-search/engine/tests/test_u5.py +163 -0
  228. package/vendor/insane-search/engine/tests/test_u7.py +124 -0
  229. package/vendor/insane-search/engine/transport.py +211 -0
  230. package/vendor/insane-search/engine/url_transforms.py +98 -0
  231. package/vendor/insane-search/engine/validators.py +331 -0
  232. package/vendor/insane-search/engine/waf_detector.py +214 -0
  233. package/vendor/insane-search/engine/waf_profiles.yaml +162 -0
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Paired-chat /session_* command grammar (G009).
3
+ *
4
+ * Pure parser + shared target validator for the Telegram session-lifecycle
5
+ * commands. The daemon parses an inbound paired-chat message here, then attaches
6
+ * transport identity (chatId/updateId/token/requestId) and routes the resulting
7
+ * frame to the orchestrator. Keeping this pure makes the grammar, the MVP
8
+ * prompt-rejection, and target validation unit-testable without the daemon.
9
+ *
10
+ * MVP scope: an initial prompt (`-- <prompt>`) is REJECTED with usage text — no
11
+ * prompt text ever enters a frame, audit, log, or response until daemon-owned
12
+ * 0600 prompt refs are designed.
13
+ */
14
+ import * as os from "node:os";
15
+ import type { SessionCloseTarget, SessionCreateTarget, SessionLifecycleResponse, SessionResumeTarget } from "./index";
16
+
17
+ export type LifecycleCommandVerb = "session_create" | "session_close" | "session_resume";
18
+
19
+ /** A parsed, validated lifecycle command (transport identity added by caller). */
20
+ export type ParsedLifecycleCommand =
21
+ | { kind: "create"; target: SessionCreateTarget }
22
+ | { kind: "close"; target: SessionCloseTarget }
23
+ | { kind: "resume"; target: SessionResumeTarget }
24
+ | { kind: "recent"; which: "create" | "resume" | "all" }
25
+ | { kind: "usage"; message: string }
26
+ | { kind: "reject"; reason: "invalid_target" | "prompt_unsupported"; message: string }
27
+ | { kind: "none" };
28
+
29
+ const USAGE = [
30
+ "Session commands:",
31
+ "/session_create path <dir>",
32
+ "/session_create worktree <repo> <branch>",
33
+ "/session_create dir <newdir>",
34
+ "/session_close <sessionId>",
35
+ "/session_resume <sessionId|prefix>",
36
+ "/session_recent [create|resume]",
37
+ ].join("\n");
38
+
39
+ /** True when the text begins a /session_* command (cheap pre-gate). */
40
+ export function isLifecycleCommandText(text: string | undefined): boolean {
41
+ if (!text) return false;
42
+ return /^\/session_(create|close|resume|recent)\b/.test(text.trim());
43
+ }
44
+
45
+ /**
46
+ * Parse a paired-chat message into a lifecycle command. Returns `none` for
47
+ * non-lifecycle text, `usage`/`reject` for malformed input (no side effect), or
48
+ * a validated `create`/`close`/`resume`/`recent` intent.
49
+ *
50
+ * The caller MUST have already enforced paired-chat authorization; this function
51
+ * performs grammar + target validation only.
52
+ */
53
+ export function parseLifecycleCommand(text: string | undefined): ParsedLifecycleCommand {
54
+ if (!isLifecycleCommandText(text)) return { kind: "none" };
55
+ const raw = (text ?? "").trim();
56
+
57
+ // MVP: reject any initial-prompt separator outright (no prompt handling yet).
58
+ if (/\s--(\s|$)/.test(raw)) {
59
+ return {
60
+ kind: "reject",
61
+ reason: "prompt_unsupported",
62
+ message: `Initial prompts (\`-- <prompt>\`) are not supported yet. Create the session, then send a normal message in its thread.\n\n${USAGE}`,
63
+ };
64
+ }
65
+
66
+ const [command, ...args] = raw.split(/\s+/);
67
+
68
+ if (command === "/session_recent") {
69
+ const which = args[0];
70
+ if (which === undefined || which === "create" || which === "resume") {
71
+ return { kind: "recent", which: which ?? "all" };
72
+ }
73
+ return { kind: "usage", message: USAGE };
74
+ }
75
+
76
+ if (command === "/session_close") {
77
+ if (args.length !== 1) return { kind: "usage", message: USAGE };
78
+ const sessionId = args[0]!;
79
+ if (!isSafeIdentifier(sessionId)) {
80
+ return { kind: "reject", reason: "invalid_target", message: `Invalid session id.\n\n${USAGE}` };
81
+ }
82
+ return { kind: "close", target: { sessionId } };
83
+ }
84
+
85
+ if (command === "/session_resume") {
86
+ if (args.length !== 1) return { kind: "usage", message: USAGE };
87
+ const idOrPrefix = args[0]!;
88
+ if (!isSafeIdentifier(idOrPrefix)) {
89
+ return { kind: "reject", reason: "invalid_target", message: `Invalid session id/prefix.\n\n${USAGE}` };
90
+ }
91
+ return { kind: "resume", target: { sessionIdOrPrefix: idOrPrefix } };
92
+ }
93
+
94
+ // /session_create <kind> ...
95
+ const kind = args[0];
96
+ if (kind === "path") {
97
+ if (args.length !== 2) return { kind: "usage", message: USAGE };
98
+ const p = normalizeLifecyclePath(args[1]!);
99
+ if (!p) return { kind: "reject", reason: "invalid_target", message: `Invalid path.\n\n${USAGE}` };
100
+ return { kind: "create", target: { kind: "existing_path", path: p } };
101
+ }
102
+ if (kind === "dir") {
103
+ if (args.length !== 2) return { kind: "usage", message: USAGE };
104
+ const p = normalizeLifecyclePath(args[1]!);
105
+ if (!p) return { kind: "reject", reason: "invalid_target", message: `Invalid dir.\n\n${USAGE}` };
106
+ return { kind: "create", target: { kind: "plain_dir", path: p } };
107
+ }
108
+ if (kind === "worktree") {
109
+ if (args.length !== 3) return { kind: "usage", message: USAGE };
110
+ const repo = normalizeLifecyclePath(args[1]!);
111
+ const branch = args[2]!;
112
+ if (!repo) return { kind: "reject", reason: "invalid_target", message: `Invalid repo path.\n\n${USAGE}` };
113
+ if (!isSafeBranch(branch)) {
114
+ return { kind: "reject", reason: "invalid_target", message: `Invalid branch name.\n\n${USAGE}` };
115
+ }
116
+ return { kind: "create", target: { kind: "worktree", repo, branch } };
117
+ }
118
+ return { kind: "usage", message: USAGE };
119
+ }
120
+
121
+ /** The canonical usage text (exported for the daemon's help replies). */
122
+ export function lifecycleUsage(): string {
123
+ return USAGE;
124
+ }
125
+
126
+ /**
127
+ * Shared target validator reused at the policy/effect boundary (after paired-chat
128
+ * auth, before any side effect). Returns null when valid, or an `invalid_target`
129
+ * reason. The orchestrator remains authoritative; this is a defensive pre-check
130
+ * the parser and any other entry point share.
131
+ */
132
+ export function validateLifecycleTarget(
133
+ verb: LifecycleCommandVerb,
134
+ target: SessionCreateTarget | SessionCloseTarget | SessionResumeTarget,
135
+ ): { ok: true } | { ok: false; reason: "invalid_target"; message: string } {
136
+ const bad = (message: string) => ({ ok: false as const, reason: "invalid_target" as const, message });
137
+ if (verb === "session_create") {
138
+ const t = target as SessionCreateTarget;
139
+ if (t.kind === "existing_path" || t.kind === "plain_dir") {
140
+ return normalizeLifecyclePath(t.path) ? { ok: true } : bad("invalid path");
141
+ }
142
+ if (t.kind === "worktree") {
143
+ if (!normalizeLifecyclePath(t.repo)) return bad("invalid repo path");
144
+ return isSafeBranch(t.branch) ? { ok: true } : bad("invalid branch");
145
+ }
146
+ return bad("unknown create target");
147
+ }
148
+ if (verb === "session_close") {
149
+ const t = target as SessionCloseTarget;
150
+ return isSafeIdentifier(t.sessionId) ? { ok: true } : bad("invalid session id");
151
+ }
152
+ const t = target as SessionResumeTarget;
153
+ return isSafeIdentifier(t.sessionIdOrPrefix) ? { ok: true } : bad("invalid session id/prefix");
154
+ }
155
+
156
+ // --- Safety primitives (defensive; the full-trust paired chat is accepted, but
157
+ // we still reject obviously malformed/injection-shaped inputs early). ---
158
+
159
+ export function normalizeLifecyclePath(value: string): string | undefined {
160
+ if (!isSafePath(value)) return undefined;
161
+ if (value === "~") return os.homedir() || undefined;
162
+ if (value.startsWith("~/")) {
163
+ const home = os.homedir();
164
+ return home ? `${home}${value.slice(1)}` : undefined;
165
+ }
166
+ if (value.startsWith("~")) return undefined;
167
+ return value;
168
+ }
169
+ function isSafeIdentifier(value: string): boolean {
170
+ return /^[A-Za-z0-9._-]{1,128}$/.test(value);
171
+ }
172
+
173
+ function isSafePath(value: string): boolean {
174
+ // Reject empty, shell-metacharacter, or newline-bearing paths. Absolute or
175
+ // relative are both allowed (full-trust chat), but not injection shapes.
176
+ if (value.length === 0 || value.length > 4096) return false;
177
+ if (/[\n\r\0]/.test(value)) return false;
178
+ return !/[;&|`$(){}<>*?!\\"']/.test(value);
179
+ }
180
+
181
+ function isSafeBranch(value: string): boolean {
182
+ // Defense-in-depth: also reject leading-hyphen names so a branch can never be
183
+ // mistaken for a CLI flag downstream.
184
+ return /^[A-Za-z0-9._/-]{1,255}$/.test(value) && !value.includes("..") && !value.startsWith("-");
185
+ }
186
+
187
+ /**
188
+ * Map a lifecycle response/error to a user-facing Telegram message (G010).
189
+ *
190
+ * Only derives text from sessionId, mode, reason, a safe message, and candidate
191
+ * {sessionId,path} — never a token or prompt. Each error reason gets tailored,
192
+ * actionable copy; an "in progress" pending response is surfaced distinctly.
193
+ */
194
+ export function formatLifecycleOutcome(r: SessionLifecycleResponse): string {
195
+ switch (r.type) {
196
+ case "session_create_response":
197
+ return `\u{1f680} Launching session ${r.sessionId} in tmux. It will appear once ready \u2014 check /session_recent.`;
198
+ case "session_close_response":
199
+ return `\u2705 Closed session ${r.sessionId} (history preserved \u2014 you can resume it later).`;
200
+ case "session_resume_response":
201
+ return r.mode === "reattached"
202
+ ? `\u2705 Reattached to live session ${r.sessionId}.`
203
+ : `\u{1f680} Cold-restarting session ${r.sessionId} from saved history in tmux \u2014 check /session_recent.`;
204
+ case "session_lifecycle_error":
205
+ break;
206
+ default:
207
+ return "Unknown lifecycle response.";
208
+ }
209
+ if (r.reason === "ambiguous_target" && r.candidates?.length) {
210
+ const list = r.candidates.map(c => `\u2022 ${c.sessionId}${c.path ? ` (${c.path})` : ""}`).join("\n");
211
+ return `\u2753 Multiple sessions match \u2014 reply with the exact id:\n${list}`;
212
+ }
213
+ switch (r.reason) {
214
+ case "unauthorized":
215
+ return "\u26d4 Not authorized for session lifecycle commands.";
216
+ case "rate_limited":
217
+ return "\u23f3 Too many create requests \u2014 please wait a bit and try again.";
218
+ case "duplicate_conflict":
219
+ return "\u26a0\ufe0f That command id was already used for a different request; send a fresh command.";
220
+ case "invalid_target":
221
+ return `\u26a0\ufe0f Invalid target. ${r.message}`;
222
+ case "spawn_failed":
223
+ return "\u26a0\ufe0f The session failed to start. Nothing was left running.";
224
+ case "discovery_timeout":
225
+ case "readiness_timeout":
226
+ return "\u23f3 The session did not become ready in time. It may still be starting \u2014 check /session_recent.";
227
+ case "close_refused":
228
+ return "\u26a0\ufe0f Close refused: that session is not SKC-managed or did not match.";
229
+ case "not_found":
230
+ return "\u2753 No matching session was found.";
231
+ case "terminal_uncertain":
232
+ return /in progress/i.test(r.message)
233
+ ? "\u23f3 That request is already in progress \u2014 hold on."
234
+ : "\u26a0\ufe0f Outcome uncertain. Check /session_recent before retrying so you don't double-spawn.";
235
+ default:
236
+ return `\u26a0\ufe0f ${r.reason}: ${r.message}`;
237
+ }
238
+ }
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Wires the authenticated Rust control endpoint (NotificationControlServer) to
3
+ * the lifecycle orchestrator with REAL daemon-side effects: a daemon-safe tmux
4
+ * launcher (create / cold-restart), force-close, and reattach-or-cold-restart
5
+ * resume. Kept separate from telegram-daemon.ts so the effects + wiring are
6
+ * unit-testable; the daemon calls {@link attachLifecycleControl} once it owns
7
+ * the control server.
8
+ */
9
+ import * as crypto from "node:crypto";
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+
13
+ import {
14
+ buildSkcTmuxExactOptionTarget,
15
+ buildSkcTmuxProfileCommands,
16
+ resolveSkcTmuxCommand,
17
+ } from "../skc-runtime/tmux-common";
18
+ import {
19
+ findSkcTmuxSessionByName,
20
+ forceCloseSkcTmuxSession,
21
+ listSkcTmuxSessions,
22
+ statusSkcTmuxSession,
23
+ } from "../skc-runtime/tmux-sessions";
24
+ import type { ResumeCandidate, SessionCreateFrame, SessionLifecycleRequest, SessionLifecycleResponse } from "./index";
25
+ import { normalizeLifecyclePath } from "./lifecycle-commands";
26
+ import {
27
+ type AuditEvent,
28
+ type CreateEffectResult,
29
+ handleLifecycleRequest,
30
+ type LedgerDoc,
31
+ type LedgerStore,
32
+ type LifecycleOutcome,
33
+ type OrchestratorDeps,
34
+ type ResumeEffectResult,
35
+ } from "./lifecycle-orchestrator";
36
+ import { listRecentSessions } from "./recent-activity";
37
+
38
+ /** Minimal view of the native control server this runtime depends on. */
39
+ export interface ControlServerLike {
40
+ onLifecycleRequest(
41
+ cb: (err: Error | null, req: { kind: string; requestId: string; payloadJson: string }) => void,
42
+ ): void;
43
+ respond(responseJson: string): void;
44
+ }
45
+
46
+ /**
47
+ * A startable control server (the native NotificationControlServer, or a fake in
48
+ * tests). Extends {@link ControlServerLike} with the start/stop lifecycle the
49
+ * daemon owns.
50
+ */
51
+ export interface LifecycleControlServer extends ControlServerLike {
52
+ start(): Promise<unknown>;
53
+ stop(): void;
54
+ }
55
+
56
+ /** Factory the daemon uses to construct a control server bound to its ownership. */
57
+ export type LifecycleControlServerFactory = (input: {
58
+ token: string;
59
+ ownerId: string;
60
+ agentDir: string;
61
+ }) => LifecycleControlServer;
62
+
63
+ /** Atomic + fsynced file-backed idempotency ledger store. */
64
+ export function fileLedgerStore(idempotencyFile: string): LedgerStore {
65
+ return {
66
+ async read(): Promise<LedgerDoc> {
67
+ try {
68
+ return JSON.parse(fs.readFileSync(idempotencyFile, "utf8")) as LedgerDoc;
69
+ } catch {
70
+ return { version: 1, entries: {} };
71
+ }
72
+ },
73
+ async write(doc: LedgerDoc): Promise<void> {
74
+ fs.mkdirSync(path.dirname(idempotencyFile), { recursive: true });
75
+ const tmp = `${idempotencyFile}.${process.pid}.${Date.now()}.tmp`;
76
+ const fd = fs.openSync(tmp, "w", 0o600);
77
+ fs.writeSync(fd, JSON.stringify(doc));
78
+ fs.fsyncSync(fd);
79
+ fs.closeSync(fd);
80
+ fs.renameSync(tmp, idempotencyFile);
81
+ // fsync the parent directory so the rename itself is durable across a
82
+ // crash / power loss (the temp-file fsync alone does not persist the
83
+ // directory entry).
84
+ try {
85
+ const dirFd = fs.openSync(path.dirname(idempotencyFile), "r");
86
+ try {
87
+ fs.fsyncSync(dirFd);
88
+ } finally {
89
+ fs.closeSync(dirFd);
90
+ }
91
+ } catch {
92
+ // Some platforms reject directory fsync; the rename is still atomic.
93
+ }
94
+ },
95
+ };
96
+ }
97
+
98
+ /** Append-only JSONL audit sink (0600). Never receives tokens or raw prompts. */
99
+ export function fileAudit(auditPath: string): (e: AuditEvent) => void {
100
+ return (e: AuditEvent) => {
101
+ fs.mkdirSync(path.dirname(auditPath), { recursive: true });
102
+ fs.appendFileSync(auditPath, `${JSON.stringify(e)}\n`, { mode: 0o600 });
103
+ };
104
+ }
105
+
106
+ /** Simple per-chat sliding-window create rate limiter. */
107
+ export function createRateLimiter(maxPerWindow: number, windowMs: number): (chatId: string, nowMs: number) => boolean {
108
+ const hits = new Map<string, number[]>();
109
+ return (chatId: string, nowMs: number) => {
110
+ const arr = (hits.get(chatId) ?? []).filter(t => nowMs - t < windowMs);
111
+ if (arr.length >= maxPerWindow) {
112
+ hits.set(chatId, arr);
113
+ return false;
114
+ }
115
+ arr.push(nowMs);
116
+ hits.set(chatId, arr);
117
+ return true;
118
+ };
119
+ }
120
+
121
+ function tmuxSessionNameFor(sessionId: string): string {
122
+ return `skc_lc_${sessionId}`;
123
+ }
124
+
125
+ /** Build the `skc` argv for a create target (existing path / worktree / dir).
126
+ *
127
+ * The launched session id is carried via `SKC_SESSION_ID` in the child env (see
128
+ * {@link daemonSpawnCreate}); the root `skc` launcher has no `--session-id`
129
+ * flag, so it must never appear in argv. Only flags the launch parser actually
130
+ * supports are emitted (`--worktree <branch>` for worktree targets). */
131
+ export function buildCreateArgv(
132
+ frame: SessionCreateFrame,
133
+ _ids: { intendedSessionId: string; startupPromptRef?: string },
134
+ ): { cwd: string; args: string[] } {
135
+ if (frame.target.kind === "worktree") {
136
+ const cwd = normalizeLifecyclePath(frame.target.repo);
137
+ if (!cwd) throw new Error("invalid_lifecycle_repo_path");
138
+ // Use the `--worktree=<branch>` form so the branch is a single argv token:
139
+ // a flag-shaped branch (e.g. `-x`) can never be mis-parsed as a separate
140
+ // launcher flag / detached-mode trigger.
141
+ return { cwd, args: [`--worktree=${frame.target.branch}`] };
142
+ }
143
+ const cwd = normalizeLifecyclePath(frame.target.path);
144
+ if (!cwd) throw new Error("invalid_lifecycle_path");
145
+ return { cwd, args: [] };
146
+ }
147
+
148
+ /** Real daemon-safe tmux launcher: detached `tmux new-session -d` + SKC tags. */
149
+ export function daemonSpawnCreate(env: NodeJS.ProcessEnv = process.env) {
150
+ return async (
151
+ frame: SessionCreateFrame,
152
+ ids: { lifecycleRequestId: string; intendedSessionId: string; startupPromptRef?: string },
153
+ ): Promise<CreateEffectResult> => {
154
+ const tmux = resolveSkcTmuxCommand(env);
155
+ const name = tmuxSessionNameFor(ids.intendedSessionId);
156
+ const { cwd, args } = buildCreateArgv(frame, ids);
157
+ // A `plain_dir` target is a NEW working directory: create it before spawn
158
+ // so `/session_create dir <newdir>` works as documented.
159
+ if (frame.target.kind === "plain_dir") {
160
+ fs.mkdirSync(cwd, { recursive: true });
161
+ }
162
+ // Detached: no interactive TTY needed (daemon-safe).
163
+ const childEnv: Record<string, string> = {
164
+ SKC_TMUX_LAUNCHED: "1",
165
+ SKC_NOTIFICATIONS: "1",
166
+ SKC_SESSION_ID: ids.intendedSessionId,
167
+ SKC_LIFECYCLE_REQUEST_ID: ids.lifecycleRequestId,
168
+ };
169
+ if (ids.startupPromptRef) childEnv.SKC_STARTUP_PROMPT_REF = ids.startupPromptRef;
170
+ const envPairs = Object.entries(childEnv)
171
+ .map(([k, v]) => `${k}=${shellQuote(v)}`)
172
+ .join(" ");
173
+ const command = `cd ${shellQuote(cwd)} && exec env ${envPairs} skc ${args.map(shellQuote).join(" ")}`;
174
+ const created = Bun.spawnSync([tmux, "new-session", "-d", "-s", name, "sh", "-c", command], {
175
+ stdout: "pipe",
176
+ stderr: "pipe",
177
+ env,
178
+ });
179
+ if (created.exitCode !== 0) {
180
+ throw new Error(created.stderr.toString().trim() || "skc_lifecycle_spawn_failed");
181
+ }
182
+ const target = buildSkcTmuxExactOptionTarget(name);
183
+ const metaCommands = buildSkcTmuxProfileCommands(target, env, {
184
+ sessionId: ids.intendedSessionId,
185
+ project: cwd,
186
+ });
187
+ for (const cmd of metaCommands) {
188
+ Bun.spawnSync([tmux, ...cmd.args], { stdout: "pipe", stderr: "pipe", env });
189
+ }
190
+ const status = statusSkcTmuxSession(name, env);
191
+ return {
192
+ sessionId: ids.intendedSessionId,
193
+ tmuxSession: name,
194
+ sessionStateFile: status.sessionStateFile,
195
+ endpointUrl: "",
196
+ topicThreadId: "",
197
+ };
198
+ };
199
+ }
200
+
201
+ /** Real force-close effect (SKC-managed only, id-matched). */
202
+ export function daemonCloseSession(env: NodeJS.ProcessEnv = process.env) {
203
+ return async (target: { sessionId: string; tmuxSession?: string; sessionStateFile?: string }) => {
204
+ const name = target.tmuxSession ?? tmuxSessionNameFor(target.sessionId);
205
+ forceCloseSkcTmuxSession(name, env, target.sessionId, target.sessionStateFile);
206
+ return { processGone: findSkcTmuxSessionByName(name, env) === undefined };
207
+ };
208
+ }
209
+
210
+ /** Real resume effect: reattach if a live SKC session matches; else resolve the
211
+ * prefix against saved history and fail closed (`ambiguous`/`notFound`) before
212
+ * cold-restarting exactly one resolved session via the daemon-safe launcher. */
213
+ export function daemonResumeSession(env: NodeJS.ProcessEnv = process.env, opts: { sessionsRoot?: string } = {}) {
214
+ return async (target: {
215
+ sessionIdOrPrefix: string;
216
+ path?: string;
217
+ }): Promise<ResumeEffectResult | { ambiguous: ResumeCandidate[] } | { notFound: true }> => {
218
+ const live = listSkcTmuxSessions(env).filter(
219
+ s => s.sessionId === target.sessionIdOrPrefix || s.sessionId?.startsWith(target.sessionIdOrPrefix),
220
+ );
221
+ if (live.length > 1) {
222
+ return {
223
+ ambiguous: live.map(s => ({ sessionId: s.sessionId ?? s.name, path: s.project })),
224
+ };
225
+ }
226
+ if (live.length === 1) {
227
+ const s = live[0]!;
228
+ return {
229
+ sessionId: s.sessionId ?? s.name,
230
+ tmuxSession: s.name,
231
+ sessionStateFile: s.sessionStateFile,
232
+ endpointUrl: "",
233
+ topicThreadId: "",
234
+ mode: "reattached",
235
+ };
236
+ }
237
+ // Dead: resolve the id/prefix against saved session history BEFORE cold
238
+ // restart, so an unknown or ambiguous prefix fails closed instead of
239
+ // blindly spawning `skc --resume <prefix>` against a non-authoritative id.
240
+ let resumeId = target.sessionIdOrPrefix;
241
+ if (opts.sessionsRoot) {
242
+ const saved = listRecentSessions({ sessionsRoot: opts.sessionsRoot, limit: 1000 });
243
+ const prefixed = saved.filter(
244
+ s => s.sessionId === target.sessionIdOrPrefix || s.sessionId.startsWith(target.sessionIdOrPrefix),
245
+ );
246
+ const exact = prefixed.filter(s => s.sessionId === target.sessionIdOrPrefix);
247
+ const resolved = exact.length > 0 ? exact : prefixed;
248
+ if (resolved.length === 0) return { notFound: true };
249
+ if (resolved.length > 1) {
250
+ return { ambiguous: resolved.map(s => ({ sessionId: s.sessionId, path: s.path })) };
251
+ }
252
+ resumeId = resolved[0]!.sessionId;
253
+ }
254
+ const tmux = resolveSkcTmuxCommand(env);
255
+ const name = tmuxSessionNameFor(resumeId);
256
+ const command = `exec env SKC_TMUX_LAUNCHED=1 SKC_NOTIFICATIONS=1 skc --resume ${shellQuote(resumeId)}`;
257
+ const r = Bun.spawnSync([tmux, "new-session", "-d", "-s", name, "sh", "-c", command], {
258
+ stdout: "pipe",
259
+ stderr: "pipe",
260
+ env,
261
+ });
262
+ if (r.exitCode !== 0) throw new Error(r.stderr.toString().trim() || "skc_lifecycle_resume_failed");
263
+ const tgt = buildSkcTmuxExactOptionTarget(name);
264
+ for (const cmd of buildSkcTmuxProfileCommands(tgt, env, { sessionId: resumeId })) {
265
+ Bun.spawnSync([tmux, ...cmd.args], { stdout: "pipe", stderr: "pipe", env });
266
+ }
267
+ return {
268
+ sessionId: resumeId,
269
+ tmuxSession: name,
270
+ endpointUrl: "",
271
+ topicThreadId: "",
272
+ mode: "cold_restarted",
273
+ };
274
+ };
275
+ }
276
+
277
+ function shellQuote(value: string): string {
278
+ return `'${value.replaceAll("'", "'\\''")}'`;
279
+ }
280
+
281
+ /** Translate an orchestrator outcome into a wire response frame. */
282
+ export function outcomeToResponse(frame: SessionLifecycleRequest, outcome: LifecycleOutcome): SessionLifecycleResponse {
283
+ if (outcome.status === "error" || outcome.status === "pending") {
284
+ const reason = outcome.status === "pending" ? "terminal_uncertain" : outcome.reason;
285
+ return {
286
+ type: "session_lifecycle_error",
287
+ requestId: frame.requestId,
288
+ status: "error",
289
+ reason,
290
+ message: outcome.status === "pending" ? "request already in progress" : outcome.message,
291
+ ...(outcome.status === "error" && outcome.candidates ? { candidates: outcome.candidates } : {}),
292
+ };
293
+ }
294
+ const e = outcome.entry;
295
+ if (frame.type === "session_create") {
296
+ return {
297
+ type: "session_create_response",
298
+ requestId: frame.requestId,
299
+ status: "ok",
300
+ lifecycleRequestId: frame.lifecycleRequestId,
301
+ sessionId: e.sessionId ?? e.intendedSessionId ?? "",
302
+ matchedBy: "spawn_marker",
303
+ endpoint: { url: e.endpointUrl ?? "", token: "" },
304
+ topic: { chatId: frame.chatId, threadId: "" },
305
+ target: frame.target,
306
+ };
307
+ }
308
+ if (frame.type === "session_close") {
309
+ return {
310
+ type: "session_close_response",
311
+ requestId: frame.requestId,
312
+ status: "ok",
313
+ sessionId: e.sessionId ?? "",
314
+ processGone: e.processGone ?? false,
315
+ historyPreserved: true,
316
+ // The killed session's per-session endpoint record is reaped by the
317
+ // daemon's dead-PID scan (scanRoots), so it is effectively stale.
318
+ endpointStale: e.processGone ?? false,
319
+ };
320
+ }
321
+ return {
322
+ type: "session_resume_response",
323
+ requestId: frame.requestId,
324
+ status: "ok",
325
+ sessionId: e.sessionId ?? "",
326
+ mode: outcome.mode ?? "reattached",
327
+ endpoint: { url: e.endpointUrl ?? "", token: "" },
328
+ topic: { chatId: frame.chatId, threadId: "" },
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Wire a control server's lifecycle requests through the orchestrator.
334
+ *
335
+ * Handlers run on a single serial queue (a promise chain): the daemon owns the
336
+ * one control endpoint, so serializing here makes each request's ledger
337
+ * read -> classify -> write atomic with respect to every other request. Two
338
+ * identical updates that arrive nearly simultaneously can no longer both
339
+ * classify as `new` and both spawn — the second sees the first's persisted
340
+ * `in_progress`/`success` entry and re-acks instead.
341
+ */
342
+ export function attachLifecycleControl(server: ControlServerLike, deps: OrchestratorDeps): void {
343
+ let queue: Promise<void> = Promise.resolve();
344
+ server.onLifecycleRequest((err, req) => {
345
+ if (err) return;
346
+ let frame: SessionLifecycleRequest;
347
+ try {
348
+ frame = JSON.parse(req.payloadJson) as SessionLifecycleRequest;
349
+ } catch {
350
+ return;
351
+ }
352
+ queue = queue
353
+ .then(async () => {
354
+ const outcome = await handleLifecycleRequest(frame, deps);
355
+ server.respond(JSON.stringify(outcomeToResponse(frame, outcome)));
356
+ })
357
+ .catch(() => {
358
+ // A handler failure must not break the queue for later requests.
359
+ });
360
+ });
361
+ }
362
+
363
+ /** Assemble real orchestrator deps for the daemon (ledger/audit under agentDir). */
364
+ export function buildOrchestratorDeps(input: {
365
+ pairedChatId: string;
366
+ agentNotificationsDir: string;
367
+ /** Root of saved session histories (`<agentDir>/sessions`), for resume resolution. */
368
+ sessionsRoot?: string;
369
+ env?: NodeJS.ProcessEnv;
370
+ }): OrchestratorDeps {
371
+ const env = input.env ?? process.env;
372
+ return {
373
+ pairedChatId: input.pairedChatId,
374
+ now: () => Date.now(),
375
+ store: fileLedgerStore(path.join(input.agentNotificationsDir, "telegram-lifecycle-idempotency.json")),
376
+ audit: fileAudit(path.join(input.agentNotificationsDir, "telegram-lifecycle-audit.jsonl")),
377
+ allowCreate: createRateLimiter(3, 10 * 60 * 1000),
378
+ writeStartupPrompt: async (requestId, prompt) => {
379
+ if (prompt === undefined) return undefined;
380
+ const ref = path.join(input.agentNotificationsDir, `startup-prompt-${requestId}`);
381
+ fs.mkdirSync(path.dirname(ref), { recursive: true });
382
+ const fd = fs.openSync(ref, "w", 0o600);
383
+ fs.writeSync(fd, prompt);
384
+ fs.fsyncSync(fd);
385
+ fs.closeSync(fd);
386
+ return ref;
387
+ },
388
+ spawnCreate: daemonSpawnCreate(env),
389
+ closeSession: daemonCloseSession(env),
390
+ resumeSession: daemonResumeSession(env, { sessionsRoot: input.sessionsRoot }),
391
+ newLifecycleRequestId: () => `lc-${crypto.randomUUID()}`,
392
+ newSessionId: () => `s${crypto.randomUUID().slice(0, 8)}`,
393
+ };
394
+ }
395
+
396
+ /**
397
+ * Default production factory: a real native NotificationControlServer bound to
398
+ * the daemon's control token, owner id, and agent dir.
399
+ */
400
+ export const createNativeControlServer: LifecycleControlServerFactory = ({ token, ownerId, agentDir }) => {
401
+ // Lazy require so loading this module (for the orchestrator / wiring / tests)
402
+ // never eagerly resolves the native addon — only a real production start does.
403
+ const { NotificationControlServer } = require("@sayknow-cli/natives") as typeof import("@sayknow-cli/natives");
404
+ return new NotificationControlServer(token, ownerId, agentDir) as unknown as LifecycleControlServer;
405
+ };