@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
package/src/tools/ask.ts CHANGED
@@ -260,7 +260,11 @@ async function askSingleQuestion(
260
260
  ? "up/down navigate enter select ←/→ question esc cancel"
261
261
  : "up/down navigate enter select esc cancel";
262
262
  const helpText =
263
- scrollTitleRows === undefined ? baseHelpText : `${baseHelpText} wheel/PgUp/PgDn scroll question`;
263
+ scrollTitleRows === undefined
264
+ ? baseHelpText
265
+ : navigation
266
+ ? "↑/↓ select enter ←/→ question esc PgUp/PgDn/Ctrl+u/d: question · Wheel: transcript"
267
+ : "↑/↓ select enter esc PgUp/PgDn/Ctrl+u/d: question · Wheel: transcript";
264
268
  const dialogOptions = {
265
269
  initialIndex,
266
270
  timeout,
package/src/tools/bash.ts CHANGED
@@ -25,6 +25,7 @@ import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-intera
25
25
  import { checkBashInterception } from "./bash-interceptor";
26
26
  import { canUseInteractiveBashPty } from "./bash-pty-selection";
27
27
  import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls";
28
+ import { checkComposerBashPolicy } from "./composer-bash-policy";
28
29
  import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta";
29
30
  import { resolveToCwd } from "./path-utils";
30
31
  import { formatToolWorkingDirectory, replaceTabs } from "./render-utils";
@@ -570,6 +571,14 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
570
571
  }
571
572
  }
572
573
 
574
+ const composerPolicy = checkComposerBashPolicy({
575
+ modelId: this.session.getActiveModelString?.() ?? this.session.getModelString?.() ?? this.session.model?.id,
576
+ commands: rawCommand === command ? [command] : [rawCommand, command],
577
+ });
578
+ if (!composerPolicy.allowed) {
579
+ throw new ToolError(composerPolicy.message);
580
+ }
581
+
573
582
  const internalUrlOptions: InternalUrlExpansionOptions = {
574
583
  skills: this.session.skills ?? [],
575
584
  internalRouter: InternalUrlRouter.instance(),
@@ -57,6 +57,10 @@ export interface TabSession {
57
57
  kindTag: BrowserKindTag;
58
58
  /** Session that acquired this tab; used for session-scoped teardown (F13). */
59
59
  ownerId?: string;
60
+ /** Unix-ms timestamp of the last acquire/run activity; drives GC idle + LRU ordering. */
61
+ lastUsedAt: number;
62
+ /** Set synchronously by the shared begin-release guard so concurrent release is a no-op. */
63
+ releasing?: boolean;
60
64
  }
61
65
 
62
66
  export interface AcquireTabOptions {
@@ -94,6 +98,76 @@ export function getTab(name: string): TabSession | undefined {
94
98
  return tabs.get(name);
95
99
  }
96
100
 
101
+ /**
102
+ * Shared synchronous begin-release guard. Returns true for the first caller (which then
103
+ * owns teardown) and false for any concurrent/repeat caller, so `releaseBrowser` and the
104
+ * `BrowserHandle.refCount` decrement happen exactly once even when the idle sweep, the RSS
105
+ * sweep, a manual close, and `forceKillTab` race on the same tab.
106
+ */
107
+ function beginRelease(tab: TabSession): boolean {
108
+ if (tab.releasing) return false;
109
+ tab.releasing = true;
110
+ return true;
111
+ }
112
+
113
+ /** Read-only, GC-facing projection of a live tab. Never exposes the mutable `tabs` map. */
114
+ export interface TabGcSnapshot {
115
+ name: string;
116
+ ownerId?: string;
117
+ state: TabSession["state"];
118
+ pendingCount: number;
119
+ kindTag: BrowserKindTag;
120
+ lastUsedAt: number;
121
+ browserRefCount: number;
122
+ }
123
+
124
+ export interface BrowserGcEligibilityPolicy {
125
+ now: () => number;
126
+ idleMs: number;
127
+ }
128
+
129
+ /** Snapshot every live tab for GC ordering/diagnostics. */
130
+ export function listTabsForGc(): TabGcSnapshot[] {
131
+ return [...tabs.values()].map(tab => ({
132
+ name: tab.name,
133
+ ownerId: tab.ownerId,
134
+ state: tab.state,
135
+ pendingCount: tab.pending.size,
136
+ kindTag: tab.kindTag,
137
+ lastUsedAt: tab.lastUsedAt,
138
+ browserRefCount: tab.browser.refCount,
139
+ }));
140
+ }
141
+
142
+ /**
143
+ * Evict a tab only if it is still GC-eligible against the LIVE supervisor state. The full
144
+ * predicate is checked synchronously and `releaseTab` is invoked with no intervening await,
145
+ * so a tab that became busy after a GC snapshot cannot be closed (its run rejected). Returns
146
+ * true only when the tab was actually released.
147
+ */
148
+ export async function releaseTabIfGcEligible(name: string, policy: BrowserGcEligibilityPolicy): Promise<boolean> {
149
+ const tab = tabs.get(name);
150
+ if (!tab) return false;
151
+ if (tab.releasing) return false;
152
+ if (tab.state !== "alive") return false;
153
+ if (tab.pending.size !== 0) return false;
154
+ if (tab.kindTag !== "headless" && tab.kindTag !== "spawned") return false;
155
+ if (policy.now() - tab.lastUsedAt <= policy.idleMs) return false;
156
+ // All predicates passed synchronously; releaseTab applies the shared begin-release guard
157
+ // on the same microtask, so no other code runs between the check and the state transition.
158
+ return await releaseTab(name, { kill: false });
159
+ }
160
+
161
+ /** Test-only: install a fabricated tab session. */
162
+ export function setTabForTest(tab: TabSession): void {
163
+ tabs.set(tab.name, tab);
164
+ }
165
+
166
+ /** Test-only: clear the tab registry between cases. */
167
+ export function clearTabsForTest(): void {
168
+ tabs.clear();
169
+ }
170
+
97
171
  export async function acquireTab(
98
172
  name: string,
99
173
  browser: BrowserHandle,
@@ -105,6 +179,7 @@ export async function acquireTab(
105
179
  if (opts.dialogs !== undefined && opts.dialogs !== existing.dialogPolicy) {
106
180
  await releaseTab(name, { kill: false });
107
181
  } else {
182
+ existing.lastUsedAt = Date.now();
108
183
  if (opts.url) {
109
184
  await runInTabWithSnapshot(
110
185
  name,
@@ -166,6 +241,7 @@ export async function acquireTab(
166
241
  dialogPolicy: opts.dialogs,
167
242
  kindTag: browser.kind.kind,
168
243
  ownerId: opts.ownerId,
244
+ lastUsedAt: Date.now(),
169
245
  };
170
246
  worker.onMessage(msg => handleTabMessage(tab, msg));
171
247
  tabs.set(name, tab);
@@ -188,6 +264,7 @@ async function runInTabWithSnapshot(
188
264
  const tab = tabs.get(name);
189
265
  if (!tab || tab.state === "dead") throw new ToolError(`Tab ${JSON.stringify(name)} is not alive. Reopen it.`);
190
266
  if (tab.pending.size > 0) throw new ToolError(`Tab ${JSON.stringify(name)} is busy`);
267
+ tab.lastUsedAt = Date.now();
191
268
  const id = Snowflake.next();
192
269
  const { promise, resolve, reject } = Promise.withResolvers<RunResultOk>();
193
270
  const pending: PendingRun = {
@@ -224,6 +301,10 @@ export async function releaseTab(name: string, opts: ReleaseTabOptions = {}): Pr
224
301
  logger.debug("releaseTab: unknown tab", { name });
225
302
  return false;
226
303
  }
304
+ if (!beginRelease(tab)) {
305
+ logger.debug("releaseTab: already releasing", { name });
306
+ return false;
307
+ }
227
308
  const wasAlive = tab.state === "alive";
228
309
  tab.state = "dead";
229
310
  const closeError = new ToolError(`Tab ${JSON.stringify(name)} was closed`);
@@ -246,7 +327,9 @@ export async function releaseTab(name: string, opts: ReleaseTabOptions = {}): Pr
246
327
  await tab.worker.terminate().catch(() => undefined);
247
328
  if (forced && tab.kindTag === "headless") await closeOrphanTarget(tab);
248
329
  await releaseBrowser(tab.browser, { kill: opts.kill ?? false });
249
- tabs.delete(name);
330
+ // Only delete if the map still holds THIS tab: a same-name reacquire during our async
331
+ // teardown may have installed a fresh tab that we must not evict.
332
+ if (tabs.get(name) === tab) tabs.delete(name);
250
333
  return true;
251
334
  }
252
335
 
@@ -392,6 +475,7 @@ function toErrorPayload(error: unknown): RunErrorPayload {
392
475
  async function forceKillTab(name: string, reason: string): Promise<void> {
393
476
  const tab = tabs.get(name);
394
477
  if (!tab) return;
478
+ if (!beginRelease(tab)) return;
395
479
  tab.state = "dead";
396
480
  const error = new ToolError(reason);
397
481
  for (const pending of tab.pending.values()) pending.reject(error);
@@ -399,7 +483,7 @@ async function forceKillTab(name: string, reason: string): Promise<void> {
399
483
  await tab.worker.terminate().catch(() => undefined);
400
484
  if (tab.kindTag === "headless") await closeOrphanTarget(tab);
401
485
  await releaseBrowser(tab.browser, { kill: false });
402
- tabs.delete(name);
486
+ if (tabs.get(name) === tab) tabs.delete(name);
403
487
  }
404
488
 
405
489
  async function closeOrphanTarget(tab: TabSession): Promise<void> {
@@ -0,0 +1,96 @@
1
+ import { isComposerHarnessModel } from "@sayknow-cli/ai/providers/composer-discipline";
2
+
3
+ export const COMPOSER_BASH_POLICY_ERROR =
4
+ "Composer bash policy blocked repository file I/O. Use find, search, read, and edit tools for file discovery, file inspection, and file mutation.";
5
+
6
+ type ComposerBashPolicyResult =
7
+ | { allowed: true }
8
+ | {
9
+ allowed: false;
10
+ reason: string;
11
+ message: string;
12
+ };
13
+
14
+ const BLOCK_PATTERNS: Array<{ id: string; pattern: RegExp }> = [
15
+ { id: "pipe", pattern: /\|/ },
16
+ { id: "process-substitution", pattern: /<[>(]/ },
17
+ { id: "heredoc", pattern: /<<[-~]?/ },
18
+ { id: "command-substitution", pattern: /\$\(|`/ },
19
+ { id: "redirection", pattern: /(^|[^<>])(?:>>?|<)(?!=)/ },
20
+ { id: "tee", pattern: /(?:^|[;&|\s])tee(?:\s|$)/ },
21
+ {
22
+ id: "shell-file-read-discovery",
23
+ pattern: /(?:^|[;&|()\s])(?:\S*\/)?(?:cat|head|tail|less|more|grep|rg|find|fd|tree|ls)\b/,
24
+ },
25
+ {
26
+ id: "shell-file-mutation",
27
+ pattern: /(?:^|[;&|()\s])(?:\S*\/)?(?:cp|mv|rm|touch|mkdir|chmod|chown|ln)\b/,
28
+ },
29
+ { id: "sed-print", pattern: /(?:^|[;&|()\s])sed\s+(?:-[^\s]*n\b|.*\bp\b)/ },
30
+ { id: "awk-print", pattern: /(?:^|[;&|()\s])awk\b/ },
31
+ { id: "git-ls-files", pattern: /(?:^|[;&|()\s])git(?:\s+-C\s+\S+)?\s+ls-files\b/ },
32
+ { id: "git-grep", pattern: /(?:^|[;&|()\s])git(?:\s+-C\s+\S+)?\s+grep\b/ },
33
+ { id: "git-show-path", pattern: /(?:^|[;&|()\s])git(?:\s+-C\s+\S+)?\s+show\s+\S+:\S+/ },
34
+ { id: "git-diff", pattern: /(?:^|[;&|()\s])git(?:\s+-C\s+\S+)?\s+diff(?:\s|$)/ },
35
+ { id: "git-cat-file", pattern: /(?:^|[;&|()\s])git(?:\s+-C\s+\S+)?\s+cat-file\b/ },
36
+ {
37
+ id: "git-show-discovery",
38
+ pattern: /(?:^|[;&|()\s])git(?:\s+-C\s+\S+)?\s+show\b.*(?:--name-only|--name-status|--stat)/,
39
+ },
40
+ {
41
+ id: "git-log-path-discovery",
42
+ pattern: /(?:^|[;&|()\s])git(?:\s+-C\s+\S+)?\s+log\b.*(?:--name-only|--name-status|--stat)/,
43
+ },
44
+ { id: "sed-in-place", pattern: /(?:^|[;&|()\s])sed\s+-[^\s]*i\b/ },
45
+ { id: "perl-in-place", pattern: /(?:^|[;&|()\s])perl\s+-[^\s]*p[^\s]*i\b/ },
46
+ {
47
+ id: "script-file-io",
48
+ pattern:
49
+ /(?:^|[;&|()\s])(?:python3?|node|bun)\s+(?:-\s*<<|-c\b|-e\b|--eval\b).*?(?:read_text|read_bytes|write_text|iterdir|listdir|glob\.glob|readFile|readFileSync|writeFile|writeFileSync|readdir|readdirSync|stat|statSync|cpSync|rmSync|mkdirSync|createReadStream|createWriteStream|Bun\.file|Bun\.write|fs\.readFile|fs\.writeFile|fs\.readdir|fs\.stat|fs\.cp|fs\.rm|fs\.mkdir|open\s*\()/s,
50
+ },
51
+ {
52
+ id: "contaminated-command",
53
+ pattern: /```|^\s*(?:I\s+(?:will|need|am going)|We\s+(?:need|will)|First[, ]|Now[, ]|Let's)\b/im,
54
+ },
55
+ ];
56
+
57
+ const ALLOWED_TERMINAL_PATTERNS: RegExp[] = [
58
+ /^bun\s+test(?:\s+[\w./:@=-]+)*$/,
59
+ /^bun\s+run\s+(?:check(?::[\w-]+)?|test(?::[\w-]+)?|build(?::[\w-]+)?)(?:\s+[\w./:@=-]+)*$/,
60
+ /^bun\s+--version$/,
61
+ /^mise\s+x\s+bun@\d+\.\d+\.\d+\s+--\s+bun\s+test(?:\s+[\w./:@=-]+)*$/,
62
+ /^mise\s+x\s+bun@\d+\.\d+\.\d+\s+--\s+bun\s+run\s+(?:check(?::[\w-]+)?|test(?::[\w-]+)?|build(?::[\w-]+)?)(?:\s+[\w./:@=-]+)*$/,
63
+ /^cargo\s+(?:test|check|build)(?:\s+[\w./:@=-]+)*$/,
64
+ /^git\s+status(?:\s+--short)?(?:\s+--branch)?$/,
65
+ /^git\s+rev-parse\s+HEAD$/,
66
+ /^npm\s+--version$/,
67
+ /^pnpm\s+--version$/,
68
+ /^yarn\s+--version$/,
69
+ ];
70
+
71
+ function isAllowedComposerTerminalCommand(command: string): boolean {
72
+ const normalized = command.trim().replace(/\s+/g, " ");
73
+ return ALLOWED_TERMINAL_PATTERNS.some(pattern => pattern.test(normalized));
74
+ }
75
+
76
+ export function isComposerBashPolicyModel(modelId: string | undefined): boolean {
77
+ return Boolean(modelId && isComposerHarnessModel(modelId));
78
+ }
79
+
80
+ export function checkComposerBashPolicy(input: {
81
+ modelId?: string;
82
+ commands: readonly string[];
83
+ }): ComposerBashPolicyResult {
84
+ if (!isComposerBashPolicyModel(input.modelId)) return { allowed: true };
85
+ for (const command of input.commands) {
86
+ for (const block of BLOCK_PATTERNS) {
87
+ if (block.pattern.test(command)) {
88
+ return { allowed: false, reason: block.id, message: COMPOSER_BASH_POLICY_ERROR };
89
+ }
90
+ }
91
+ if (!isAllowedComposerTerminalCommand(command)) {
92
+ return { allowed: false, reason: "not-allowlisted", message: COMPOSER_BASH_POLICY_ERROR };
93
+ }
94
+ }
95
+ return { allowed: true };
96
+ }
@@ -0,0 +1,66 @@
1
+ import type { Dirent } from "node:fs";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { logger } from "@sayknow-cli/utils";
6
+
7
+ /** Prefix of every computer-use screenshot fallback directory created under the OS temp dir. */
8
+ export const SCREENSHOT_FALLBACK_DIR_PREFIX = "skc-computer-screenshots-";
9
+
10
+ let screenshotFallbackDirsCreated = false;
11
+
12
+ /** Lazy-arm marker: called when the first screenshot fallback dir is created this process. */
13
+ export function markScreenshotFallbackDirCreatedForGc(): void {
14
+ screenshotFallbackDirsCreated = true;
15
+ }
16
+
17
+ /** Whether any screenshot fallback dir has been created this process (GC lazy-arm gate). */
18
+ export function hasCreatedScreenshotFallbackDir(): boolean {
19
+ return screenshotFallbackDirsCreated;
20
+ }
21
+
22
+ /** Test-only: reset the lazy-arm marker between cases. */
23
+ export function resetScreenshotFallbackGcForTest(): void {
24
+ screenshotFallbackDirsCreated = false;
25
+ }
26
+
27
+ export interface ScreenshotGcOptions {
28
+ now: () => number;
29
+ staleMs: number;
30
+ /** Base dir to scan; defaults to os.tmpdir(). Injectable for tests. */
31
+ tmpDir?: string;
32
+ }
33
+
34
+ /**
35
+ * Disk-only GC for stale computer-use screenshot fallback directories. Scans the temp dir for
36
+ * `skc-computer-screenshots-*` directories and removes those whose mtime is older than `staleMs`.
37
+ * Never throws on a per-directory failure; the whole sweep is best-effort.
38
+ */
39
+ export async function cleanupStaleScreenshotFallbackDirs(
40
+ options: ScreenshotGcOptions,
41
+ ): Promise<{ scanned: number; removed: number }> {
42
+ const base = options.tmpDir ?? os.tmpdir();
43
+ const entries: Dirent[] = await fs.readdir(base, { withFileTypes: true }).catch(err => {
44
+ logger.debug("screenshot GC: failed to read temp dir", { base, error: (err as Error).message });
45
+ return [] as Dirent[];
46
+ });
47
+
48
+ const now = options.now();
49
+ let scanned = 0;
50
+ let removed = 0;
51
+ for (const entry of entries) {
52
+ if (!entry.isDirectory()) continue;
53
+ if (!entry.name.startsWith(SCREENSHOT_FALLBACK_DIR_PREFIX)) continue;
54
+ scanned++;
55
+ const dir = path.join(base, entry.name);
56
+ try {
57
+ const stat = await fs.stat(dir);
58
+ if (now - stat.mtimeMs <= options.staleMs) continue;
59
+ await fs.rm(dir, { recursive: true, force: true });
60
+ removed++;
61
+ } catch (err) {
62
+ logger.debug("screenshot GC: failed to remove stale dir", { dir, error: (err as Error).message });
63
+ }
64
+ }
65
+ return { scanned, removed };
66
+ }
@@ -7,6 +7,7 @@ import { prompt } from "@sayknow-cli/utils";
7
7
  import * as z from "zod/v4";
8
8
  import computerDescription from "../prompts/tools/computer.md" with { type: "text" };
9
9
  import { resizeImage } from "../utils/image-resize";
10
+ import { markScreenshotFallbackDirCreatedForGc } from "./computer-gc";
10
11
  import type { ToolSession } from "./index";
11
12
  import type { OutputMeta } from "./output-meta";
12
13
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
@@ -547,6 +548,7 @@ function getScreenshotFallbackDir(session: ToolSession): Promise<string> {
547
548
  async function createScreenshotFallbackDir(): Promise<string> {
548
549
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "skc-computer-screenshots-"));
549
550
  await fs.chmod(dir, 0o700);
551
+ markScreenshotFallbackDirCreatedForGc();
550
552
  return dir;
551
553
  }
552
554
 
package/src/tools/cron.ts CHANGED
@@ -19,34 +19,36 @@ const MAX_RECURRING_JITTER_MS = 30 * 60 * 1000;
19
19
  const MAX_ONE_SHOT_EARLY_JITTER_MS = 90 * 1000;
20
20
  const MAX_TIMEOUT_MS = 2_147_483_647;
21
21
 
22
- const cronCreateSchema = z.object({
23
- cron_expression: z
24
- .string()
25
- .describe(
26
- "Standard 5-field cron expression in the user's local timezone: 'minute hour day-of-month month day-of-week'. Examples: '*/5 * * * *' (every 5 min), '0 9 * * *' (9am daily), '0 9 * * 1-5' (weekdays at 9am). Day-of-week uses 0/7 for Sunday through 6 for Saturday. When both day-of-month and day-of-week are constrained, a date matches if either field matches (vixie-cron semantics).",
27
- ),
28
- prompt: z
29
- .string()
30
- .describe(
31
- "Prompt to inject between turns when the cron fires. May reference slash commands (e.g. '/review-pr 1234') or natural-language instructions.",
32
- ),
33
- recurring: z
34
- .boolean()
35
- .default(true)
36
- .describe(
37
- "true to fire on every match of the cron expression (recurring, auto-expires after 7 days); false to fire once at the next match and then self-delete.",
38
- ),
39
- });
40
-
41
- export type CronCreateParams = z.infer<typeof cronCreateSchema>;
42
-
43
- const cronListSchema = z.object({});
44
- export type CronListParams = z.infer<typeof cronListSchema>;
45
-
46
- const cronDeleteSchema = z.object({
47
- id: z.string().min(1).describe("The 8-character job ID returned by CronCreate."),
48
- });
49
- export type CronDeleteParams = z.infer<typeof cronDeleteSchema>;
22
+ const cronSchema = z
23
+ .object({
24
+ op: z
25
+ .enum(["create", "list", "delete"])
26
+ .describe(
27
+ "operation: 'create' schedules a prompt on a cron expression, 'list' enumerates scheduled tasks, 'delete' cancels a task by id",
28
+ ),
29
+ cron_expression: z
30
+ .string()
31
+ .optional()
32
+ .describe(
33
+ "(op=create, required) Standard 5-field cron expression in the user's local timezone: 'minute hour day-of-month month day-of-week'. Examples: '*/5 * * * *' (every 5 min), '0 9 * * *' (9am daily), '0 9 * * 1-5' (weekdays at 9am). Day-of-week uses 0/7 for Sunday through 6 for Saturday. When both day-of-month and day-of-week are constrained, a date matches if either field matches (vixie-cron semantics).",
34
+ ),
35
+ prompt: z
36
+ .string()
37
+ .optional()
38
+ .describe(
39
+ "(op=create, required) Prompt to inject between turns when the cron fires. May reference slash commands (e.g. '/review-pr 1234') or natural-language instructions.",
40
+ ),
41
+ recurring: z
42
+ .boolean()
43
+ .optional()
44
+ .describe(
45
+ "(op=create) true to fire on every match of the cron expression (recurring, auto-expires after 7 days); false to fire once at the next match and then self-delete.",
46
+ ),
47
+ id: z.string().optional().describe("(op=delete, required) The 8-character job ID returned by op=create."),
48
+ })
49
+ .strict();
50
+
51
+ export type CronParams = z.infer<typeof cronSchema>;
50
52
 
51
53
  export interface CronJobSnapshot {
52
54
  id: string;
@@ -68,20 +70,14 @@ export interface CronListJobDetails {
68
70
  humanSchedule: string;
69
71
  }
70
72
 
71
- export interface CronCreateToolDetails {
72
- id: string;
73
- cron_expression: string;
74
- recurring: boolean;
73
+ export interface CronToolDetails {
74
+ op: "create" | "list" | "delete";
75
+ id?: string;
76
+ cron_expression?: string;
77
+ recurring?: boolean;
75
78
  nextFireAt?: number;
76
- }
77
-
78
- export interface CronListToolDetails {
79
- jobs: CronListJobDetails[];
80
- }
81
-
82
- export interface CronDeleteToolDetails {
83
- id: string;
84
- deleted: boolean;
79
+ jobs?: CronListJobDetails[];
80
+ deleted?: boolean;
85
81
  }
86
82
 
87
83
  interface CronTimerHandle {
@@ -543,12 +539,12 @@ function isCronDisabled(): boolean {
543
539
  return process.env.CLAUDE_CODE_DISABLE_CRON === "1";
544
540
  }
545
541
 
546
- export class CronCreateTool implements AgentTool<typeof cronCreateSchema, CronCreateToolDetails> {
547
- readonly name = "CronCreate";
548
- readonly label = "CronCreate";
549
- readonly summary = "Schedule a prompt on a 5-field cron expression";
542
+ export class CronTool implements AgentTool<typeof cronSchema, CronToolDetails> {
543
+ readonly name = "cron";
544
+ readonly label = "Cron";
545
+ readonly summary = "Schedule, list, and cancel cron-style prompts (op: create | list | delete)";
550
546
  readonly description: string;
551
- readonly parameters = cronCreateSchema;
547
+ readonly parameters = cronSchema;
552
548
  readonly strict = true;
553
549
  readonly loadMode = "discoverable";
554
550
 
@@ -556,19 +552,30 @@ export class CronCreateTool implements AgentTool<typeof cronCreateSchema, CronCr
556
552
  this.description = prompt.render(cronDescription);
557
553
  }
558
554
 
559
- static createIf(session: ToolSession): CronCreateTool | null {
555
+ static createIf(session: ToolSession): CronTool | null {
560
556
  if (!isBackgroundJobSupportEnabled(session.settings)) return null;
561
557
  if (isCronDisabled()) return null;
562
- return new CronCreateTool(session);
558
+ return new CronTool(session);
563
559
  }
564
560
 
565
561
  async execute(
566
562
  _toolCallId: string,
567
- params: CronCreateParams,
563
+ params: CronParams,
568
564
  _signal?: AbortSignal,
569
- _onUpdate?: AgentToolUpdateCallback<CronCreateToolDetails>,
565
+ _onUpdate?: AgentToolUpdateCallback<CronToolDetails>,
570
566
  _context?: AgentToolContext,
571
- ): Promise<AgentToolResult<CronCreateToolDetails>> {
567
+ ): Promise<AgentToolResult<CronToolDetails>> {
568
+ switch (params.op) {
569
+ case "create":
570
+ return this.#create(params);
571
+ case "list":
572
+ return this.#list();
573
+ case "delete":
574
+ return this.#delete(params);
575
+ }
576
+ }
577
+
578
+ async #create(params: CronParams): Promise<AgentToolResult<CronToolDetails>> {
572
579
  const manager = AsyncJobManager.instance();
573
580
  if (!manager) {
574
581
  throw new ToolError("Async execution is disabled; cron is unavailable in this session.");
@@ -576,26 +583,30 @@ export class CronCreateTool implements AgentTool<typeof cronCreateSchema, CronCr
576
583
  if (isCronDisabled()) {
577
584
  throw new ToolError("Cron is disabled by CLAUDE_CODE_DISABLE_CRON=1.");
578
585
  }
586
+ if (!params.cron_expression || !params.prompt) {
587
+ throw new ToolError("cron op=create requires both 'cron_expression' and 'prompt'.");
588
+ }
579
589
  validateCronExpression(params.cron_expression);
580
590
 
581
591
  const ownerId = this.session.getAgentId?.() ?? undefined;
582
592
  const state = getOrCreateOwnerState(ownerId);
583
593
  if (state.jobs.size >= MAX_CRON_TASKS_PER_OWNER) {
584
594
  throw new ToolError(
585
- `Cron task limit reached (${MAX_CRON_TASKS_PER_OWNER}). Cancel an existing task with CronDelete first.`,
595
+ `Cron task limit reached (${MAX_CRON_TASKS_PER_OWNER}). Cancel an existing task with cron op=delete first.`,
586
596
  );
587
597
  }
588
598
  ensureOwnerCleanup(ownerId, manager, state);
589
599
 
590
600
  const id = generateCronId(new Set(state.jobs.keys()));
591
601
  const now = Date.now();
602
+ const recurring = params.recurring ?? true;
592
603
  const snapshot: CronJobSnapshot = {
593
604
  id,
594
605
  cron_expression: params.cron_expression.trim(),
595
606
  prompt: params.prompt,
596
- recurring: params.recurring,
607
+ recurring,
597
608
  createdAt: now,
598
- expiresAt: params.recurring ? now + CRON_RECURRING_MAX_AGE_MS : undefined,
609
+ expiresAt: recurring ? now + CRON_RECURRING_MAX_AGE_MS : undefined,
599
610
  humanSchedule: humanizeCronExpression(params.cron_expression.trim()),
600
611
  ownerId,
601
612
  };
@@ -604,7 +615,7 @@ export class CronCreateTool implements AgentTool<typeof cronCreateSchema, CronCr
604
615
  scheduleExpiry(ownerId, record);
605
616
  scheduleRecord(ownerId, state, record);
606
617
 
607
- logger.debug("CronCreate: scheduled task", {
618
+ logger.debug("cron op=create: scheduled task", {
608
619
  id,
609
620
  ownerId,
610
621
  cron: snapshot.cron_expression,
@@ -614,6 +625,7 @@ export class CronCreateTool implements AgentTool<typeof cronCreateSchema, CronCr
614
625
  return {
615
626
  content: [{ type: "text", text: `Scheduled ${id} (${snapshot.humanSchedule})` }],
616
627
  details: {
628
+ op: "create",
617
629
  id,
618
630
  cron_expression: snapshot.cron_expression,
619
631
  recurring: snapshot.recurring,
@@ -621,34 +633,8 @@ export class CronCreateTool implements AgentTool<typeof cronCreateSchema, CronCr
621
633
  },
622
634
  };
623
635
  }
624
- }
625
-
626
- export class CronListTool implements AgentTool<typeof cronListSchema, CronListToolDetails> {
627
- readonly name = "CronList";
628
- readonly label = "CronList";
629
- readonly summary = "List scheduled cron jobs";
630
- readonly description: string;
631
- readonly parameters = cronListSchema;
632
- readonly strict = true;
633
- readonly loadMode = "discoverable";
634
-
635
- constructor(private readonly session: ToolSession) {
636
- this.description = prompt.render(cronDescription);
637
- }
638
636
 
639
- static createIf(session: ToolSession): CronListTool | null {
640
- if (!isBackgroundJobSupportEnabled(session.settings)) return null;
641
- if (isCronDisabled()) return null;
642
- return new CronListTool(session);
643
- }
644
-
645
- async execute(
646
- _toolCallId: string,
647
- _params: CronListParams,
648
- _signal?: AbortSignal,
649
- _onUpdate?: AgentToolUpdateCallback<CronListToolDetails>,
650
- _context?: AgentToolContext,
651
- ): Promise<AgentToolResult<CronListToolDetails>> {
637
+ async #list(): Promise<AgentToolResult<CronToolDetails>> {
652
638
  const ownerId = this.session.getAgentId?.() ?? undefined;
653
639
  const state = schedulesByOwner.get(ownerKey(ownerId));
654
640
  const records = state
@@ -658,7 +644,7 @@ export class CronListTool implements AgentTool<typeof cronListSchema, CronListTo
658
644
  if (jobs.length === 0) {
659
645
  return {
660
646
  content: [{ type: "text", text: "No scheduled jobs" }],
661
- details: { jobs: [] },
647
+ details: { op: "list", jobs: [] },
662
648
  };
663
649
  }
664
650
  const lines = jobs.map(job => {
@@ -667,43 +653,20 @@ export class CronListTool implements AgentTool<typeof cronListSchema, CronListTo
667
653
  });
668
654
  return {
669
655
  content: [{ type: "text", text: lines.join("\n") }],
670
- details: { jobs },
656
+ details: { op: "list", jobs },
671
657
  };
672
658
  }
673
- }
674
659
 
675
- export class CronDeleteTool implements AgentTool<typeof cronDeleteSchema, CronDeleteToolDetails> {
676
- readonly name = "CronDelete";
677
- readonly label = "CronDelete";
678
- readonly summary = "Cancel a scheduled cron job by ID";
679
- readonly description: string;
680
- readonly parameters = cronDeleteSchema;
681
- readonly strict = true;
682
- readonly loadMode = "discoverable";
683
-
684
- constructor(private readonly session: ToolSession) {
685
- this.description = prompt.render(cronDescription);
686
- }
687
-
688
- static createIf(session: ToolSession): CronDeleteTool | null {
689
- if (!isBackgroundJobSupportEnabled(session.settings)) return null;
690
- if (isCronDisabled()) return null;
691
- return new CronDeleteTool(session);
692
- }
693
-
694
- async execute(
695
- _toolCallId: string,
696
- params: CronDeleteParams,
697
- _signal?: AbortSignal,
698
- _onUpdate?: AgentToolUpdateCallback<CronDeleteToolDetails>,
699
- _context?: AgentToolContext,
700
- ): Promise<AgentToolResult<CronDeleteToolDetails>> {
660
+ async #delete(params: CronParams): Promise<AgentToolResult<CronToolDetails>> {
661
+ if (!params.id) {
662
+ throw new ToolError("cron op=delete requires 'id'.");
663
+ }
701
664
  const ownerId = this.session.getAgentId?.() ?? undefined;
702
665
  const deleted = deleteRecord(ownerId, params.id);
703
666
  const text = deleted ? `Cancelled ${params.id}` : `No scheduled task '${params.id}' found; nothing to cancel.`;
704
667
  return {
705
668
  content: [{ type: "text", text }],
706
- details: { id: params.id, deleted },
669
+ details: { op: "delete", id: params.id, deleted },
707
670
  };
708
671
  }
709
672
  }