@sayknow-cli/coding-agent 0.3.8 → 0.3.9

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 (57) hide show
  1. package/dist/types/config/settings-schema.d.ts +14 -0
  2. package/dist/types/modes/components/thinking-selector.d.ts +2 -2
  3. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  4. package/dist/types/modes/interactive-mode.d.ts +2 -0
  5. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  6. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  7. package/dist/types/modes/types.d.ts +2 -0
  8. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  9. package/dist/types/notifications/config-commands.d.ts +9 -0
  10. package/dist/types/notifications/config.d.ts +16 -0
  11. package/dist/types/notifications/index.d.ts +2 -0
  12. package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
  13. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  14. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  15. package/dist/types/notifications/rich-draft.d.ts +68 -0
  16. package/dist/types/notifications/rich-render.d.ts +90 -0
  17. package/dist/types/notifications/telegram-daemon.d.ts +22 -0
  18. package/dist/types/notifications/telegram-reference.d.ts +7 -0
  19. package/dist/types/notifications/threaded-render.d.ts +8 -0
  20. package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
  21. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  22. package/package.json +7 -7
  23. package/src/cli/notify-cli.ts +11 -9
  24. package/src/cli/update-cli.ts +63 -24
  25. package/src/config/keybindings.ts +1 -1
  26. package/src/config/settings-schema.ts +8 -0
  27. package/src/internal-urls/docs-index.generated.ts +5 -5
  28. package/src/lsp/client.ts +1 -0
  29. package/src/modes/components/footer.ts +7 -2
  30. package/src/modes/components/thinking-selector.ts +10 -8
  31. package/src/modes/controllers/event-controller.ts +11 -2
  32. package/src/modes/controllers/extension-ui-controller.ts +8 -1
  33. package/src/modes/controllers/input-controller.ts +12 -17
  34. package/src/modes/controllers/selector-controller.ts +41 -0
  35. package/src/modes/interactive-mode.ts +5 -0
  36. package/src/modes/rpc/rpc-mode.ts +3 -9
  37. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  38. package/src/modes/types.ts +2 -0
  39. package/src/modes/utils/injected-user-submission.ts +94 -0
  40. package/src/notifications/config-commands.ts +20 -0
  41. package/src/notifications/config.ts +27 -0
  42. package/src/notifications/index.ts +28 -5
  43. package/src/notifications/lifecycle-commands.ts +37 -16
  44. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  45. package/src/notifications/reply-sent-store.ts +134 -0
  46. package/src/notifications/rich-draft.ts +107 -0
  47. package/src/notifications/rich-render.ts +142 -0
  48. package/src/notifications/telegram-daemon.ts +346 -89
  49. package/src/notifications/telegram-reference.ts +17 -0
  50. package/src/notifications/threaded-render.ts +28 -1
  51. package/src/prompts/tools/search-tool-bm25.md +5 -0
  52. package/src/sdk.ts +36 -8
  53. package/src/session/agent-session.ts +6 -4
  54. package/src/skc-runtime/tmux-common.ts +1 -0
  55. package/src/skc-runtime/ultragoal-runtime.ts +10 -1
  56. package/src/slash-commands/builtin-registry.ts +78 -1
  57. package/src/utils/pasted-image-path.ts +170 -0
@@ -21,3 +21,8 @@ Returns JSON with:
21
21
  - `activated_tools` — tools activated by this search call
22
22
  - `match_count` — number of ranked matches returned by the search
23
23
  - `total_tools`
24
+
25
+ Match details include:
26
+ - `server_name` — MCP server name when the activated result is an MCP tool
27
+ - `mcp_tool_name` — original MCP tool name when applicable
28
+ - `schema_keys` — searchable input property names
package/src/sdk.ts CHANGED
@@ -123,7 +123,12 @@ import {
123
123
  } from "./system-prompt";
124
124
  import { AgentOutputManager } from "./task/output-manager";
125
125
  import { parseThinkingLevel, resolveThinkingLevelForModel, toReasoningEffort } from "./thinking";
126
- import { collectDiscoverableTools, type DiscoverableTool } from "./tool-discovery/tool-index";
126
+ import {
127
+ collectDiscoverableTools,
128
+ type DiscoverableTool,
129
+ isMCPBridgeTool,
130
+ isMCPToolName,
131
+ } from "./tool-discovery/tool-index";
127
132
  import {
128
133
  applyConfiguredSearchTimeout,
129
134
  BashTool,
@@ -1812,7 +1817,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1812
1817
  { source: "builtin" },
1813
1818
  )
1814
1819
  : [];
1815
- const discoverableToolsForDesc: DiscoverableTool[] = [...discoverableBuiltinTools];
1820
+ const discoverableMCPTools: DiscoverableTool[] = mcpDiscoveryEnabled
1821
+ ? collectDiscoverableTools(
1822
+ Array.from(tools.values()).filter(
1823
+ tool => isMCPToolName(tool.name) && !activeToolNames.has(tool.name),
1824
+ ),
1825
+ { source: "mcp" },
1826
+ )
1827
+ : [];
1828
+ const discoverableToolsForDesc: DiscoverableTool[] = [...discoverableBuiltinTools, ...discoverableMCPTools];
1816
1829
  const promptTools = buildSystemPromptToolMetadata(tools, {
1817
1830
  search_tool_bm25: { description: renderSearchToolBm25Description(discoverableToolsForDesc) },
1818
1831
  });
@@ -1861,8 +1874,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1861
1874
  intentField,
1862
1875
  mcpDiscoveryMode: false,
1863
1876
  mcpDiscoveryServerSummaries: [],
1864
- toolDiscoveryActive: effectiveDiscoveryMode === "all",
1865
- discoverableTools: discoverableBuiltinTools
1877
+ toolDiscoveryActive: effectiveDiscoveryMode === "all" || mcpDiscoveryEnabled,
1878
+ discoverableTools: [...discoverableBuiltinTools, ...discoverableMCPTools]
1866
1879
  .map(tool => ({ name: tool.name, summary: tool.summary }))
1867
1880
  .sort((a, b) => a.name.localeCompare(b.name)),
1868
1881
  eagerTasks,
@@ -1893,11 +1906,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1893
1906
  : toolNamesFromRegistry;
1894
1907
  const normalizedRequested = requestedToolNames.filter(name => toolRegistry.has(name));
1895
1908
  const requestedToolNameSet = new Set(normalizedRequested);
1896
- // Effective discovery mode only covers built-in tools; MCP tool discovery
1897
- // is quarantined from the SKC public surface.
1909
+ // Generic tool discovery hides non-essential built-ins behind search_tool_bm25.
1910
+ // Legacy MCP discovery remains separately controlled by mcp.discoveryMode so
1911
+ // loaded MCP catalogs can be searched without enabling every hidden built-in.
1898
1912
  const toolsDiscoveryModeSetting = settings.get("tools.discoveryMode");
1899
1913
  const effectiveDiscoveryMode: "off" | "all" = toolsDiscoveryModeSetting === "all" ? "all" : "off";
1900
- const mcpDiscoveryEnabled = false;
1914
+ const mcpDiscoveryEnabled = settings.get("mcp.discoveryMode") === true;
1901
1915
  const defaultInactiveToolNames = new Set(
1902
1916
  registeredTools.filter(tool => tool.definition.defaultInactive).map(tool => tool.definition.name),
1903
1917
  );
@@ -1910,6 +1924,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1910
1924
  const discoveryDefaultServerToolNames: string[] = [];
1911
1925
  let initialSelectedMCPToolNames: string[] = [];
1912
1926
  let defaultSelectedMCPToolNames: string[] = [];
1927
+ if (mcpDiscoveryEnabled) {
1928
+ const defaultServerNames = new Set(settings.get("mcp.discoveryDefaultServers") ?? []);
1929
+ for (const tool of toolRegistry.values()) {
1930
+ if (!isMCPBridgeTool(tool)) continue;
1931
+ discoverableMCPToolNames.add(tool.name);
1932
+ if (initialRequestedActiveToolNames.includes(tool.name)) {
1933
+ explicitlyRequestedMCPToolNames.push(tool.name);
1934
+ }
1935
+ const serverName = (tool as AgentTool & { mcpServerName?: string }).mcpServerName;
1936
+ if (serverName && defaultServerNames.has(serverName)) {
1937
+ discoveryDefaultServerToolNames.push(tool.name);
1938
+ }
1939
+ }
1940
+ }
1913
1941
  let initialToolNames = [...initialRequestedActiveToolNames];
1914
1942
  if (mcpDiscoveryEnabled) {
1915
1943
  const restoredSelectedMCPToolNames = existingSession.selectedMCPToolNames.filter(name =>
@@ -2261,7 +2289,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2261
2289
  initialSelectedMCPToolNames,
2262
2290
  defaultSelectedMCPToolNames,
2263
2291
  persistInitialMCPToolSelection: !hasExistingSession,
2264
- defaultSelectedMCPServerNames: [],
2292
+ defaultSelectedMCPServerNames: settings.get("mcp.discoveryDefaultServers") ?? [],
2265
2293
  ttsrManager,
2266
2294
  obfuscator,
2267
2295
  agentId: resolvedAgentId,
@@ -1786,7 +1786,7 @@ export class AgentSession {
1786
1786
  high = mid - 1;
1787
1787
  }
1788
1788
  }
1789
- return { message: bestMessage, tokens: Math.min(bestTokens, maxTokens) };
1789
+ return { message: bestMessage, tokens: bestTokens };
1790
1790
  };
1791
1791
 
1792
1792
  for (let i = providerMessages.length - 1; i >= 0; i--) {
@@ -1806,9 +1806,11 @@ export class AgentSession {
1806
1806
  recordSkip("token-limit");
1807
1807
  if (selected.length === 0) {
1808
1808
  const truncated = truncateMessageToTokenBudget(sanitized);
1809
- selected.unshift(truncated.message);
1810
- approximateTokens = truncated.tokens;
1811
- skippedReasons["newest-message-truncated"] = (skippedReasons["newest-message-truncated"] ?? 0) + 1;
1809
+ if (truncated.tokens <= maxTokens) {
1810
+ selected.unshift(truncated.message);
1811
+ approximateTokens = truncated.tokens;
1812
+ skippedReasons["newest-message-truncated"] = (skippedReasons["newest-message-truncated"] ?? 0) + 1;
1813
+ }
1812
1814
  }
1813
1815
  break;
1814
1816
  }
@@ -4,6 +4,7 @@ import { resolveSkcTmuxBinary } from "./psmux-detect";
4
4
  export const SKC_DEFAULT_TMUX_SESSION = "sayknow_cli";
5
5
  export const SKC_TMUX_SESSION_PREFIX = `${SKC_DEFAULT_TMUX_SESSION}_`;
6
6
  export const SKC_TMUX_COMMAND_ENV = "SKC_TMUX_COMMAND";
7
+ export const SKC_TMUX_ACTIVE_SESSION_ENV = "SKC_TMUX_ACTIVE_SESSION";
7
8
  export const SKC_TMUX_PROFILE_ENV = "SKC_TMUX_PROFILE";
8
9
  export const SKC_TMUX_MOUSE_ENV = "SKC_MOUSE";
9
10
  export const SKC_TMUX_PROFILE_OPTION = "@skc-profile";
@@ -4544,6 +4544,15 @@ function parseGitNameStatus(output: string): UltragoalChangeSetPath[] {
4544
4544
  return rows;
4545
4545
  }
4546
4546
 
4547
+ function categorizeCiChangedPath(value: string): UltragoalChangeCategory {
4548
+ // CI_DEV_CHANGED_PATHS intentionally carries path names only. Mixed registries
4549
+ // such as settings-schema.ts require diff-level narrowing; without the diff,
4550
+ // treating the whole registry as computer-control source forces the mandatory
4551
+ // computer red-team suite on unrelated settings changes.
4552
+ if (isSettingsSchemaPath(value)) return "other";
4553
+ return categorizeComputerChangePath(value);
4554
+ }
4555
+
4547
4556
  function ciDevChangedPathRows(): UltragoalChangeSetPath[] {
4548
4557
  const raw = process.env.CI_DEV_CHANGED_PATHS;
4549
4558
  if (!raw) return [];
@@ -4554,7 +4563,7 @@ function ciDevChangedPathRows(): UltragoalChangeSetPath[] {
4554
4563
  .map(pathValue => ({
4555
4564
  path: normalizeRepoPath(pathValue),
4556
4565
  status: "unknown" as UltragoalChangeStatus,
4557
- category: categorizeComputerChangePath(pathValue),
4566
+ category: categorizeCiChangedPath(pathValue),
4558
4567
  }));
4559
4568
  }
4560
4569
 
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import type { ThinkingLevel } from "@sayknow-cli/agent-core";
3
+ import { ThinkingLevel } from "@sayknow-cli/agent-core";
4
4
  import { type Model, modelsAreEqual } from "@sayknow-cli/ai";
5
5
  import { getOAuthProviders } from "@sayknow-cli/ai/utils/oauth";
6
6
  import { Spacer, Text } from "@sayknow-cli/tui";
@@ -380,6 +380,60 @@ function modelSelectionUsage(runtime: SlashCommandRuntime, currentModelLine?: st
380
380
  .join("\n\n");
381
381
  }
382
382
 
383
+ const EFFORT_COMMAND_INPUT_HINT = "[inherit|off|minimal|low|medium|high|xhigh|max]";
384
+ const EFFORT_COMMAND_ACCEPTED_VALUES = ["inherit", "off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
385
+
386
+ function effortCommandUsage(prefix?: string): string {
387
+ return [prefix, `Usage: /effort ${EFFORT_COMMAND_INPUT_HINT}`]
388
+ .filter((line): line is string => Boolean(line))
389
+ .join("\n");
390
+ }
391
+
392
+ function formatEffortStatus(runtime: SlashCommandRuntime): string {
393
+ const current = runtime.session.thinkingLevel ?? ThinkingLevel.Off;
394
+ const configuredDefault = runtime.settings.get("defaultThinkingLevel");
395
+ const supported = runtime.session.getAvailableThinkingLevels();
396
+ return [
397
+ `Current effective effort: ${current}`,
398
+ `Configured default effort: ${configuredDefault}`,
399
+ `Accepted values: ${EFFORT_COMMAND_ACCEPTED_VALUES.join(", ")}`,
400
+ `Current-model supported levels: ${supported.length > 0 ? supported.join(", ") : "(none reported)"}`,
401
+ ].join("\n");
402
+ }
403
+
404
+ async function handleEffortCommand(
405
+ command: ParsedSlashCommand,
406
+ runtime: SlashCommandRuntime,
407
+ ): Promise<SlashCommandResult> {
408
+ const tokens = command.args.trim().split(/\s+/).filter(Boolean);
409
+ if (tokens.length === 0) {
410
+ await runtime.output(formatEffortStatus(runtime));
411
+ return commandConsumed();
412
+ }
413
+ if (tokens.length !== 1) {
414
+ return usage(effortCommandUsage("Invalid effort input."), runtime);
415
+ }
416
+
417
+ const requestedToken = tokens[0];
418
+ const requestedLevel = parseThinkingLevel(requestedToken);
419
+ if (!requestedToken || !requestedLevel) {
420
+ return usage(effortCommandUsage(`Invalid effort: ${tokens[0] ?? ""}.`), runtime);
421
+ }
422
+
423
+ const levelToApply =
424
+ requestedLevel === ThinkingLevel.Inherit ? runtime.settings.get("defaultThinkingLevel") : requestedLevel;
425
+ runtime.session.setThinkingLevel(levelToApply, false);
426
+ const effectiveLevel = runtime.session.thinkingLevel ?? ThinkingLevel.Off;
427
+ const requestedLabel =
428
+ requestedLevel === ThinkingLevel.Inherit ? `${requestedLevel} (${levelToApply})` : requestedLevel;
429
+ const clampedSuffix =
430
+ effectiveLevel === levelToApply ? "" : ` Requested ${levelToApply}; effective ${effectiveLevel}.`;
431
+ await runtime.output(
432
+ `Reasoning effort set to ${requestedLabel}. Effective effort: ${effectiveLevel}.${clampedSuffix}`,
433
+ );
434
+ return commandConsumed();
435
+ }
436
+
383
437
  function refreshStatusLine(ctx: InteractiveModeContext): void {
384
438
  ctx.statusLine.invalidate();
385
439
  ctx.updateEditorTopBorder();
@@ -588,6 +642,29 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
588
642
  runtime.ctx.editor.setText("");
589
643
  },
590
644
  },
645
+ {
646
+ name: "effort",
647
+ description: "Show or set model reasoning effort",
648
+ acpDescription: "Show or set model reasoning effort",
649
+ inlineHint: EFFORT_COMMAND_INPUT_HINT,
650
+ acpInputHint: EFFORT_COMMAND_INPUT_HINT,
651
+ allowArgs: true,
652
+ handle: handleEffortCommand,
653
+ handleTui: async (command, runtime) => {
654
+ if (command.args.trim()) {
655
+ const result = await handleEffortCommand(command, toSlashCommandRuntime(runtime));
656
+ runtime.ctx.statusLine.invalidate();
657
+ runtime.ctx.updateEditorBorderColor();
658
+ runtime.ctx.updateEditorTopBorder();
659
+ runtime.ctx.editor.setText("");
660
+ runtime.ctx.ui.requestRender();
661
+ return result;
662
+ }
663
+
664
+ runtime.ctx.showEffortSelector();
665
+ runtime.ctx.editor.setText("");
666
+ },
667
+ },
591
668
  {
592
669
  name: "fast",
593
670
  description: "Toggle priority service tier (OpenAI service_tier=priority, Anthropic speed=fast)",
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Resolve pasted editor text to an image file path.
3
+ *
4
+ * Terminals insert a shell-escaped filesystem path when a file is drag-dropped
5
+ * onto them (e.g. macOS `Screenshot\ 2026-07-07\ at\ 11.06.38 PM.png`, where
6
+ * the visible "space" before AM/PM is U+202F and stays unescaped). When the
7
+ * entire paste is a single path to an existing image file, the interactive
8
+ * editor attaches the image and inserts an `[image N]` placeholder instead of
9
+ * leaving the raw path in the prompt.
10
+ */
11
+ import * as fs from "node:fs";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const IMAGE_FILE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp)$/i;
17
+
18
+ export interface DecodePastedPathOptions {
19
+ /**
20
+ * Platform whose path semantics apply when decoding (shell unescaping,
21
+ * `file://` drive letters / UNC hosts). Defaults to `process.platform`;
22
+ * injectable so tests can pin the win32 contract from any host.
23
+ */
24
+ platform?: NodeJS.Platform;
25
+ /** Home directory for `~/` expansion. Defaults to `os.homedir()`. */
26
+ homedir?: string;
27
+ }
28
+
29
+ export interface ResolvePastedImagePathOptions extends DecodePastedPathOptions {
30
+ /** Base directory for relative paths. Defaults to `process.cwd()`. */
31
+ cwd?: string;
32
+ }
33
+
34
+ /**
35
+ * Convert a `file://` URI to a filesystem path for the given platform, or
36
+ * `undefined` when the URI is invalid.
37
+ *
38
+ * The production path (injected platform === host platform, always true
39
+ * outside tests) delegates to Node's `fileURLToPath`. When tests inject a
40
+ * foreign platform, a mirror of `fileURLToPath`'s rules is applied instead —
41
+ * Bun (1.3) accepts but ignores `fileURLToPath`'s `windows` option, so the
42
+ * native call cannot emulate a foreign platform.
43
+ */
44
+ function decodeFileUrl(candidate: string, platform: NodeJS.Platform): string | undefined {
45
+ let url: URL;
46
+ try {
47
+ url = new URL(candidate);
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ if (url.protocol !== "file:") return undefined;
52
+
53
+ if (platform === process.platform) {
54
+ try {
55
+ return fileURLToPath(url);
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ // Foreign-platform mirror of fileURLToPath (tests only).
62
+ if (/%2f/i.test(url.pathname) || (platform === "win32" && /%5c/i.test(url.pathname))) return undefined;
63
+ let pathname: string;
64
+ try {
65
+ pathname = decodeURIComponent(url.pathname);
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ if (platform === "win32") {
70
+ if (url.hostname && url.hostname !== "localhost") {
71
+ // UNC: file://server/share/img.png -> \\server\share\img.png
72
+ return `\\\\${url.hostname}${pathname.replaceAll("/", "\\")}`;
73
+ }
74
+ // Drive letter required: file:///C:/x -> C:\x
75
+ if (!/^\/[A-Za-z]:/.test(pathname)) return undefined;
76
+ return pathname.slice(1).replaceAll("/", "\\");
77
+ }
78
+ if (url.hostname && url.hostname !== "localhost") return undefined;
79
+ return pathname;
80
+ }
81
+
82
+ /**
83
+ * Decode pasted text into a filesystem path candidate. No filesystem access.
84
+ *
85
+ * Handles terminal drag-drop shell escaping (`\ `, `\(`, ...; skipped on
86
+ * win32 where `\` is the path separator), quoted paths, `file://` URIs
87
+ * (drive-letter, `file://localhost`, and UNC forms on win32), and `~/`
88
+ * expansion. Returns `undefined` when the text is empty, spans multiple
89
+ * lines, or is an invalid `file://` URI.
90
+ */
91
+ export function decodePastedPathCandidate(text: string, options?: DecodePastedPathOptions): string | undefined {
92
+ const platform = options?.platform ?? process.platform;
93
+ let candidate = text.trim();
94
+ if (!candidate || /[\r\n]/.test(candidate)) return undefined;
95
+
96
+ // Quoted paths (some terminals quote instead of escaping).
97
+ if (
98
+ candidate.length >= 2 &&
99
+ (candidate.startsWith('"') || candidate.startsWith("'")) &&
100
+ candidate.endsWith(candidate[0])
101
+ ) {
102
+ candidate = candidate.slice(1, -1);
103
+ }
104
+
105
+ if (candidate.startsWith("file://")) {
106
+ const decoded = decodeFileUrl(candidate, platform);
107
+ if (decoded === undefined) return undefined;
108
+ candidate = decoded;
109
+ } else if (platform !== "win32") {
110
+ // Terminal drag-drop escapes shell-special characters (`\ `, `\(`, ...).
111
+ // Skipped on Windows where `\` is the path separator.
112
+ candidate = candidate.replace(/\\(.)/g, "$1");
113
+ }
114
+
115
+ if (candidate.startsWith("~/")) {
116
+ candidate = path.join(options?.homedir ?? os.homedir(), candidate.slice(2));
117
+ }
118
+
119
+ return candidate;
120
+ }
121
+
122
+ /**
123
+ * Sniff the file header for a supported image signature (PNG, JPEG, GIF,
124
+ * WEBP). Prevents existing non-image files with image-looking extensions
125
+ * (e.g. a text file named `not-image.png`) from being treated as image
126
+ * candidates — consuming such a paste would lose the raw path when the
127
+ * image loader later rejects the content.
128
+ */
129
+ function hasSupportedImageMagic(filePath: string): boolean {
130
+ let fd: number;
131
+ try {
132
+ fd = fs.openSync(filePath, "r");
133
+ } catch {
134
+ return false;
135
+ }
136
+ try {
137
+ // Zero-filled; short reads leave trailing zeros so comparisons fail safely.
138
+ const header = Buffer.alloc(12);
139
+ fs.readSync(fd, header, 0, 12, 0);
140
+ if (header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4e && header[3] === 0x47) return true; // PNG
141
+ if (header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) return true; // JPEG
142
+ const ascii6 = header.toString("latin1", 0, 6);
143
+ if (ascii6 === "GIF87a" || ascii6 === "GIF89a") return true; // GIF
144
+ if (header.toString("latin1", 0, 4) === "RIFF" && header.toString("latin1", 8, 12) === "WEBP") return true; // WEBP
145
+ return false;
146
+ } catch {
147
+ return false;
148
+ } finally {
149
+ fs.closeSync(fd);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Returns the resolved path when the whole pasted text is a single path to an
155
+ * existing image file (verified by extension AND content signature),
156
+ * otherwise `undefined` (the paste is inserted as text).
157
+ */
158
+ export function resolvePastedImagePath(text: string, options?: ResolvePastedImagePathOptions): string | undefined {
159
+ const candidate = decodePastedPathCandidate(text, options);
160
+ if (!candidate || !IMAGE_FILE_EXTENSION_PATTERN.test(candidate)) return undefined;
161
+
162
+ const resolved = path.resolve(options?.cwd ?? process.cwd(), candidate);
163
+ try {
164
+ if (!fs.statSync(resolved).isFile()) return undefined;
165
+ } catch {
166
+ return undefined;
167
+ }
168
+ if (!hasSupportedImageMagic(resolved)) return undefined;
169
+ return resolved;
170
+ }