@sayknow-cli/coding-agent 0.3.1 → 0.3.4

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 (216) 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/plugin-cli.d.ts +2 -0
  4. package/dist/types/cli.d.ts +6 -0
  5. package/dist/types/commands/mcp.d.ts +70 -0
  6. package/dist/types/commands/plugin.d.ts +6 -0
  7. package/dist/types/commands/session.d.ts +6 -0
  8. package/dist/types/config/keybindings.d.ts +2 -2
  9. package/dist/types/config/model-profile-activation.d.ts +8 -1
  10. package/dist/types/config/model-profiles.d.ts +2 -2
  11. package/dist/types/config/model-registry.d.ts +3 -3
  12. package/dist/types/config/models-config-schema.d.ts +0 -5
  13. package/dist/types/config/settings-schema.d.ts +79 -68
  14. package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
  15. package/dist/types/export/html/template.generated.d.ts +1 -1
  16. package/dist/types/extensibility/skc-plugins/compiler.d.ts +19 -0
  17. package/dist/types/extensibility/skc-plugins/constrained-hooks.d.ts +29 -0
  18. package/dist/types/extensibility/skc-plugins/index.d.ts +9 -0
  19. package/dist/types/extensibility/skc-plugins/injection.d.ts +9 -0
  20. package/dist/types/extensibility/skc-plugins/installer.d.ts +13 -0
  21. package/dist/types/extensibility/skc-plugins/mcp-policy.d.ts +26 -0
  22. package/dist/types/extensibility/skc-plugins/observability.d.ts +27 -0
  23. package/dist/types/extensibility/skc-plugins/prompt-appendix.d.ts +16 -0
  24. package/dist/types/extensibility/skc-plugins/registry.d.ts +32 -0
  25. package/dist/types/extensibility/skc-plugins/runtime-adapters.d.ts +64 -0
  26. package/dist/types/extensibility/skc-plugins/session-validation.d.ts +42 -0
  27. package/dist/types/extensibility/skc-plugins/types.d.ts +158 -2
  28. package/dist/types/extensibility/skc-plugins/validation.d.ts +8 -1
  29. package/dist/types/i18n/messages/en.d.ts +0 -1
  30. package/dist/types/main.d.ts +2 -0
  31. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  32. package/dist/types/modes/components/model-selector.d.ts +8 -0
  33. package/dist/types/modes/components/settings-defs.d.ts +0 -1
  34. package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
  35. package/dist/types/modes/theme/defaults/index.d.ts +99 -0
  36. package/dist/types/modes/theme/theme.d.ts +1 -1
  37. package/dist/types/notifications/html-format.d.ts +11 -0
  38. package/dist/types/notifications/index.d.ts +149 -1
  39. package/dist/types/notifications/lifecycle-commands.d.ts +60 -0
  40. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  41. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  42. package/dist/types/notifications/operator-runtime.d.ts +52 -0
  43. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  44. package/dist/types/notifications/recent-activity.d.ts +35 -0
  45. package/dist/types/notifications/telegram-daemon.d.ts +114 -16
  46. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  47. package/dist/types/notifications/topic-registry.d.ts +12 -9
  48. package/dist/types/runtime-mcp/types.d.ts +7 -0
  49. package/dist/types/sdk.d.ts +2 -0
  50. package/dist/types/session/agent-session.d.ts +14 -4
  51. package/dist/types/session/blob-store.d.ts +25 -0
  52. package/dist/types/session/session-manager.d.ts +57 -0
  53. package/dist/types/skc-runtime/launch-tmux.d.ts +1 -0
  54. package/dist/types/skc-runtime/psmux-detect.d.ts +78 -0
  55. package/dist/types/skc-runtime/state-renderer.d.ts +5 -0
  56. package/dist/types/skc-runtime/team-runtime.d.ts +2 -0
  57. package/dist/types/skc-runtime/tmux-common.d.ts +30 -2
  58. package/dist/types/skc-runtime/tmux-sessions.d.ts +18 -0
  59. package/dist/types/skc-runtime/ultragoal-guard.d.ts +37 -1
  60. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +80 -0
  61. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  62. package/dist/types/system-prompt.d.ts +2 -0
  63. package/dist/types/task/executor.d.ts +9 -1
  64. package/dist/types/tools/browser/tab-supervisor.d.ts +31 -0
  65. package/dist/types/tools/composer-bash-policy.d.ts +14 -0
  66. package/dist/types/tools/computer-gc.d.ts +23 -0
  67. package/dist/types/tools/cron.d.ts +36 -61
  68. package/dist/types/tools/index.d.ts +3 -2
  69. package/dist/types/tools/resource-gc.d.ts +54 -0
  70. package/dist/types/utils/changelog.d.ts +1 -0
  71. package/dist/types/web/insane/url-guard.d.ts +6 -3
  72. package/dist/types/web/scrapers/types.d.ts +5 -0
  73. package/dist/types/web/scrapers/utils.d.ts +7 -1
  74. package/dist/types/web/search/index.d.ts +1 -0
  75. package/dist/types/web/search/providers/utils.d.ts +11 -4
  76. package/package.json +11 -9
  77. package/scripts/g004-tmux-smoke.ts +100 -0
  78. package/scripts/g005-daemon-smoke.ts +180 -0
  79. package/scripts/g011-daemon-path-smoke.ts +153 -0
  80. package/src/cli/args.ts +0 -1
  81. package/src/cli/fast-help.ts +29 -2
  82. package/src/cli/mcp-cli.ts +272 -0
  83. package/src/cli/plugin-cli.ts +66 -3
  84. package/src/cli/web-search-cli.ts +5 -0
  85. package/src/cli.ts +30 -12
  86. package/src/commands/mcp.ts +117 -0
  87. package/src/commands/plugin.ts +4 -0
  88. package/src/commands/session.ts +18 -0
  89. package/src/config/keybindings.ts +2 -2
  90. package/src/config/model-profile-activation.ts +62 -8
  91. package/src/config/model-profiles.ts +3 -4
  92. package/src/config/model-registry.ts +3 -6
  93. package/src/config/models-config-schema.ts +1 -1
  94. package/src/config/settings-schema.ts +88 -103
  95. package/src/deep-interview/plaintext-gate-guard.ts +94 -0
  96. package/src/defaults/skc/extensions/grok-cli-vendor/biome.json +1 -1
  97. package/src/defaults/skc/skills/deep-interview/SKILL.md +7 -6
  98. package/src/defaults/skc/skills/team/SKILL.md +5 -3
  99. package/src/defaults/skc/skills/ultragoal/SKILL.md +41 -13
  100. package/src/export/html/index.ts +2 -2
  101. package/src/export/html/template.generated.ts +1 -1
  102. package/src/export/html/template.js +0 -12
  103. package/src/extensibility/extensions/runner.ts +1 -0
  104. package/src/extensibility/skc-plugins/compiler.ts +351 -0
  105. package/src/extensibility/skc-plugins/constrained-hooks.ts +170 -0
  106. package/src/extensibility/skc-plugins/index.ts +9 -0
  107. package/src/extensibility/skc-plugins/injection.ts +109 -0
  108. package/src/extensibility/skc-plugins/installer.ts +434 -0
  109. package/src/extensibility/skc-plugins/loader.ts +3 -1
  110. package/src/extensibility/skc-plugins/mcp-policy.ts +239 -0
  111. package/src/extensibility/skc-plugins/observability.ts +84 -0
  112. package/src/extensibility/skc-plugins/paths.ts +1 -1
  113. package/src/extensibility/skc-plugins/prompt-appendix.ts +109 -0
  114. package/src/extensibility/skc-plugins/registry.ts +180 -0
  115. package/src/extensibility/skc-plugins/runtime-adapters.ts +234 -0
  116. package/src/extensibility/skc-plugins/schema.ts +250 -20
  117. package/src/extensibility/skc-plugins/session-validation.ts +147 -0
  118. package/src/extensibility/skc-plugins/types.ts +199 -3
  119. package/src/extensibility/skc-plugins/validation.ts +80 -0
  120. package/src/extensibility/skills.ts +15 -0
  121. package/src/goals/tools/goal-tool.ts +14 -1
  122. package/src/hooks/skill-state.ts +57 -0
  123. package/src/i18n/messages/de.ts +0 -1
  124. package/src/i18n/messages/en.ts +0 -1
  125. package/src/i18n/messages/es.ts +0 -1
  126. package/src/i18n/messages/fr.ts +0 -1
  127. package/src/i18n/messages/ja.ts +0 -1
  128. package/src/i18n/messages/ko.ts +0 -1
  129. package/src/i18n/messages/zh.ts +0 -1
  130. package/src/internal-urls/docs-index.generated.ts +14 -11
  131. package/src/main.ts +14 -3
  132. package/src/modes/bridge/bridge-mode.ts +11 -0
  133. package/src/modes/components/assistant-message.ts +49 -1
  134. package/src/modes/components/custom-editor.ts +2 -0
  135. package/src/modes/components/footer.ts +2 -3
  136. package/src/modes/components/hook-editor.ts +1 -1
  137. package/src/modes/components/hook-selector.ts +67 -43
  138. package/src/modes/components/model-selector.ts +65 -12
  139. package/src/modes/components/settings-defs.ts +1 -2
  140. package/src/modes/components/settings-selector.ts +2 -7
  141. package/src/modes/components/status-line/git-utils.ts +25 -0
  142. package/src/modes/components/status-line.ts +10 -11
  143. package/src/modes/components/welcome.ts +2 -3
  144. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  145. package/src/modes/controllers/selector-controller.ts +59 -11
  146. package/src/modes/interactive-mode.ts +4 -1
  147. package/src/modes/shared/agent-wire/scopes.ts +1 -1
  148. package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
  149. package/src/modes/theme/defaults/index.ts +2 -0
  150. package/src/modes/theme/theme.ts +0 -4
  151. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  152. package/src/notifications/html-format.ts +38 -0
  153. package/src/notifications/index.ts +242 -12
  154. package/src/notifications/lifecycle-commands.ts +238 -0
  155. package/src/notifications/lifecycle-control-runtime.ts +405 -0
  156. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  157. package/src/notifications/operator-runtime.ts +171 -0
  158. package/src/notifications/rate-limit-pool.ts +19 -0
  159. package/src/notifications/recent-activity.ts +132 -0
  160. package/src/notifications/telegram-daemon.ts +778 -257
  161. package/src/notifications/telegram-reference.ts +25 -7
  162. package/src/notifications/topic-registry.ts +23 -9
  163. package/src/prompts/agents/executor.md +2 -2
  164. package/src/prompts/system/system-prompt.md +2 -2
  165. package/src/prompts/tools/cron.md +5 -3
  166. package/src/prompts/tools/read.md +1 -1
  167. package/src/runtime-mcp/transports/stdio.ts +38 -4
  168. package/src/runtime-mcp/types.ts +7 -0
  169. package/src/sdk.ts +162 -10
  170. package/src/session/agent-session.ts +210 -74
  171. package/src/session/blob-store.ts +196 -8
  172. package/src/session/session-manager.ts +762 -12
  173. package/src/skc-runtime/launch-tmux.ts +68 -19
  174. package/src/skc-runtime/psmux-detect.ts +239 -0
  175. package/src/skc-runtime/state-renderer.ts +13 -0
  176. package/src/skc-runtime/team-runtime.ts +56 -23
  177. package/src/skc-runtime/tmux-common.ts +88 -4
  178. package/src/skc-runtime/tmux-sessions.ts +111 -9
  179. package/src/skc-runtime/ultragoal-guard.ts +192 -8
  180. package/src/skc-runtime/ultragoal-runtime.ts +296 -16
  181. package/src/slash-commands/builtin-registry.ts +23 -3
  182. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  183. package/src/slash-commands/helpers/parse.ts +2 -1
  184. package/src/system-prompt.ts +9 -0
  185. package/src/task/executor.ts +31 -7
  186. package/src/task/index.ts +2 -0
  187. package/src/tools/ask.ts +5 -1
  188. package/src/tools/bash.ts +9 -0
  189. package/src/tools/browser/tab-supervisor.ts +86 -2
  190. package/src/tools/composer-bash-policy.ts +96 -0
  191. package/src/tools/computer-gc.ts +66 -0
  192. package/src/tools/computer.ts +2 -0
  193. package/src/tools/cron.ts +75 -112
  194. package/src/tools/fetch.ts +18 -2
  195. package/src/tools/index.ts +5 -9
  196. package/src/tools/read.ts +25 -55
  197. package/src/tools/renderers.ts +0 -2
  198. package/src/tools/resource-gc.ts +291 -0
  199. package/src/tools/ultragoal-ask-guard.ts +7 -1
  200. package/src/utils/changelog.ts +8 -0
  201. package/src/web/insane/url-guard.ts +18 -14
  202. package/src/web/scrapers/types.ts +143 -45
  203. package/src/web/scrapers/utils.ts +70 -19
  204. package/src/web/search/index.ts +1 -0
  205. package/src/web/search/providers/utils.ts +22 -5
  206. package/vendor/insane-search/MANIFEST.json +3 -1
  207. package/vendor/insane-search/engine/__init__.py +14 -0
  208. package/vendor/insane-search/engine/content_safety.py +151 -0
  209. package/vendor/insane-search/engine/fetch_chain.py +32 -0
  210. package/vendor/insane-search/engine/tests/test_u8.py +216 -0
  211. package/dist/types/tools/inspect-image-renderer.d.ts +0 -26
  212. package/dist/types/tools/inspect-image.d.ts +0 -31
  213. package/src/prompts/tools/inspect-image-system.md +0 -20
  214. package/src/prompts/tools/inspect-image.md +0 -32
  215. package/src/tools/inspect-image-renderer.ts +0 -103
  216. package/src/tools/inspect-image.ts +0 -172
@@ -1,3 +1,6 @@
1
+ import type { ResolvedTmuxBinary } from "./psmux-detect";
2
+ import { resolveSkcTmuxBinary } from "./psmux-detect";
3
+
1
4
  export const SKC_DEFAULT_TMUX_SESSION = "sayknow_cli";
2
5
  export const SKC_TMUX_SESSION_PREFIX = `${SKC_DEFAULT_TMUX_SESSION}_`;
3
6
  export const SKC_TMUX_COMMAND_ENV = "SKC_TMUX_COMMAND";
@@ -11,6 +14,7 @@ export const SKC_TMUX_PROJECT_OPTION = "@skc-project";
11
14
  export const SKC_TMUX_SESSION_ID_OPTION = "@skc-session-id";
12
15
  export const SKC_TMUX_SESSION_STATE_FILE_OPTION = "@skc-session-state-file";
13
16
  export const SKC_TMUX_VERSION_OPTION = "@skc-version";
17
+ export const SKC_PSMUX_PROFILE_FORCE_ENV = "SKC_PSMUX_PROFILE_FORCE";
14
18
 
15
19
  export interface SkcTmuxProfileCommand {
16
20
  description: string;
@@ -31,10 +35,33 @@ export function envDisabled(value: string | undefined): boolean {
31
35
  return normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no";
32
36
  }
33
37
 
34
- export function resolveSkcTmuxCommand(env: NodeJS.ProcessEnv = process.env): string {
35
- return env[SKC_TMUX_COMMAND_ENV]?.trim() || env.SKC_TEAM_TMUX_COMMAND?.trim() || "tmux";
38
+ /**
39
+ * Resolve the tmux (or tmux-compatible multiplexer) command SKC should invoke.
40
+ *
41
+ * This is the shared entry point used by every SKC code path that needs to talk
42
+ * to a multiplexer: `skc --tmux` planning, `skc session ...`, `skc team ...`,
43
+ * the lifecycle controller, and the harness resident owner. Routing all of
44
+ * them through the same resolver means a single `SKC_TMUX_COMMAND` override or
45
+ * a single Windows psmux / pmux detection wins for the whole process — the
46
+ * failure mode where `skc --tmux` creates a psmux-backed session and then
47
+ * `skc session status` fails because it queries literal `tmux` is closed off.
48
+ *
49
+ * Explicit `SKC_TMUX_COMMAND` / `SKC_TEAM_TMUX_COMMAND` overrides are honored on
50
+ * every platform. On native Windows without an override the resolver walks
51
+ * `psmux`, then `pmux`, then `tmux` and uses the first binary present on PATH.
52
+ * On POSIX the resolver returns `tmux` (the historical default) and only
53
+ * falls through to the platform-aware walker if the caller opts in.
54
+ */
55
+ export function resolveSkcTmuxCommand(
56
+ env: NodeJS.ProcessEnv = process.env,
57
+ platform: NodeJS.Platform = process.platform,
58
+ ): string {
59
+ return resolveSkcTmuxBinary({ env, platform }).command;
36
60
  }
37
61
 
62
+ export type { PsmuxProbe, ResolvedTmuxBinary, ResolveSkcTmuxBinaryOptions } from "./psmux-detect";
63
+ export { clearPsmuxDetectionCache, detectPsmux, probePsmux, resolveSkcTmuxBinary } from "./psmux-detect";
64
+
38
65
  /**
39
66
  * Build the exact-session target for tmux *option* commands
40
67
  * (`show-options` / `set-option`) and `display-message -t`.
@@ -45,7 +72,17 @@ export function resolveSkcTmuxCommand(env: NodeJS.ProcessEnv = process.env): str
45
72
  * keeps the exact-session match while giving tmux the window-qualified target
46
73
  * those commands require. See sayknow-cli#580.
47
74
  */
48
- export function buildSkcTmuxExactOptionTarget(sessionName: string): string {
75
+ export function buildSkcTmuxExactOptionTarget(
76
+ sessionName: string,
77
+ opts: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; binary?: ResolvedTmuxBinary } = {},
78
+ ): string {
79
+ const binary = opts.binary ?? resolveSkcTmuxBinary({ env: opts.env, platform: opts.platform });
80
+ // psmux 3.3.0 rejects the tmux `=NAME` exact-session prefix for option
81
+ // commands ("no server running on session '=NAME'"); bare `NAME` and
82
+ // window-qualified `NAME:` both work. tmux 3.6a needs the
83
+ // window-qualified `=NAME:` to resolve the session for option
84
+ // commands (sayknow-cli#580).
85
+ if (binary.isPsmux) return sessionName;
49
86
  return `=${sessionName}:`;
50
87
  }
51
88
 
@@ -55,7 +92,9 @@ export function buildSkcTmuxUntaggedSessionHint(tmuxCommand: string): string {
55
92
  return (
56
93
  `the active multiplexer "${tmuxCommand}" lists this session but did not return SKC's ${SKC_TMUX_PROFILE_OPTION} ownership tag; ` +
57
94
  "SKC-managed sessions and `skc team` require a tmux provider that round-trips tmux user options. " +
58
- "Alternative multiplexers such as psmux on Windows do not persist user options yet, so the Windows-native psmux path is not fully supported; " +
95
+ "For psmux on Windows, cwd/start-directory flags such as `-c` do not isolate the server namespace; psmux uses the tmux-compatible global `-L <namespace>` flag for that. " +
96
+ "SKC_TMUX_COMMAND and SKC_TEAM_TMUX_COMMAND are binary overrides, not shell command lines, so `psmux -L name` is not a supported value. " +
97
+ "Alternative multiplexers such as psmux on Windows do not reliably persist user options yet, so the Windows-native psmux path is not fully supported; " +
59
98
  "use real tmux for SKC-managed session and team flows."
60
99
  );
61
100
  }
@@ -144,6 +183,16 @@ export function buildSkcTmuxRequiredProfileCommands(
144
183
  return commands;
145
184
  }
146
185
 
186
+ /**
187
+ * Keys whose set-option / set-window-option round-trip is unreliable on psmux
188
+ * 3.3.0. psmux does not support the tmux `set-window-option` command at all
189
+ * (it reports "unknown command: set-window-option") and silently drops several
190
+ * `set-option` keys. The list lives here so every code path that tags a tmux
191
+ * session (skc --tmux planning, skc session create, skc team bootstrap)
192
+ * applies the same filter.
193
+ */
194
+ const PSMUX_UNSUPPORTED_PROFILE_KEYS = new Set(["mouse", "set-clipboard", "mode-style"]);
195
+
147
196
  export function buildSkcTmuxProfileCommands(
148
197
  target: string,
149
198
  env: NodeJS.ProcessEnv = process.env,
@@ -155,6 +204,7 @@ export function buildSkcTmuxProfileCommands(
155
204
  sessionStateFile?: string | null;
156
205
  version?: string | null;
157
206
  } = {},
207
+ opts: { platform?: NodeJS.Platform; tmuxCommand?: string } = {},
158
208
  ): SkcTmuxProfileCommand[] {
159
209
  const commands = buildSkcTmuxRequiredProfileCommands(target, metadata);
160
210
  if (envDisabled(env[SKC_TMUX_PROFILE_ENV])) return commands;
@@ -170,6 +220,40 @@ export function buildSkcTmuxProfileCommands(
170
220
  description: "enable tmux mouse scrolling",
171
221
  args: ["set-option", "-t", target, "mouse", "on"],
172
222
  });
223
+ // psmux does not implement set-window-option and historically drops
224
+ // mouse / set-clipboard / mode-style. Filter the UX profile commands
225
+ // centrally so every code path that tags a session (skc --tmux planning,
226
+ // skc session create, skc team bootstrap) drops the same set. The
227
+ // SKC_PSMUX_PROFILE_FORCE override lets the operator opt back in when
228
+ // running on a psmux build that has caught up. The ownership-tag
229
+ // round-trip (set-option @skc-*) is never filtered, since skc session /
230
+ // skc team rely on it.
231
+ // The filter is opt-in: callers that explicitly pass `opts.tmuxCommand`
232
+ // name a psmux-class multiplexer (psmux / pmux) when they want the UX
233
+ // profile filtered. Auto-detect on Windows hosts where psmux happens
234
+ // to be on PATH would silently change the test output for every caller
235
+ // that does not pin the multiplexer, so we require the caller to opt
236
+ // in by naming the multiplexer. SKC_PSMUX_PROFILE_FORCE re-enables
237
+ // the UX profile commands when a psmux build catches up.
238
+ const tmuxName = (opts.tmuxCommand ?? "").toLowerCase();
239
+ const isPsmuxClass =
240
+ tmuxName === "psmux" ||
241
+ tmuxName === "pmux" ||
242
+ tmuxName.endsWith("/psmux") ||
243
+ tmuxName.endsWith("/pmux") ||
244
+ tmuxName.endsWith("\\psmux") ||
245
+ tmuxName.endsWith("\\pmux");
246
+ const dropUx = isPsmuxClass && !envDisabled(env[SKC_PSMUX_PROFILE_FORCE_ENV]);
247
+ if (dropUx) {
248
+ return commands.filter(command => {
249
+ const flag = command.args[0];
250
+ const key = command.args[command.args.length - 2];
251
+ return !(
252
+ PSMUX_UNSUPPORTED_PROFILE_KEYS.has(String(key)) &&
253
+ (flag === "set-option" || flag === "set-window-option")
254
+ );
255
+ });
256
+ }
173
257
  return commands;
174
258
  }
175
259
 
@@ -1,3 +1,4 @@
1
+ import { resolveSkcTmuxBinary } from "./psmux-detect";
1
2
  import {
2
3
  buildSkcTmuxExactOptionTarget,
3
4
  buildSkcTmuxProfileCommands,
@@ -128,10 +129,29 @@ function runListSessions(format: string, env: NodeJS.ProcessEnv = process.env):
128
129
  }
129
130
  throw error;
130
131
  }
131
- return output
132
+ const lines = output
132
133
  .split("\n")
133
134
  .map(line => line.trim())
134
135
  .filter(Boolean);
136
+ // psmux 3.3.0 silently ignores the tmux `-F` format flag and returns its
137
+ // default `name: N windows (created ...)` shape. Detect that case and
138
+ // synthesize a tab-separated row so downstream parseSessionLine /
139
+ // hydrateSessionFromExactOptions can recover the @skc-* ownership tags
140
+ // via follow-up show-options calls. Without this fallback skc session
141
+ // list / status return an empty list on psmux even when sessions exist.
142
+ if (lines.length > 0 && !lines[0].includes("\t")) {
143
+ const binary = resolveSkcTmuxBinary({ env });
144
+ if (binary.isPsmux) {
145
+ return lines.map(line => {
146
+ const match = line.match(/^([^:]+):\s*(\d+)\s+windows?\s+\(created\s+([^)]+)\)/);
147
+ if (!match) return line;
148
+ const [, name, windows, created] = match;
149
+ const createdEpoch = String(Math.floor(new Date(`${created} UTC`).getTime() / 1000) || 0);
150
+ return [name, windows, "0", createdEpoch, "", "", "0", "", "", "", "", "", "", ""].join("\t");
151
+ });
152
+ }
153
+ }
154
+ return lines;
135
155
  }
136
156
 
137
157
  function listSessionLines(env: NodeJS.ProcessEnv = process.env): string[] {
@@ -221,7 +241,13 @@ export function statusSkcTmuxSession(sessionName: string, env: NodeJS.ProcessEnv
221
241
  export function createSkcTmuxSession(env: NodeJS.ProcessEnv = process.env): SkcTmuxSessionStatus {
222
242
  const tmuxCommand = resolveSkcTmuxCommand(env);
223
243
  const sessionName = buildSkcTmuxSessionName(env);
224
- const command = "exec env SKC_TMUX_LAUNCHED=1 skc";
244
+ // Build a shell-bootstrap command appropriate for the host shell. Psmux on
245
+ // Windows runs the new-session command through PowerShell, so we use the
246
+ // $env:VAR = ... assignment form there. POSIX keeps the historical exec
247
+ // env form so the launched skc inherits SKC_TMUX_LAUNCHED without leaking
248
+ // into the parent tmux server.
249
+ const platform = process.platform;
250
+ const command = platform === "win32" ? "$env:SKC_TMUX_LAUNCHED = '1'; skc" : "exec env SKC_TMUX_LAUNCHED=1 skc";
225
251
  const created = Bun.spawnSync([tmuxCommand, "new-session", "-d", "-s", sessionName, command], {
226
252
  stdout: "pipe",
227
253
  stderr: "pipe",
@@ -229,7 +255,14 @@ export function createSkcTmuxSession(env: NodeJS.ProcessEnv = process.env): SkcT
229
255
  });
230
256
  if (created.exitCode !== 0) throw new Error(created.stderr.toString().trim() || "skc_tmux_session_create_failed");
231
257
  try {
232
- for (const profileCommand of buildSkcTmuxProfileCommands(sessionName, env)) {
258
+ // psmux 3.3.0 rejects the tmux `=NAME` exact-session prefix for option
259
+ // commands, so the target is the bare session name on psmux and the
260
+ // window-qualified `=NAME:` on tmux. The ownership-tag round-trip
261
+ // (set-option @skc-*) is preserved on both; only the UX profile commands
262
+ // (mouse / set-clipboard / mode-style / set-window-option) get filtered
263
+ // by buildSkcTmuxProfileCommands when the active binary is psmux.
264
+ const target = buildSkcTmuxExactOptionTarget(sessionName, { env });
265
+ for (const profileCommand of buildSkcTmuxProfileCommands(target, env, {}, { tmuxCommand })) {
233
266
  runTmux(profileCommand.args, env);
234
267
  }
235
268
  } catch (error) {
@@ -240,18 +273,43 @@ export function createSkcTmuxSession(env: NodeJS.ProcessEnv = process.env): SkcT
240
273
  }
241
274
 
242
275
  function readProfileForExactTarget(sessionName: string, env: NodeJS.ProcessEnv): string {
243
- return runTmux(
244
- ["show-options", "-qv", "-t", buildSkcTmuxExactOptionTarget(sessionName), SKC_TMUX_PROFILE_OPTION],
276
+ const raw = runTmux(
277
+ ["show-options", "-qv", "-t", buildSkcTmuxExactOptionTarget(sessionName, { env }), SKC_TMUX_PROFILE_OPTION],
245
278
  env,
246
279
  ).trim();
280
+ // tmux returns just the value; psmux returns `key value`. Strip the
281
+ // leading key on psmux so the SKC_TMUX_PROFILE_VALUE equality check
282
+ // against "1" works the same on both.
283
+ if (raw && resolveSkcTmuxBinary({ env }).isPsmux) {
284
+ const tokens = raw.split(/\s+/).filter(Boolean);
285
+ return tokens[tokens.length - 1] ?? raw;
286
+ }
287
+ return raw;
247
288
  }
248
289
 
249
290
  function readExactOptionForGc(sessionName: string, option: string, env: NodeJS.ProcessEnv): string | undefined {
250
291
  try {
251
- return (
252
- runTmux(["show-options", "-qv", "-t", buildSkcTmuxExactOptionTarget(sessionName), option], env).trim() ||
253
- undefined
254
- );
292
+ const raw = runTmux(
293
+ ["show-options", "-qv", "-t", buildSkcTmuxExactOptionTarget(sessionName, { env }), option],
294
+ env,
295
+ ).trim();
296
+ if (!raw) return undefined;
297
+ // tmux returns just the option value (e.g. `1` for @skc-profile).
298
+ // psmux 3.3.0 returns `key value` (or `key "value with space"` for
299
+ // @skc-branch etc.). On psmux, parse the last token and strip any
300
+ // surrounding double quotes so both shapes resolve to the same value.
301
+ if (resolveSkcTmuxBinary({ env }).isPsmux) {
302
+ // Prefer the last whitespace-separated token. If the value is
303
+ // quoted, find the matching close-quote and slice.
304
+ const lastQuote = raw.lastIndexOf('"');
305
+ if (lastQuote > 0 && raw[lastQuote - 1] !== "\\") {
306
+ const firstQuote = raw.lastIndexOf('"', lastQuote - 1);
307
+ if (firstQuote > 0) return raw.slice(firstQuote + 1, lastQuote);
308
+ }
309
+ const tokens = raw.split(/\s+/).filter(Boolean);
310
+ return tokens[tokens.length - 1];
311
+ }
312
+ return raw;
255
313
  } catch {
256
314
  return undefined;
257
315
  }
@@ -306,6 +364,50 @@ export function removeSkcTmuxSession(sessionName: string, env: NodeJS.ProcessEnv
306
364
  return session;
307
365
  }
308
366
 
367
+ /**
368
+ * Force-close a SKC-managed tmux session, even if a live pane is attached.
369
+ *
370
+ * This is the lifecycle-control counterpart to {@link removeSkcTmuxSession}: it
371
+ * intentionally does NOT refuse live/attached panes (hard-kill is the contract),
372
+ * but it keeps every safety check so it can only ever kill a genuinely
373
+ * SKC-managed session:
374
+ * - re-reads the exact tmux profile immediately before kill (never a non-SKC
375
+ * session, even one that collides by name);
376
+ * - when `expectedSessionId` is given, requires the `@skc-session-id` tag match;
377
+ * - when `expectedStateFile` is given, requires the `@skc-session-state-file`
378
+ * tag match.
379
+ *
380
+ * Returns the prior status (for audit). Throws a tagged error otherwise:
381
+ * `skc_tmux_session_not_found`, `skc_tmux_session_not_managed`,
382
+ * `skc_tmux_session_id_mismatch`, or `skc_tmux_session_state_file_mismatch`.
383
+ */
384
+ export function forceCloseSkcTmuxSession(
385
+ sessionName: string,
386
+ env: NodeJS.ProcessEnv = process.env,
387
+ expectedSessionId?: string,
388
+ expectedStateFile?: string,
389
+ ): SkcTmuxSessionStatus {
390
+ const session = statusSkcTmuxSession(sessionName, env);
391
+ if (readProfileForExactTarget(session.name, env) !== SKC_TMUX_PROFILE_VALUE) {
392
+ throw new Error(`skc_tmux_session_not_managed:${sessionName}`);
393
+ }
394
+ if (expectedSessionId !== undefined) {
395
+ const actual = readExactOptionForGc(session.name, SKC_TMUX_SESSION_ID_OPTION, env);
396
+ if (actual !== expectedSessionId) {
397
+ throw new Error(`skc_tmux_session_id_mismatch:${sessionName}`);
398
+ }
399
+ }
400
+ if (expectedStateFile !== undefined) {
401
+ const actual = readExactOptionForGc(session.name, SKC_TMUX_SESSION_STATE_FILE_OPTION, env);
402
+ if (actual !== expectedStateFile) {
403
+ throw new Error(`skc_tmux_session_state_file_mismatch:${sessionName}`);
404
+ }
405
+ }
406
+ // Intentionally NOT refusing live/attached panes — force-close is hard-kill.
407
+ runTmux(["kill-session", "-t", `=${session.name}`], env);
408
+ return session;
409
+ }
410
+
309
411
  export function attachSkcTmuxSession(sessionName: string, env: NodeJS.ProcessEnv = process.env): never {
310
412
  const session = statusSkcTmuxSession(sessionName, env);
311
413
  const tmuxCommand = resolveSkcTmuxCommand(env);
@@ -8,9 +8,13 @@ import {
8
8
  hashStructuredValue,
9
9
  readUltragoalLedger,
10
10
  readUltragoalPlan,
11
+ recordUltragoalNudgeIfBudgetRemaining,
12
+ resolveUltragoalNudgeBudget,
13
+ selectUltragoalNudgeTarget,
11
14
  type UltragoalCompletionVerification,
12
15
  type UltragoalGoal,
13
16
  type UltragoalLedgerEvent,
17
+ type UltragoalNudgeSurface,
14
18
  type UltragoalPaths,
15
19
  type UltragoalPlan,
16
20
  type UltragoalReceiptKind,
@@ -65,15 +69,17 @@ function isKnownUltragoalObjective(currentObjective: string): boolean {
65
69
  );
66
70
  }
67
71
 
68
- async function ultragoalReadPaths(cwd: string): Promise<UltragoalPaths> {
72
+ async function ultragoalReadPaths(cwd: string): Promise<{ paths: UltragoalPaths; sessionId: string | null }> {
69
73
  const envSessionId = process.env.SKC_SESSION_ID?.trim();
70
- if (envSessionId) return getUltragoalPaths(cwd, envSessionId);
74
+ if (envSessionId) return { paths: getUltragoalPaths(cwd, envSessionId), sessionId: envSessionId };
71
75
  try {
72
76
  const session = await resolveSkcSessionForRead(cwd, { envSessionId: process.env.SKC_SESSION_ID });
73
- return getUltragoalPaths(cwd, session.skcSessionId);
77
+ return { paths: getUltragoalPaths(cwd, session.skcSessionId), sessionId: session.skcSessionId };
74
78
  } catch (error) {
75
79
  if (error instanceof SessionResolutionError && error.code === "no_session") {
76
- return getUltragoalPaths(cwd, null);
80
+ // No session could be resolved (no env, no auto-detectable active session).
81
+ // Surface the null session id so callers can decide; ask-guard treats it as inactive.
82
+ return { paths: getUltragoalPaths(cwd, null), sessionId: null };
77
83
  }
78
84
  throw error;
79
85
  }
@@ -82,7 +88,7 @@ async function ultragoalReadPaths(cwd: string): Promise<UltragoalPaths> {
82
88
  async function hasDurableUltragoalState(cwd: string): Promise<boolean> {
83
89
  let paths: UltragoalPaths;
84
90
  try {
85
- paths = await ultragoalReadPaths(cwd);
91
+ ({ paths } = await ultragoalReadPaths(cwd));
86
92
  } catch (error) {
87
93
  if (error instanceof SessionResolutionError) return true;
88
94
  throw error;
@@ -368,14 +374,26 @@ export async function readUltragoalVerificationState(input: {
368
374
 
369
375
  export async function isUltragoalAskBlocked(cwd: string): Promise<UltragoalAskBlockDiagnostic> {
370
376
  let paths: UltragoalPaths;
377
+ let sessionId: string | null;
371
378
  try {
372
- paths = await ultragoalReadPaths(cwd);
379
+ ({ paths, sessionId } = await ultragoalReadPaths(cwd));
373
380
  } catch (error) {
374
381
  return activeAskDiagnostic({
375
382
  reason: `Unable to resolve durable Ultragoal state: ${error instanceof Error ? error.message : String(error)}`,
376
383
  source: "durable_state_unreadable",
377
384
  });
378
385
  }
386
+ // Ultragoal state is session-scoped. When no session can be resolved (no env,
387
+ // no auto-detectable active session) there is no active run to protect, so the
388
+ // ask guard must fall open rather than block on legacy/global durable state.
389
+ if (sessionId === null) {
390
+ return inactiveAskDiagnostic({
391
+ reason: "No active SKC session resolved; ultragoal is inactive.",
392
+ source: "absent",
393
+ goalsPath: paths.goalsPath,
394
+ ledgerPath: paths.ledgerPath,
395
+ });
396
+ }
379
397
  try {
380
398
  await fs.stat(paths.dir);
381
399
  } catch (error) {
@@ -398,8 +416,8 @@ export async function isUltragoalAskBlocked(cwd: string): Promise<UltragoalAskBl
398
416
  let plan: UltragoalPlan | null;
399
417
  let ledger: UltragoalLedgerEvent[];
400
418
  try {
401
- plan = await readUltragoalPlan(cwd);
402
- ledger = await readUltragoalLedger(cwd);
419
+ plan = await readUltragoalPlan(cwd, sessionId);
420
+ ledger = await readUltragoalLedger(cwd, sessionId);
403
421
  } catch (error) {
404
422
  return activeAskDiagnostic({
405
423
  reason: `Unable to read durable Ultragoal state: ${error instanceof Error ? error.message : String(error)}`,
@@ -409,6 +427,9 @@ export async function isUltragoalAskBlocked(cwd: string): Promise<UltragoalAskBl
409
427
  });
410
428
  }
411
429
  if (!plan) {
430
+ // goals.json absent or empty while the state dir exists is an inconsistent
431
+ // durable state, not a clean "no run". Fail closed so the pause guard (which
432
+ // relies on this `durable_state_unreadable` signal) keeps blocking give-ups.
412
433
  return activeAskDiagnostic({
413
434
  reason: "Durable .skc/ultragoal state exists but goals.json is missing or empty.",
414
435
  source: "durable_state_unreadable",
@@ -477,6 +498,96 @@ export async function isUltragoalAskBlocked(cwd: string): Promise<UltragoalAskBl
477
498
  });
478
499
  }
479
500
 
501
+ const NUDGE_SURFACE_LABEL: Record<UltragoalNudgeSurface, string> = {
502
+ pause: "pausing the goal",
503
+ drop: "dropping the run",
504
+ ask: "asking the user",
505
+ premature_complete: "finishing the story early",
506
+ };
507
+
508
+ const NUDGE_SURFACE_REASON: Record<UltragoalNudgeSurface, string> = {
509
+ pause: "an active story still has resolvable work",
510
+ drop: "the aggregate run still has unfinished stories",
511
+ ask: "the decision can be resolved as durable story work",
512
+ premature_complete: "story verification is not yet satisfied",
513
+ };
514
+
515
+ /**
516
+ * Escalating per-attempt refusal text. Deliberately avoids every
517
+ * `isUltragoalBypassPrompt` trigger: no `update_goal(`, no "skip/weaken verification",
518
+ * no "mark ... complete", no "--status complete", and the word "complete" never
519
+ * appears (so the `goal` ... `complete` proximity rule cannot match).
520
+ */
521
+ export function formatUltragoalNudgeMessage(input: {
522
+ surface: UltragoalNudgeSurface;
523
+ attempt: number;
524
+ budget: number;
525
+ goalId: string;
526
+ }): string {
527
+ const label = NUDGE_SURFACE_LABEL[input.surface];
528
+ const reason = NUDGE_SURFACE_REASON[input.surface];
529
+ return [
530
+ `Ultragoal try-harder nudge (${input.attempt}/${input.budget}) for ${input.goalId}: ${label} was refused before the normal gate.`,
531
+ `Resolving this is part of the goal, not a reason to stop. Try a different approach first: inspect the failure, run a focused test or replay, find local credentials/config if access is the blocker, split the obstacle with \`skc ultragoal steer --kind add_subgoal\`, delegate an executor, or record concrete review blockers.`,
532
+ `Reason: ${reason}.`,
533
+ ].join("\n");
534
+ }
535
+
536
+ /**
537
+ * Consume one nudge for a guarded give-up attempt. MUST only be called from assert/
538
+ * consume paths, never from read-style `is...Blocked` diagnostics. Returns a nudge
539
+ * message while the per-story budget remains; otherwise reports not-nudged so the
540
+ * caller falls through to today's gate.
541
+ */
542
+ async function consumeUltragoalNudge(input: {
543
+ cwd: string;
544
+ surface: UltragoalNudgeSurface;
545
+ currentGoal?: CurrentGoalLike | null;
546
+ sessionId?: string | null;
547
+ }): Promise<{ nudged: true; message: string } | { nudged: false }> {
548
+ const sessionId = input.sessionId?.trim() || (await ultragoalReadPaths(input.cwd)).sessionId;
549
+ if (!sessionId) return { nudged: false };
550
+ const plan = await readUltragoalPlan(input.cwd, sessionId);
551
+ if (!plan) return { nudged: false };
552
+ const target = selectUltragoalNudgeTarget(plan, { currentGoalObjective: input.currentGoal?.objective });
553
+ if (!target) return { nudged: false };
554
+ const { budget } = await resolveUltragoalNudgeBudget(input.cwd);
555
+ const outcome = await recordUltragoalNudgeIfBudgetRemaining({
556
+ cwd: input.cwd,
557
+ sessionId,
558
+ target,
559
+ surface: input.surface,
560
+ budget,
561
+ reason: NUDGE_SURFACE_REASON[input.surface],
562
+ ...(input.currentGoal?.objective ? { currentGoalObjective: input.currentGoal.objective } : {}),
563
+ });
564
+ if (outcome.nudged) {
565
+ return {
566
+ nudged: true,
567
+ message: formatUltragoalNudgeMessage({
568
+ surface: input.surface,
569
+ attempt: outcome.attempt,
570
+ budget: outcome.budget,
571
+ goalId: outcome.goalId,
572
+ }),
573
+ };
574
+ }
575
+ return { nudged: false };
576
+ }
577
+
578
+ /**
579
+ * Assert-path entry for the `ask` surface (the ask guard lives in another module).
580
+ * Resolves the active (leader) Ultragoal session so subagent/headless asks consume
581
+ * the leader run's budget rather than a fresh child ledger.
582
+ */
583
+ export async function consumeUltragoalAskNudge(
584
+ cwd: string,
585
+ sessionId?: string | null,
586
+ ): Promise<{ nudged: true; message: string } | { nudged: false }> {
587
+ if (!cwd) return { nudged: false };
588
+ return consumeUltragoalNudge({ cwd, surface: "ask", sessionId });
589
+ }
590
+
480
591
  export async function assertCanCompleteCurrentGoal(input: {
481
592
  cwd: string;
482
593
  currentGoal?: CurrentGoalLike | null;
@@ -485,6 +596,13 @@ export async function assertCanCompleteCurrentGoal(input: {
485
596
  if (!input.cwd) return;
486
597
  const diagnostic = await readUltragoalVerificationState(input);
487
598
  if (["inactive", "unrelated_goal", "active_verified_complete"].includes(diagnostic.state)) return;
599
+ const nudge = await consumeUltragoalNudge({
600
+ cwd: input.cwd,
601
+ surface: "premature_complete",
602
+ currentGoal: input.currentGoal,
603
+ sessionId: input.sessionId,
604
+ });
605
+ if (nudge.nudged) throw new Error(nudge.message);
488
606
  throw new Error(
489
607
  `${diagnostic.message} Run strict \`skc ultragoal checkpoint --status complete --quality-gate-json <file> --skc-goal-json <file>\` first, or record review blockers and rerun verification.`,
490
608
  );
@@ -541,6 +659,10 @@ export async function isUltragoalPauseBlocked(cwd: string): Promise<UltragoalPau
541
659
  }
542
660
 
543
661
  export async function assertUltragoalPauseAllowed(cwd: string): Promise<void> {
662
+ if (cwd) {
663
+ const nudge = await consumeUltragoalNudge({ cwd, surface: "pause" });
664
+ if (nudge.nudged) throw new Error(nudge.message);
665
+ }
544
666
  const diagnostic = await isUltragoalPauseBlocked(cwd);
545
667
  if (!diagnostic.blocked) return;
546
668
  throw new Error(
@@ -551,3 +673,65 @@ export async function assertUltragoalPauseAllowed(cwd: string): Promise<void> {
551
673
  ].join("\n"),
552
674
  );
553
675
  }
676
+ /**
677
+ * Guard `goal({"op":"drop"})` during an active Ultragoal run. A *real give-up* (an
678
+ * aggregate run still mid-flight with incomplete required stories) is nudged while
679
+ * budget remains; once exhausted it falls through to today's drop behavior. Legitimate
680
+ * aggregate-reset drops — no durable run, unrelated goal, an already dropped/stale
681
+ * aggregate, or an all-stories-complete run — are never nudged. If durable state
682
+ * exists but cannot be read to classify the drop, fail closed.
683
+ */
684
+ export async function assertUltragoalDropAllowed(input: {
685
+ cwd: string;
686
+ currentGoal?: CurrentGoalLike | null;
687
+ sessionId?: string | null;
688
+ }): Promise<void> {
689
+ if (!input.cwd) return;
690
+ let paths: UltragoalPaths;
691
+ let sessionId: string | null;
692
+ try {
693
+ ({ paths, sessionId } = await ultragoalReadPaths(input.cwd));
694
+ } catch (error) {
695
+ throw new Error(
696
+ `Unable to classify Ultragoal drop (durable state unreadable): ${error instanceof Error ? error.message : String(error)}`,
697
+ );
698
+ }
699
+ if (sessionId === null) return;
700
+ try {
701
+ await fs.stat(paths.dir);
702
+ } catch (error) {
703
+ if (isEnoent(error)) return;
704
+ throw new Error(
705
+ `Unable to classify Ultragoal drop (durable state present but unreadable): ${error instanceof Error ? error.message : String(error)}`,
706
+ );
707
+ }
708
+ let plan: UltragoalPlan | null;
709
+ try {
710
+ plan = await readUltragoalPlan(input.cwd, sessionId);
711
+ } catch (error) {
712
+ throw new Error(
713
+ `Unable to classify Ultragoal drop (goals.json unreadable): ${error instanceof Error ? error.message : String(error)}`,
714
+ );
715
+ }
716
+ if (!plan) {
717
+ throw new Error("Unable to classify Ultragoal drop: durable state exists but goals.json is missing or empty.");
718
+ }
719
+ // Out of scope: per-story mode drops keep today's behavior.
720
+ if (plan.skcGoalMode !== "aggregate") return;
721
+ // A real give-up requires an active aggregate goal to actually be abandoned. With no
722
+ // current goal-mode goal (or a non-active one), `drop` is a no-op/reset before a fresh
723
+ // `create`, never a give-up — so it is left un-nudged.
724
+ if (!input.currentGoal) return;
725
+ if (input.currentGoal.status !== "active") return;
726
+ // Unrelated active goal: not this aggregate run.
727
+ if (!objectiveMatches(input.currentGoal.objective, plan)) return;
728
+ // All required stories complete: a legitimate reset, not a give-up.
729
+ if (getUltragoalRunCompletionState(plan).allComplete) return;
730
+ const nudge = await consumeUltragoalNudge({
731
+ cwd: input.cwd,
732
+ surface: "drop",
733
+ currentGoal: input.currentGoal,
734
+ sessionId,
735
+ });
736
+ if (nudge.nudged) throw new Error(nudge.message);
737
+ }