@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
@@ -17,7 +17,7 @@ import { CachedOutputBlock } from "../tui/output-block";
17
17
  import { formatDimensionNote, resizeImage } from "../utils/image-resize";
18
18
  import { ensureTool } from "../utils/tools-manager";
19
19
  import { INSANE_NOTES, tryInsaneFetch } from "../web/insane/bridge";
20
- import { validatePublicHttpUrlForInsane } from "../web/insane/url-guard";
20
+ import { validatePublicHttpUrl, validatePublicHttpUrlForInsane } from "../web/insane/url-guard";
21
21
  import { extractWithParallel, findParallelApiKey, getParallelExtractContent } from "../web/parallel";
22
22
  import { specialHandlers } from "../web/scrapers";
23
23
  import type { RenderResult } from "../web/scrapers/types";
@@ -789,6 +789,21 @@ async function renderUrl(
789
789
 
790
790
  // Step 0: Normalize URL (ensure scheme for special handlers)
791
791
  url = normalizeUrl(url);
792
+ const publicUrl = await validatePublicHttpUrl(url);
793
+ if (!publicUrl.ok) {
794
+ notes.push(`Blocked URL fetch: target URL is not public HTTP(S): ${publicUrl.reason}`);
795
+ return {
796
+ url,
797
+ finalUrl: url,
798
+ contentType: "unknown",
799
+ method: "failed",
800
+ content: "",
801
+ fetchedAt,
802
+ truncated: false,
803
+ notes,
804
+ };
805
+ }
806
+ url = publicUrl.url.toString();
792
807
 
793
808
  // Step 1: Try special handlers for known sites (unless raw mode)
794
809
  if (!raw) {
@@ -802,7 +817,8 @@ async function renderUrl(
802
817
  throw new ToolAbortError();
803
818
  }
804
819
  if (!response.ok) {
805
- const failureNote = response.status ? `Failed to fetch URL (HTTP ${response.status})` : "Failed to fetch URL";
820
+ const failureNote =
821
+ response.error ?? (response.status ? `Failed to fetch URL (HTTP ${response.status})` : "Failed to fetch URL");
806
822
  notes.push(failureNote);
807
823
  const insane = await tryInsaneFallback({
808
824
  url,
@@ -1,5 +1,5 @@
1
1
  import type { AgentTelemetryConfig, AgentTool } from "@sayknow-cli/agent-core";
2
- import type { Model, ToolChoice } from "@sayknow-cli/ai";
2
+ import type { Model, ServiceTier, ToolChoice } from "@sayknow-cli/ai";
3
3
  import { $env, $flag, logger } from "@sayknow-cli/utils";
4
4
  import type { PromptTemplate } from "../config/prompt-templates";
5
5
  import type { Settings } from "../config/settings";
@@ -38,12 +38,11 @@ import { BrowserTool } from "./browser";
38
38
  import { CalculatorTool } from "./calculator";
39
39
  import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
40
40
  import { ComputerTool, isComputerCallable, isComputerLoadablePlatform } from "./computer";
41
- import { CronCreateTool, CronDeleteTool, CronListTool } from "./cron";
41
+ import { CronTool } from "./cron";
42
42
  import { DebugTool } from "./debug";
43
43
  import { EvalTool } from "./eval";
44
44
  import { FindTool } from "./find";
45
45
  import { GithubTool } from "./gh";
46
- import { InspectImageTool } from "./inspect-image";
47
46
  import { IrcTool } from "./irc";
48
47
  import { JobTool } from "./job";
49
48
  import { MonitorTool } from "./monitor";
@@ -83,7 +82,6 @@ export * from "./eval";
83
82
  export * from "./find";
84
83
  export * from "./gh";
85
84
  export * from "./image-gen";
86
- export * from "./inspect-image";
87
85
  export * from "./irc";
88
86
  export * from "./job";
89
87
  export * from "./monitor";
@@ -222,6 +220,8 @@ export interface ToolSession {
222
220
  agentOutputManager?: AgentOutputManager;
223
221
  /** Settings instance for passing to subagents */
224
222
  settings: Settings;
223
+ /** Live service-tier intent of the parent session, inherited by `inherit` subagents. */
224
+ serviceTier?: ServiceTier;
225
225
  /** Plan mode state (if active) */
226
226
  getPlanModeState?: () => PlanModeState | undefined;
227
227
  /** Goal mode state (if active or paused) */
@@ -387,7 +387,6 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
387
387
  find: s => new FindTool(s),
388
388
  search: s => new SearchTool(s),
389
389
  lsp: LspTool.createIf,
390
- inspect_image: s => new InspectImageTool(s),
391
390
  browser: s => new BrowserTool(s),
392
391
  ...(isComputerLoadablePlatform() ? { computer: ComputerTool.createIf } : {}),
393
392
  checkpoint: CheckpointTool.createIf,
@@ -396,9 +395,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
396
395
  subagent: s => new SubagentTool(s),
397
396
  job: JobTool.createIf,
398
397
  monitor: MonitorTool.createIf,
399
- CronCreate: CronCreateTool.createIf,
400
- CronList: CronListTool.createIf,
401
- CronDelete: CronDeleteTool.createIf,
398
+ cron: CronTool.createIf,
402
399
  recipe: RecipeTool.createIf,
403
400
  irc: IrcTool.createIf,
404
401
  todo_write: s => new TodoWriteTool(s),
@@ -558,7 +555,6 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
558
555
  if (name === "ast_grep") return session.settings.get("astGrep.enabled");
559
556
  if (name === "ast_edit") return session.settings.get("astEdit.enabled");
560
557
  if (name === "render_mermaid") return session.settings.get("renderMermaid.enabled");
561
- if (name === "inspect_image") return session.settings.get("inspect_image.enabled");
562
558
  if (name === "web_search") return session.settings.get("web_search.enabled");
563
559
  // search_tool_bm25 is allowed when either legacy mcp.discoveryMode or new tools.discoveryMode is active.
564
560
  if (name === "search_tool_bm25") return discoveryActive;
package/src/tools/read.ts CHANGED
@@ -670,7 +670,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
670
670
 
671
671
  readonly #autoResizeImages: boolean;
672
672
  readonly #defaultLimit: number;
673
- readonly #inspectImageEnabled: boolean;
674
673
 
675
674
  constructor(private readonly session: ToolSession) {
676
675
  const displayMode = resolveFileDisplayMode(session);
@@ -679,7 +678,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
679
678
  1,
680
679
  Math.min(session.settings.get("read.defaultLimit") ?? DEFAULT_MAX_LINES, DEFAULT_MAX_LINES),
681
680
  );
682
- this.#inspectImageEnabled = session.settings.get("inspect_image.enabled");
683
681
  this.description = prompt.render(readDescription, {
684
682
  DEFAULT_LIMIT: String(this.#defaultLimit),
685
683
  DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
@@ -1572,62 +1570,34 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1572
1570
  | undefined;
1573
1571
 
1574
1572
  if (mimeType) {
1575
- if (this.#inspectImageEnabled) {
1576
- const metadata = imageMetadata;
1577
- const outputMime = metadata?.mimeType ?? mimeType;
1578
- const outputBytes = fileSize;
1579
- const metadataLines = [
1580
- "Image metadata:",
1581
- `- MIME: ${outputMime}`,
1582
- `- Bytes: ${outputBytes} (${formatBytes(outputBytes)})`,
1583
- metadata?.width !== undefined && metadata.height !== undefined
1584
- ? `- Dimensions: ${metadata.width}x${metadata.height}`
1585
- : "- Dimensions: unknown",
1586
- metadata?.channels !== undefined ? `- Channels: ${metadata.channels}` : "- Channels: unknown",
1587
- metadata?.hasAlpha === true
1588
- ? "- Alpha: yes"
1589
- : metadata?.hasAlpha === false
1590
- ? "- Alpha: no"
1591
- : "- Alpha: unknown",
1592
- "",
1593
- `If you want to analyze the image, call inspect_image with path="${formatPathRelativeToCwd(
1594
- absolutePath,
1595
- this.session.cwd,
1596
- )}" and a question describing what to inspect and the desired output format.`,
1573
+ if (fileSize > MAX_IMAGE_SIZE) {
1574
+ const sizeStr = formatBytes(fileSize);
1575
+ const maxStr = formatBytes(MAX_IMAGE_SIZE);
1576
+ throw new ToolError(`Image file too large: ${sizeStr} exceeds ${maxStr} limit.`);
1577
+ }
1578
+ try {
1579
+ const imageInput = await loadImageInput({
1580
+ path: readPath,
1581
+ cwd: this.session.cwd,
1582
+ autoResize: this.#autoResizeImages,
1583
+ maxBytes: MAX_IMAGE_SIZE,
1584
+ resolvedPath: absolutePath,
1585
+ detectedMimeType: mimeType,
1586
+ });
1587
+ if (!imageInput) {
1588
+ throw new ToolError(`Read image file [${mimeType}] failed: unsupported image format.`);
1589
+ }
1590
+ content = [
1591
+ { type: "text", text: imageInput.textNote },
1592
+ { type: "image", data: imageInput.data, mimeType: imageInput.mimeType },
1597
1593
  ];
1598
- content = [{ type: "text", text: metadataLines.join("\n") }];
1599
1594
  details = {};
1600
- sourcePath = absolutePath;
1601
- } else {
1602
- if (fileSize > MAX_IMAGE_SIZE) {
1603
- const sizeStr = formatBytes(fileSize);
1604
- const maxStr = formatBytes(MAX_IMAGE_SIZE);
1605
- throw new ToolError(`Image file too large: ${sizeStr} exceeds ${maxStr} limit.`);
1606
- }
1607
- try {
1608
- const imageInput = await loadImageInput({
1609
- path: readPath,
1610
- cwd: this.session.cwd,
1611
- autoResize: this.#autoResizeImages,
1612
- maxBytes: MAX_IMAGE_SIZE,
1613
- resolvedPath: absolutePath,
1614
- detectedMimeType: mimeType,
1615
- });
1616
- if (!imageInput) {
1617
- throw new ToolError(`Read image file [${mimeType}] failed: unsupported image format.`);
1618
- }
1619
- content = [
1620
- { type: "text", text: imageInput.textNote },
1621
- { type: "image", data: imageInput.data, mimeType: imageInput.mimeType },
1622
- ];
1623
- details = {};
1624
- sourcePath = imageInput.resolvedPath;
1625
- } catch (error) {
1626
- if (error instanceof ImageInputTooLargeError) {
1627
- throw new ToolError(error.message);
1628
- }
1629
- throw error;
1595
+ sourcePath = imageInput.resolvedPath;
1596
+ } catch (error) {
1597
+ if (error instanceof ImageInputTooLargeError) {
1598
+ throw new ToolError(error.message);
1630
1599
  }
1600
+ throw error;
1631
1601
  }
1632
1602
  } else if (isNotebookPath(absolutePath) && !isRawSelector(parsed)) {
1633
1603
  const notebookText = await readEditableNotebookText(absolutePath, localReadPath);
@@ -22,7 +22,6 @@ import { debugToolRenderer } from "./debug";
22
22
  import { evalToolRenderer } from "./eval";
23
23
  import { findToolRenderer } from "./find";
24
24
  import { githubToolRenderer } from "./gh-renderer";
25
- import { inspectImageToolRenderer } from "./inspect-image-renderer";
26
25
  import { jobToolRenderer } from "./job";
27
26
  import { readToolRenderer } from "./read";
28
27
  import { recipeToolRenderer } from "./recipe/render";
@@ -63,7 +62,6 @@ export const toolRenderers: Record<string, ToolRenderer> = {
63
62
  find: findToolRenderer as ToolRenderer,
64
63
  search: searchToolRenderer as ToolRenderer,
65
64
  lsp: lspToolRenderer as ToolRenderer,
66
- inspect_image: inspectImageToolRenderer as ToolRenderer,
67
65
  read: readToolRenderer as ToolRenderer,
68
66
  job: jobToolRenderer as ToolRenderer,
69
67
  resolve: resolveToolRenderer as ToolRenderer,
@@ -0,0 +1,291 @@
1
+ import { logger } from "@sayknow-cli/utils";
2
+ import type { Settings } from "../config/settings";
3
+ import { listTabsForGc, releaseTabIfGcEligible, type TabGcSnapshot } from "./browser/tab-supervisor";
4
+ import { cleanupStaleScreenshotFallbackDirs, hasCreatedScreenshotFallbackDir } from "./computer-gc";
5
+
6
+ /**
7
+ * Mandatory, session-aware resource garbage collector.
8
+ *
9
+ * A single process-wide, reference-counted, unref'd, non-overlapping interval sweeps:
10
+ * - browser tabs (the heavyweight resource: one worker thread per tab + Chrome child
11
+ * processes) via an idle sweep and an opportunistic RSS-pressure sweep, and
12
+ * - stale computer-use screenshot fallback directories on disk (lazy-armed + throttled).
13
+ *
14
+ * Eviction targets ONLY alive, non-in-flight, SKC-managed headless/spawned tabs owned by a
15
+ * registered session; connected/real-Chrome/held/in-flight tabs and ownerless tabs are never
16
+ * touched. RSS is the SKC parent-process RSS only (`process.memoryUsage().rss`); pressure
17
+ * eviction is best-effort and never force-evicts.
18
+ */
19
+
20
+ const DEFAULT_SWEEP_INTERVAL_MS = 30_000;
21
+ const BYTES_PER_MB = 1024 * 1024;
22
+
23
+ export interface BrowserGcPolicy {
24
+ enabled: boolean;
25
+ idleMs: number;
26
+ rssLimitBytes: number;
27
+ }
28
+
29
+ export interface ComputerGcPolicy {
30
+ enabled: boolean;
31
+ staleMs: number;
32
+ scanIntervalMs: number;
33
+ }
34
+
35
+ export function resolveBrowserGcPolicy(settings: Settings): BrowserGcPolicy {
36
+ return {
37
+ enabled: settings.get("browser.gc.enabled"),
38
+ idleMs: settings.get("browser.gc.idleMs"),
39
+ rssLimitBytes: settings.get("browser.gc.rssLimitMb") * BYTES_PER_MB,
40
+ };
41
+ }
42
+
43
+ export function resolveComputerGcPolicy(settings: Settings): ComputerGcPolicy {
44
+ return {
45
+ enabled: settings.get("computer.screenshotGc.enabled"),
46
+ staleMs: settings.get("computer.screenshotGc.staleMs"),
47
+ scanIntervalMs: settings.get("computer.screenshotGc.scanIntervalMs"),
48
+ };
49
+ }
50
+
51
+ export function resolveSweepIntervalMs(settings: Settings): number {
52
+ return settings.get("resourceGc.sweepIntervalMs");
53
+ }
54
+
55
+ /** Injectable seams so the controller is fully testable without real browsers/filesystem/RSS. */
56
+ export interface ResourceGcDeps {
57
+ now: () => number;
58
+ rssBytes: () => number;
59
+ logWarn: (msg: string, meta?: Record<string, unknown>) => void;
60
+ listTabs: () => TabGcSnapshot[];
61
+ releaseTab: (name: string, policy: { now: () => number; idleMs: number }) => Promise<boolean>;
62
+ cleanupScreenshots: (opts: { now: () => number; staleMs: number }) => Promise<{ scanned: number; removed: number }>;
63
+ screenshotArmed: () => boolean;
64
+ }
65
+
66
+ const defaultDeps: ResourceGcDeps = {
67
+ now: () => Date.now(),
68
+ rssBytes: () => process.memoryUsage().rss,
69
+ logWarn: (msg, meta) => logger.warn(msg, meta),
70
+ listTabs: () => listTabsForGc(),
71
+ releaseTab: (name, policy) => releaseTabIfGcEligible(name, policy),
72
+ cleanupScreenshots: opts => cleanupStaleScreenshotFallbackDirs(opts),
73
+ screenshotArmed: () => hasCreatedScreenshotFallbackDir(),
74
+ };
75
+
76
+ // ── Controller state (process-global; tabs/browsers are module-global too) ──────────────────
77
+ const activeSessions = new Map<string, Settings>();
78
+ let timer: ReturnType<typeof setTimeout> | null = null;
79
+ let stopped = false;
80
+ // Bumped on every stop so an in-flight tick from a previous schedule cannot reschedule after a
81
+ // stop+re-register and leak a duplicate timer.
82
+ let timerGeneration = 0;
83
+ let inProgress = false;
84
+ let rssWarningActive = false;
85
+ let lastScreenshotScanAt = 0;
86
+ let deps: ResourceGcDeps = defaultDeps;
87
+
88
+ export interface ResourceGcRegistration {
89
+ sessionId: string;
90
+ settings: Settings;
91
+ }
92
+
93
+ /**
94
+ * Register a session with the resource GC. Starts the single shared timer on the first
95
+ * registration. Returns an idempotent unregister function; the timer stops only when the last
96
+ * session unregisters.
97
+ */
98
+ export function registerResourceGcSession(reg: ResourceGcRegistration): () => void {
99
+ activeSessions.set(reg.sessionId, reg.settings);
100
+ ensureTimerStarted();
101
+ let unregistered = false;
102
+ return () => {
103
+ if (unregistered) return;
104
+ unregistered = true;
105
+ activeSessions.delete(reg.sessionId);
106
+ if (activeSessions.size === 0) stopTimer();
107
+ };
108
+ }
109
+
110
+ function currentSweepIntervalMs(): number {
111
+ let min = Number.POSITIVE_INFINITY;
112
+ for (const settings of activeSessions.values()) min = Math.min(min, resolveSweepIntervalMs(settings));
113
+ return Number.isFinite(min) ? min : DEFAULT_SWEEP_INTERVAL_MS;
114
+ }
115
+
116
+ function ensureTimerStarted(): void {
117
+ if (timer) return;
118
+ stopped = false;
119
+ scheduleNextSweep();
120
+ }
121
+
122
+ // Recursive setTimeout (not setInterval) so the cadence is recomputed every cycle and later
123
+ // session register/unregister changes to resourceGc.sweepIntervalMs are honored live.
124
+ function scheduleNextSweep(): void {
125
+ if (stopped || activeSessions.size === 0) {
126
+ timer = null;
127
+ return;
128
+ }
129
+ const generation = timerGeneration;
130
+ timer = setTimeout(() => {
131
+ void tickAndReschedule(generation);
132
+ }, currentSweepIntervalMs());
133
+ timer.unref?.();
134
+ }
135
+
136
+ async function tickAndReschedule(generation: number): Promise<void> {
137
+ await runTick();
138
+ // A stop (and possible re-register) happened during the tick: a newer cycle owns the timer now.
139
+ if (generation !== timerGeneration) return;
140
+ scheduleNextSweep();
141
+ }
142
+
143
+ function stopTimer(): void {
144
+ stopped = true;
145
+ timerGeneration++;
146
+ if (timer) {
147
+ clearTimeout(timer);
148
+ timer = null;
149
+ }
150
+ }
151
+
152
+ async function runTick(): Promise<void> {
153
+ if (inProgress) return;
154
+ inProgress = true;
155
+ try {
156
+ await sweepOnce(deps);
157
+ } catch (err) {
158
+ logger.debug("resource GC sweep failed", { error: (err as Error).message });
159
+ } finally {
160
+ inProgress = false;
161
+ }
162
+ }
163
+
164
+ export async function sweepOnce(d: ResourceGcDeps = deps): Promise<void> {
165
+ if (activeSessions.size === 0) return;
166
+ await sweepBrowserTabs(d);
167
+ await sweepScreenshots(d);
168
+ }
169
+
170
+ function ownerBrowserPolicy(snapshot: TabGcSnapshot): BrowserGcPolicy | null {
171
+ if (!snapshot.ownerId) return null;
172
+ const settings = activeSessions.get(snapshot.ownerId);
173
+ if (!settings) return null;
174
+ return resolveBrowserGcPolicy(settings);
175
+ }
176
+
177
+ /** Coarse, ordering-only eligibility; the live recheck in releaseTabIfGcEligible is authoritative. */
178
+ function isCoarselyEligible(snapshot: TabGcSnapshot): boolean {
179
+ return (
180
+ snapshot.state === "alive" &&
181
+ snapshot.pendingCount === 0 &&
182
+ (snapshot.kindTag === "headless" || snapshot.kindTag === "spawned")
183
+ );
184
+ }
185
+
186
+ /** Collect idle, non-in-flight, SKC-managed, owned-and-enabled tabs, sorted LRU (oldest first). */
187
+ function collectIdleCandidates(d: ResourceGcDeps): Array<{ snapshot: TabGcSnapshot; policy: BrowserGcPolicy }> {
188
+ const candidates: Array<{ snapshot: TabGcSnapshot; policy: BrowserGcPolicy }> = [];
189
+ for (const snapshot of d.listTabs()) {
190
+ if (!isCoarselyEligible(snapshot)) continue;
191
+ const policy = ownerBrowserPolicy(snapshot);
192
+ if (!policy?.enabled) continue;
193
+ if (d.now() - snapshot.lastUsedAt <= policy.idleMs) continue;
194
+ candidates.push({ snapshot, policy });
195
+ }
196
+ candidates.sort((a, b) => a.snapshot.lastUsedAt - b.snapshot.lastUsedAt);
197
+ return candidates;
198
+ }
199
+
200
+ async function sweepBrowserTabs(d: ResourceGcDeps): Promise<void> {
201
+ // Reclamation honors IR-1 strictly: ONLY idle, non-in-flight, SKC-managed, owned tabs are ever
202
+ // evicted. RSS pressure never relaxes that boundary — it only drives the warning below.
203
+ for (const { snapshot, policy } of collectIdleCandidates(d)) {
204
+ await d.releaseTab(snapshot.name, { now: d.now, idleMs: policy.idleMs });
205
+ }
206
+ evaluateRssPressureWarning(d);
207
+ }
208
+
209
+ /** Owners whose own RSS limit is exceeded by the single shared parent-process RSS sample. */
210
+ function pressuredOwnerIds(d: ResourceGcDeps): Set<string> {
211
+ const rss = d.rssBytes();
212
+ const owners = new Set<string>();
213
+ for (const [sessionId, settings] of activeSessions) {
214
+ const policy = resolveBrowserGcPolicy(settings);
215
+ if (policy.enabled && rss > policy.rssLimitBytes) owners.add(sessionId);
216
+ }
217
+ return owners;
218
+ }
219
+
220
+ /**
221
+ * RSS pressure is a best-effort warning signal only. Because eviction is always idle-gated
222
+ * (IR-1), when parent-process RSS stays over an enabled owner's limit and no idle, unheld tab
223
+ * remains to reclaim for a pressured owner, we warn exactly once per continuous episode and
224
+ * never force-evict. The warning episode resets when RSS recovers or a reclaimable tab appears.
225
+ */
226
+ function evaluateRssPressureWarning(d: ResourceGcDeps): void {
227
+ const pressured = pressuredOwnerIds(d);
228
+ if (pressured.size === 0) {
229
+ rssWarningActive = false;
230
+ return;
231
+ }
232
+ const reclaimableRemains = collectIdleCandidates(d).some(
233
+ c => c.snapshot.ownerId !== undefined && pressured.has(c.snapshot.ownerId),
234
+ );
235
+ if (reclaimableRemains) {
236
+ rssWarningActive = false;
237
+ return;
238
+ }
239
+ if (!rssWarningActive) {
240
+ rssWarningActive = true;
241
+ d.logWarn("Browser GC: RSS over limit but no safe (idle, unheld) browser tabs are evictable", {
242
+ rssBytes: d.rssBytes(),
243
+ });
244
+ }
245
+ }
246
+
247
+ async function sweepScreenshots(d: ResourceGcDeps): Promise<void> {
248
+ if (!d.screenshotArmed()) return;
249
+
250
+ let staleMs: number | null = null;
251
+ let scanIntervalMs = Number.POSITIVE_INFINITY;
252
+ for (const settings of activeSessions.values()) {
253
+ const policy = resolveComputerGcPolicy(settings);
254
+ if (!policy.enabled) continue;
255
+ staleMs = staleMs === null ? policy.staleMs : Math.min(staleMs, policy.staleMs);
256
+ scanIntervalMs = Math.min(scanIntervalMs, policy.scanIntervalMs);
257
+ }
258
+ if (staleMs === null) return; // no session has screenshot GC enabled
259
+
260
+ const now = d.now();
261
+ if (now - lastScreenshotScanAt < scanIntervalMs) return;
262
+ lastScreenshotScanAt = now;
263
+ await d.cleanupScreenshots({ now: d.now, staleMs });
264
+ }
265
+
266
+ // ── Test-only seams ─────────────────────────────────────────────────────────────────────────
267
+ export function __setResourceGcDepsForTest(overrides: Partial<ResourceGcDeps>): void {
268
+ deps = { ...defaultDeps, ...overrides };
269
+ }
270
+
271
+ export async function __runResourceGcTickForTest(): Promise<void> {
272
+ await runTick();
273
+ }
274
+
275
+ export function __getResourceGcStateForTest(): {
276
+ timerActive: boolean;
277
+ sessionCount: number;
278
+ rssWarningActive: boolean;
279
+ inProgress: boolean;
280
+ } {
281
+ return { timerActive: timer !== null, sessionCount: activeSessions.size, rssWarningActive, inProgress };
282
+ }
283
+
284
+ export function __resetResourceGcForTest(): void {
285
+ stopTimer();
286
+ activeSessions.clear();
287
+ inProgress = false;
288
+ rssWarningActive = false;
289
+ lastScreenshotScanAt = 0;
290
+ deps = defaultDeps;
291
+ }
@@ -1,5 +1,9 @@
1
1
  import type { AgentTool } from "@sayknow-cli/agent-core";
2
- import { isUltragoalAskBlocked, type UltragoalAskBlockDiagnostic } from "../skc-runtime/ultragoal-guard";
2
+ import {
3
+ consumeUltragoalAskNudge,
4
+ isUltragoalAskBlocked,
5
+ type UltragoalAskBlockDiagnostic,
6
+ } from "../skc-runtime/ultragoal-guard";
3
7
  import { ToolError } from "./tool-errors";
4
8
 
5
9
  const ULTRAGOAL_ASK_GUARD = Symbol.for("sayknow-cli.ultragoalAskGuard");
@@ -17,6 +21,8 @@ export function formatUltragoalAskBlockMessage(diagnostic: UltragoalAskBlockDiag
17
21
  export async function assertUltragoalAskAllowed(cwd: string): Promise<void> {
18
22
  const diagnostic = await isUltragoalAskBlocked(cwd);
19
23
  if (!diagnostic.active) return;
24
+ const nudge = await consumeUltragoalAskNudge(cwd);
25
+ if (nudge.nudged) throw new ToolError(nudge.message);
20
26
  throw new ToolError(formatUltragoalAskBlockMessage(diagnostic));
21
27
  }
22
28
 
@@ -92,6 +92,14 @@ export function getDisplayChangelogEntries(): ChangelogEntry[] {
92
92
  return parseChangelogContent(CHANGELOG_TEXT);
93
93
  }
94
94
 
95
+ export function getInstalledVersionChangelogEntry(
96
+ entries: readonly ChangelogEntry[],
97
+ installedVersion: string,
98
+ ): ChangelogEntry | undefined {
99
+ const [major = 0, minor = 0, patch = 0] = installedVersion.split(".").map(Number);
100
+ return entries.find(entry => entry.major === major && entry.minor === minor && entry.patch === patch) ?? entries[0];
101
+ }
102
+
95
103
  /**
96
104
  * Compare versions. Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2
97
105
  */
@@ -1,16 +1,13 @@
1
1
  /**
2
- * Public HTTP(S) URL guard for the insane-search read fallback.
2
+ * Public HTTP(S) URL guard for user-supplied web fetch targets.
3
3
  *
4
- * The vendored insane-search engine performs its own network requests (curl_cffi,
5
- * a real browser) entirely outside the TypeScript fetch path, so the normal
6
- * `loadPage()` flow cannot protect against SSRF. This guard MUST run before any
7
- * dependency probe or engine subprocess is spawned. It is fail-closed: anything
8
- * it cannot prove is a public, non-credentialed http/https target is rejected.
4
+ * Network-capable URL readers MUST run this guard before the first request and
5
+ * before following any redirect target. It is fail-closed: anything it cannot
6
+ * prove is a public, non-credentialed http/https target is rejected.
9
7
  *
10
- * It does NOT follow or re-validate redirects — the engine may follow redirects
11
- * internally that this guard never sees. That residual risk is documented in the
12
- * plan and mitigated by validating the input target and keeping the feature
13
- * opt-in (default off).
8
+ * The vendored insane-search engine performs its own redirects outside the
9
+ * TypeScript fetch path, so its fallback remains opt-in and is guarded before
10
+ * any dependency probe or engine subprocess is spawned.
14
11
  */
15
12
  import * as dns from "node:dns/promises";
16
13
  import * as net from "node:net";
@@ -105,11 +102,11 @@ export function isPrivateOrSpecialAddress(address: string): boolean {
105
102
  }
106
103
 
107
104
  /**
108
- * Validate that `rawUrl` is a public http/https target safe to hand to the
109
- * insane-search engine. Resolves DNS names and rejects any that map to a
110
- * private/special address. Never throws; returns a discriminated result.
105
+ * Validate that `rawUrl` is a public http/https target. Resolves DNS names and
106
+ * rejects any that map to a private/special address. Never throws; returns a
107
+ * discriminated result.
111
108
  */
112
- export async function validatePublicHttpUrlForInsane(
109
+ export async function validatePublicHttpUrl(
113
110
  rawUrl: string,
114
111
  options: { resolver?: AddressResolver } = {},
115
112
  ): Promise<PublicUrlResult> {
@@ -153,3 +150,10 @@ export async function validatePublicHttpUrlForInsane(
153
150
  }
154
151
  return { ok: true, url, addresses };
155
152
  }
153
+
154
+ export async function validatePublicHttpUrlForInsane(
155
+ rawUrl: string,
156
+ options: { resolver?: AddressResolver } = {},
157
+ ): Promise<PublicUrlResult> {
158
+ return validatePublicHttpUrl(rawUrl, options);
159
+ }