wave-agent-sdk 1.0.7 → 1.0.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 (55) hide show
  1. package/builtin/skills/artifact/SKILL.md +14 -0
  2. package/builtin/skills/settings/ENV.md +2 -1
  3. package/builtin/skills/settings/MODELS.md +3 -0
  4. package/builtin/skills/settings/SKILL.md +7 -0
  5. package/builtin/skills/settings/SUBAGENTS.md +1 -1
  6. package/builtin/subagents/vision.md +18 -0
  7. package/dist/constants/tools.d.ts +1 -0
  8. package/dist/constants/tools.js +1 -0
  9. package/dist/managers/aiManager.js +46 -4
  10. package/dist/managers/backgroundTaskManager.js +52 -4
  11. package/dist/managers/messageManager.js +8 -2
  12. package/dist/managers/permissionManager.d.ts +5 -0
  13. package/dist/managers/permissionManager.js +18 -2
  14. package/dist/managers/pluginManager.d.ts +9 -0
  15. package/dist/managers/pluginManager.js +20 -0
  16. package/dist/managers/skillManager.d.ts +13 -0
  17. package/dist/managers/skillManager.js +30 -0
  18. package/dist/managers/subagentManager.d.ts +12 -0
  19. package/dist/managers/subagentManager.js +32 -2
  20. package/dist/managers/toolManager.d.ts +8 -0
  21. package/dist/managers/toolManager.js +18 -0
  22. package/dist/services/aiService.d.ts +1 -1
  23. package/dist/services/aiService.js +3 -2
  24. package/dist/services/artifactAvailability.d.ts +9 -0
  25. package/dist/services/artifactAvailability.js +34 -0
  26. package/dist/services/artifactSession.d.ts +27 -0
  27. package/dist/services/artifactSession.js +52 -0
  28. package/dist/services/configurationService.d.ts +2 -1
  29. package/dist/services/configurationService.js +16 -1
  30. package/dist/services/initializationService.js +5 -0
  31. package/dist/services/remoteSettingsService.js +2 -0
  32. package/dist/tools/agentTool.js +2 -1
  33. package/dist/tools/artifactTool.d.ts +2 -0
  34. package/dist/tools/artifactTool.js +357 -0
  35. package/dist/tools/bashTool.js +24 -13
  36. package/dist/tools/editTool.js +7 -2
  37. package/dist/tools/types.d.ts +3 -1
  38. package/dist/tools/webFetchTool.js +141 -0
  39. package/dist/types/agent.d.ts +2 -0
  40. package/dist/types/config.d.ts +2 -0
  41. package/dist/types/configuration.d.ts +2 -0
  42. package/dist/types/messaging.d.ts +3 -1
  43. package/dist/types/permissions.d.ts +3 -1
  44. package/dist/types/permissions.js +2 -1
  45. package/dist/utils/bashParser.d.ts +14 -0
  46. package/dist/utils/bashParser.js +45 -1
  47. package/dist/utils/containerSetup.js +8 -0
  48. package/dist/utils/convertMessagesForAPI.js +51 -4
  49. package/dist/utils/encoding.d.ts +28 -0
  50. package/dist/utils/encoding.js +99 -0
  51. package/dist/utils/messageOperations.d.ts +4 -2
  52. package/dist/utils/messageOperations.js +23 -7
  53. package/dist/utils/subagentParser.d.ts +5 -2
  54. package/dist/utils/subagentParser.js +14 -4
  55. package/package.json +2 -1
@@ -28,7 +28,7 @@ export interface CallAgentOptions {
28
28
  onToolUpdate?: (toolCall: {
29
29
  id: string;
30
30
  name: string;
31
- parameters: string;
31
+ parameters?: string;
32
32
  parametersChunk?: string;
33
33
  stage?: "start" | "streaming" | "running" | "end";
34
34
  }) => void;
@@ -425,7 +425,9 @@ async function processStreamingResponse(stream, onContentUpdate, onToolUpdate, o
425
425
  if (functionDelta.arguments) {
426
426
  existingCall.function.arguments += functionDelta.arguments;
427
427
  }
428
- // Emit streaming updates for all chunks with actual content (including first chunk)
428
+ // Emit streaming updates for all chunks with actual content (including first chunk).
429
+ // Streaming carries only the delta `parametersChunk` — consumers accumulate it;
430
+ // authoritative `parameters` arrives at `start` (empty) / `running` / `end`.
429
431
  if (onToolUpdate &&
430
432
  existingCall.function.name &&
431
433
  functionDelta.arguments &&
@@ -434,7 +436,6 @@ async function processStreamingResponse(stream, onContentUpdate, onToolUpdate, o
434
436
  onToolUpdate({
435
437
  id: existingCall.id,
436
438
  name: existingCall.function.name,
437
- parameters: existingCall.function.arguments,
438
439
  parametersChunk: functionDelta.arguments,
439
440
  stage: "streaming",
440
441
  });
@@ -0,0 +1,9 @@
1
+ /** Code default for Artifact availability. Flip to true after the frame backend goes live. */
2
+ export declare const ARTIFACT_DEFAULT_ENABLED = false;
3
+ /**
4
+ * Whether the Artifact tool should be registered / usable for the given workdir.
5
+ * Resolution order: remote managed settings (`enableArtifact` from
6
+ * `GET /api/wave/settings`, admin override) → explicit `enableArtifact` in local
7
+ * merged settings → code default.
8
+ */
9
+ export declare function isArtifactEnabled(workdir?: string): boolean;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Artifact feature availability.
3
+ *
4
+ * The frame backend (`POST /api/frame/deploy/direct`) is not live yet, so the
5
+ * code default is DISABLED. Internal beta / gradual rollout enables the feature
6
+ * via settings.json `enableArtifact: true` without code changes. Once the
7
+ * backend goes live, flip `ARTIFACT_DEFAULT_ENABLED` to `true` so an unset
8
+ * `enableArtifact` defaults to enabled (matching Claude Code's `enableArtifact`
9
+ * "unset follows feature availability" semantics).
10
+ */
11
+ import { loadMergedWaveConfig } from "./configurationService.js";
12
+ import { getRemoteSettingsSync } from "./remoteSettingsService.js";
13
+ /** Code default for Artifact availability. Flip to true after the frame backend goes live. */
14
+ export const ARTIFACT_DEFAULT_ENABLED = false;
15
+ /**
16
+ * Whether the Artifact tool should be registered / usable for the given workdir.
17
+ * Resolution order: remote managed settings (`enableArtifact` from
18
+ * `GET /api/wave/settings`, admin override) → explicit `enableArtifact` in local
19
+ * merged settings → code default.
20
+ */
21
+ export function isArtifactEnabled(workdir) {
22
+ // Remote managed settings win (same last-write-wins semantics as `model`).
23
+ const remote = getRemoteSettingsSync();
24
+ if (remote?.enableArtifact !== undefined) {
25
+ return remote.enableArtifact;
26
+ }
27
+ if (workdir) {
28
+ const config = loadMergedWaveConfig(workdir);
29
+ if (config?.enableArtifact !== undefined) {
30
+ return config.enableArtifact;
31
+ }
32
+ }
33
+ return ARTIFACT_DEFAULT_ENABLED;
34
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Session-scoped state shared between the Artifact tool and the WebFetch tool.
3
+ *
4
+ * - file_path → artifact record: lets a model republish a file it already
5
+ * published this session without passing `url` again, and enables the
6
+ * same-session auto-allow (the publish was already confirmed once).
7
+ * - slug → latest known version: powers the stale-version guard
8
+ * (local knowledge older than the live version blocks a redeploy
9
+ * unless `force` is set) and lets WebFetch record versions it observed.
10
+ *
11
+ * State is keyed by sessionId so parallel sessions never observe each other.
12
+ */
13
+ export interface ArtifactRecord {
14
+ url: string;
15
+ slug: string;
16
+ version: string;
17
+ }
18
+ /** Record a successful publish so same-session republishes auto-allow. */
19
+ export declare function recordArtifact(sessionId: string, filePath: string, record: ArtifactRecord): void;
20
+ /** Look up an artifact previously published in this session by file path. */
21
+ export declare function getArtifactByFilePath(sessionId: string, filePath: string): ArtifactRecord | undefined;
22
+ /** Latest version of a slug this session has observed (publish, conflict, or read). */
23
+ export declare function getRecordedVersion(sessionId: string, slug: string): string | undefined;
24
+ /** Record an observed version (from a publish, a 409 conflict, or a WebFetch read). */
25
+ export declare function recordVersion(sessionId: string, slug: string, version: string): void;
26
+ /** Clear all session-scoped artifact state (used when a session ends). */
27
+ export declare function clearArtifactSession(sessionId: string): void;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Session-scoped state shared between the Artifact tool and the WebFetch tool.
3
+ *
4
+ * - file_path → artifact record: lets a model republish a file it already
5
+ * published this session without passing `url` again, and enables the
6
+ * same-session auto-allow (the publish was already confirmed once).
7
+ * - slug → latest known version: powers the stale-version guard
8
+ * (local knowledge older than the live version blocks a redeploy
9
+ * unless `force` is set) and lets WebFetch record versions it observed.
10
+ *
11
+ * State is keyed by sessionId so parallel sessions never observe each other.
12
+ */
13
+ const sessionArtifacts = new Map();
14
+ const sessionSlugVersions = new Map();
15
+ function fileMapFor(sessionId) {
16
+ let map = sessionArtifacts.get(sessionId);
17
+ if (!map) {
18
+ map = new Map();
19
+ sessionArtifacts.set(sessionId, map);
20
+ }
21
+ return map;
22
+ }
23
+ function slugMapFor(sessionId) {
24
+ let map = sessionSlugVersions.get(sessionId);
25
+ if (!map) {
26
+ map = new Map();
27
+ sessionSlugVersions.set(sessionId, map);
28
+ }
29
+ return map;
30
+ }
31
+ /** Record a successful publish so same-session republishes auto-allow. */
32
+ export function recordArtifact(sessionId, filePath, record) {
33
+ fileMapFor(sessionId).set(filePath, record);
34
+ slugMapFor(sessionId).set(record.slug, record.version);
35
+ }
36
+ /** Look up an artifact previously published in this session by file path. */
37
+ export function getArtifactByFilePath(sessionId, filePath) {
38
+ return sessionArtifacts.get(sessionId)?.get(filePath);
39
+ }
40
+ /** Latest version of a slug this session has observed (publish, conflict, or read). */
41
+ export function getRecordedVersion(sessionId, slug) {
42
+ return sessionSlugVersions.get(sessionId)?.get(slug);
43
+ }
44
+ /** Record an observed version (from a publish, a 409 conflict, or a WebFetch read). */
45
+ export function recordVersion(sessionId, slug, version) {
46
+ slugMapFor(sessionId).set(slug, version);
47
+ }
48
+ /** Clear all session-scoped artifact state (used when a session ends). */
49
+ export function clearArtifactSession(sessionId) {
50
+ sessionArtifacts.delete(sessionId);
51
+ sessionSlugVersions.delete(sessionId);
52
+ }
@@ -81,9 +81,10 @@ export declare class ConfigurationService {
81
81
  * @param fastModel - Fast model override (optional)
82
82
  * @param maxTokens - Max output tokens override (optional)
83
83
  * @param permissionMode - Permission mode override (optional)
84
+ * @param visionModel - Vision model override (optional)
84
85
  * @returns Resolved model configuration with defaults
85
86
  */
86
- resolveModelConfig(model?: string, fastModel?: string, maxTokens?: number, permissionMode?: PermissionMode): ModelConfig;
87
+ resolveModelConfig(model?: string, fastModel?: string, maxTokens?: number, permissionMode?: PermissionMode, visionModel?: string): ModelConfig;
87
88
  /**
88
89
  * Resolves token limit with fallbacks
89
90
  * Resolution priority: override > options > env (from settings.json) > process.env > default
@@ -459,9 +459,10 @@ export class ConfigurationService {
459
459
  * @param fastModel - Fast model override (optional)
460
460
  * @param maxTokens - Max output tokens override (optional)
461
461
  * @param permissionMode - Permission mode override (optional)
462
+ * @param visionModel - Vision model override (optional)
462
463
  * @returns Resolved model configuration with defaults
463
464
  */
464
- resolveModelConfig(model, fastModel, maxTokens, permissionMode) {
465
+ resolveModelConfig(model, fastModel, maxTokens, permissionMode, visionModel) {
465
466
  // Resolve agent model: override > options > currentConfiguration (settings.json model, possibly remote-merged) > process.env
466
467
  // Priority: user's explicit model field > admin's env.WAVE_MODEL default.
467
468
  // If admin wants hard enforcement, they set the `model` scalar field (overwrites local in mergeRemoteSettings).
@@ -473,11 +474,16 @@ export class ConfigurationService {
473
474
  const resolvedFastModel = fastModel ||
474
475
  this.options.fastModel ||
475
476
  (this.envSnapshot.WAVE_FAST_MODEL ?? process.env.WAVE_FAST_MODEL);
477
+ // Resolve vision model: override > options > process.env (includes settings.json env)
478
+ const resolvedVisionModel = visionModel ||
479
+ this.options.visionModel ||
480
+ (this.envSnapshot.WAVE_VISION_MODEL ?? process.env.WAVE_VISION_MODEL);
476
481
  // Resolve max output tokens
477
482
  const resolvedMaxTokens = this.resolveMaxOutputTokens(maxTokens);
478
483
  const baseConfig = {
479
484
  model: resolvedAgentModel,
480
485
  fastModel: resolvedFastModel,
486
+ visionModel: resolvedVisionModel,
481
487
  maxTokens: resolvedMaxTokens,
482
488
  permissionMode: permissionMode ?? this.options.permissionMode,
483
489
  };
@@ -1062,9 +1068,13 @@ export function loadWaveConfigFromFile(filePath) {
1062
1068
  autoMemoryEnabled: config.autoMemoryEnabled !== undefined
1063
1069
  ? config.autoMemoryEnabled
1064
1070
  : undefined,
1071
+ autoMemoryFrequency: config.autoMemoryFrequency !== undefined
1072
+ ? config.autoMemoryFrequency
1073
+ : undefined,
1065
1074
  models: config.models || undefined,
1066
1075
  marketplaces: config.marketplaces || undefined,
1067
1076
  worktree: config.worktree || undefined,
1077
+ enableArtifact: config.enableArtifact !== undefined ? config.enableArtifact : undefined,
1068
1078
  };
1069
1079
  }
1070
1080
  catch (error) {
@@ -1209,6 +1219,10 @@ export function loadMergedWaveConfig(workdir) {
1209
1219
  if (config.worktree !== undefined) {
1210
1220
  mergedConfig.worktree = config.worktree;
1211
1221
  }
1222
+ // Merge enableArtifact (last one wins)
1223
+ if (config.enableArtifact !== undefined) {
1224
+ mergedConfig.enableArtifact = config.enableArtifact;
1225
+ }
1212
1226
  // Merge models
1213
1227
  if (config.models) {
1214
1228
  if (!mergedConfig.models)
@@ -1247,5 +1261,6 @@ export function loadMergedWaveConfig(workdir) {
1247
1261
  ? mergedConfig.models
1248
1262
  : undefined,
1249
1263
  worktree: mergedConfig.worktree,
1264
+ enableArtifact: mergedConfig.enableArtifact,
1250
1265
  };
1251
1266
  }
@@ -64,6 +64,11 @@ export class InitializationService {
64
64
  const phaseStart = performance.now();
65
65
  // Load hooks configuration using ConfigurationService
66
66
  const configResult = await configurationService.loadMergedConfiguration(workdir);
67
+ // Rebuild subagent configurations now that settings.json env is loaded
68
+ // into the snapshot: conditional builtin subagents (model: visionModel
69
+ // → WAVE_VISION_MODEL) can only register once the env is available.
70
+ // Plugin agents are preserved by refreshConfigurations.
71
+ await subagentManager.refreshConfigurations();
67
72
  hookManager.loadConfigurationFromWaveConfig(configResult.configuration);
68
73
  // Update plugin manager with enabled plugins configuration
69
74
  if (configResult.configuration?.enabledPlugins) {
@@ -307,6 +307,8 @@ export function mergeRemoteSettings(localMerged, remote) {
307
307
  result.marketplaces = remote.marketplaces;
308
308
  if (remote.enabledPlugins !== undefined)
309
309
  result.enabledPlugins = remote.enabledPlugins;
310
+ if (remote.enableArtifact !== undefined)
311
+ result.enableArtifact = remote.enableArtifact;
310
312
  return result;
311
313
  }
312
314
  /**
@@ -172,7 +172,8 @@ When using the Agent tool, you must specify a subagent_type parameter to select
172
172
  success: true,
173
173
  content: `Agent backgrounded with ID: ${taskId}.${outputPath ? ` Real-time output: ${outputPath}` : ""}`,
174
174
  shortResult: "Agent backgrounded",
175
- isManuallyBackgrounded: true,
175
+ backgroundedByUser: true,
176
+ backgroundTaskId: taskId,
176
177
  });
177
178
  },
178
179
  });
@@ -0,0 +1,2 @@
1
+ import type { ToolPlugin } from "./types.js";
2
+ export declare const artifactTool: ToolPlugin;
@@ -0,0 +1,357 @@
1
+ import { readFileSync } from "fs";
2
+ import path from "path";
3
+ import { marked } from "marked";
4
+ import { ARTIFACT_TOOL_NAME } from "../constants/tools.js";
5
+ import { authService, createAuthAwareFetch } from "../services/authService.js";
6
+ import { logger } from "../utils/globalLogger.js";
7
+ import { recordArtifact, getArtifactByFilePath, getRecordedVersion, recordVersion, } from "../services/artifactSession.js";
8
+ // --- Limits ---
9
+ const DEPLOY_TIMEOUT_MS = 30000;
10
+ const PROBE_TIMEOUT_MS = 15000;
11
+ /** Server-side content limit — POSTs above this get a 413. */
12
+ const ARTIFACT_MAX_CONTENT_BYTES = 16 * 1024 * 1024; // 16MB
13
+ const LABEL_MAX_LENGTH = 60;
14
+ const DEFAULT_FAVICON = "📄";
15
+ function isValidFavicon(favicon) {
16
+ if (!favicon || favicon.trim().length === 0)
17
+ return false;
18
+ // Count code points excluding variation selectors (👨‍👩‍👧 counts as 3 and is
19
+ // rejected — only simple 1-2 emoji are accepted per the server contract).
20
+ const codePoints = [...favicon].filter((cp) => cp !== "\uFE0F");
21
+ if (codePoints.length < 1 || codePoints.length > 2)
22
+ return false;
23
+ return codePoints.every((cp) => {
24
+ // \p{Emoji} also matches ASCII digits/letters — plain text is not an emoji.
25
+ if (/[A-Za-z0-9]/.test(cp))
26
+ return false;
27
+ return /\p{Emoji}/u.test(cp);
28
+ });
29
+ }
30
+ /** Extract the artifact slug from a `{host}/code/artifact/{slug}` URL. */
31
+ function extractSlugFromUrl(url) {
32
+ try {
33
+ const parsed = new URL(url);
34
+ const match = parsed.pathname.match(/^\/code\/artifact\/([^/]+)\/?$/);
35
+ return match ? decodeURIComponent(match[1]) : null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ function escapeHtml(s) {
42
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
43
+ }
44
+ /** Render Markdown to a complete HTML document (client-side md→HTML). */
45
+ function renderMarkdown(md, title) {
46
+ const body = marked.parse(md, { async: false });
47
+ const titleTag = title ? `<title>${escapeHtml(title)}</title>` : "";
48
+ return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${titleTag}</head><body>${body}</body></html>`;
49
+ }
50
+ /** Probe an artifact's current metadata (`via=model_read` marks model access). */
51
+ async function probeFrame(slug, signal) {
52
+ const serverUrl = authService.getServerUrl();
53
+ const authFetch = createAuthAwareFetch(globalThis.fetch);
54
+ try {
55
+ const res = await authFetch(`${serverUrl}/api/frame/${encodeURIComponent(slug)}?via=model_read`, { method: "GET", signal });
56
+ if (res.status === 404)
57
+ return null;
58
+ if (!res.ok) {
59
+ logger?.warn("Artifact probe failed", {
60
+ slug,
61
+ status: res.status,
62
+ statusText: res.statusText,
63
+ });
64
+ return null;
65
+ }
66
+ return (await res.json());
67
+ }
68
+ catch (err) {
69
+ logger?.warn("Artifact probe error", { slug, error: String(err) });
70
+ return null;
71
+ }
72
+ }
73
+ export const artifactTool = {
74
+ name: ARTIFACT_TOOL_NAME,
75
+ isConcurrencySafe: false,
76
+ config: {
77
+ type: "function",
78
+ function: {
79
+ name: ARTIFACT_TOOL_NAME,
80
+ description: "Publish local HTML or Markdown files as shareable web pages (artifacts). " +
81
+ "Each publish returns a private URL you can share; the page is only accessible to you " +
82
+ "unless you change its sharing. Only .html and .md files are supported — inline content " +
83
+ "is not accepted. Markdown files are rendered to HTML automatically. " +
84
+ "Pass `url` (an existing artifact URL) to redeploy that artifact, " +
85
+ "or omit it to republish a file already published in this session. " +
86
+ "Use `force` to overwrite an artifact that has been updated by someone else.",
87
+ parameters: {
88
+ type: "object",
89
+ properties: {
90
+ file_path: {
91
+ type: "string",
92
+ description: "Path to the .html or .md file to publish (relative to the working directory). The file must exist.",
93
+ },
94
+ favicon: {
95
+ type: "string",
96
+ description: "1-2 emoji characters shown as the page favicon (no text, URLs, or HTML). Defaults to 📄.",
97
+ },
98
+ label: {
99
+ type: "string",
100
+ description: `Optional short label for the artifact (max ${LABEL_MAX_LENGTH} characters).`,
101
+ },
102
+ url: {
103
+ type: "string",
104
+ description: "Existing artifact URL to redeploy, e.g. https://host/code/artifact/abc123. Omit when republishing a file already published earlier in this session.",
105
+ },
106
+ force: {
107
+ type: "boolean",
108
+ description: "Set true to overwrite an artifact that was updated since this session last saw it (stale version or conflict).",
109
+ },
110
+ },
111
+ required: ["file_path"],
112
+ },
113
+ },
114
+ },
115
+ formatCompactParams: (params) => {
116
+ const filePath = typeof params.file_path === "string" ? params.file_path : "";
117
+ const url = typeof params.url === "string" ? params.url : "";
118
+ return `${ARTIFACT_TOOL_NAME}(${filePath}${url ? ` → ${url}` : ""})`;
119
+ },
120
+ execute: async (args, context) => {
121
+ const filePath = typeof args.file_path === "string" ? args.file_path.trim() : "";
122
+ if (!filePath) {
123
+ return {
124
+ success: false,
125
+ content: "",
126
+ error: `${ARTIFACT_TOOL_NAME}: missing required parameter "file_path"`,
127
+ };
128
+ }
129
+ const faviconRaw = typeof args.favicon === "string" ? args.favicon.trim() : "";
130
+ const favicon = faviconRaw || DEFAULT_FAVICON;
131
+ if (!isValidFavicon(favicon)) {
132
+ return {
133
+ success: false,
134
+ content: "",
135
+ error: `${ARTIFACT_TOOL_NAME}: favicon must be 1-2 emoji characters (e.g. "📄" or "🔖"), no text, URLs, or HTML markup`,
136
+ };
137
+ }
138
+ const labelRaw = typeof args.label === "string" ? args.label.trim() : "";
139
+ const label = labelRaw || undefined;
140
+ if (label !== undefined && label.length > LABEL_MAX_LENGTH) {
141
+ return {
142
+ success: false,
143
+ content: "",
144
+ error: `${ARTIFACT_TOOL_NAME}: label must be at most ${LABEL_MAX_LENGTH} characters (got ${label.length})`,
145
+ };
146
+ }
147
+ const force = args.force === true;
148
+ const urlRaw = typeof args.url === "string" ? args.url.trim() : "";
149
+ const url = urlRaw || undefined;
150
+ let slug;
151
+ if (url) {
152
+ slug = extractSlugFromUrl(url);
153
+ if (!slug) {
154
+ return {
155
+ success: false,
156
+ content: "",
157
+ error: `${ARTIFACT_TOOL_NAME}: url must point to an artifact page ({host}/code/artifact/{slug})`,
158
+ };
159
+ }
160
+ }
161
+ // Resolve and read the file (relative to the workdir).
162
+ const absolutePath = path.resolve(context.workdir, filePath);
163
+ const ext = path.extname(absolutePath).toLowerCase();
164
+ if (ext !== ".html" && ext !== ".md") {
165
+ return {
166
+ success: false,
167
+ content: "",
168
+ error: `${ARTIFACT_TOOL_NAME}: only .html and .md files can be published as artifacts (got "${ext || "no extension"}")`,
169
+ };
170
+ }
171
+ let fileContent;
172
+ try {
173
+ fileContent = readFileSync(absolutePath, "utf-8");
174
+ }
175
+ catch {
176
+ return {
177
+ success: false,
178
+ content: "",
179
+ error: `${ARTIFACT_TOOL_NAME}: file not found or unreadable: ${filePath}`,
180
+ };
181
+ }
182
+ const content = ext === ".md" ? renderMarkdown(fileContent, label) : fileContent;
183
+ const contentBytes = Buffer.byteLength(content, "utf-8");
184
+ if (contentBytes > ARTIFACT_MAX_CONTENT_BYTES) {
185
+ return {
186
+ success: false,
187
+ content: "",
188
+ error: `${ARTIFACT_TOOL_NAME}: content exceeds the ${Math.floor(ARTIFACT_MAX_CONTENT_BYTES / 1024 / 1024)}MB server limit (${(contentBytes / 1024 / 1024).toFixed(1)}MB). Reduce the file or split it up.`,
189
+ };
190
+ }
191
+ if (!authService.getSSOToken()) {
192
+ return {
193
+ success: false,
194
+ content: "",
195
+ error: `${ARTIFACT_TOOL_NAME}: not authenticated. Run /login to connect your account before publishing artifacts.`,
196
+ };
197
+ }
198
+ const sessionId = context.sessionId || "";
199
+ const signal = context.abortSignal
200
+ ? AbortSignal.any([
201
+ context.abortSignal,
202
+ AbortSignal.timeout(DEPLOY_TIMEOUT_MS),
203
+ ])
204
+ : AbortSignal.timeout(DEPLOY_TIMEOUT_MS);
205
+ // Redeploy: probe current metadata for baseVersion, shared-live detection,
206
+ // and the stale-version guard.
207
+ let serverVersion;
208
+ let sharedLive = false;
209
+ if (url && slug) {
210
+ const meta = await probeFrame(slug, AbortSignal.timeout(PROBE_TIMEOUT_MS));
211
+ if (!meta) {
212
+ return {
213
+ success: false,
214
+ content: "",
215
+ error: `${ARTIFACT_TOOL_NAME}: artifact not found at ${url}. It may have been deleted or the URL is invalid.`,
216
+ };
217
+ }
218
+ serverVersion = meta.version;
219
+ sharedLive = !!(meta.perm && meta.perm.mode !== "owner" && !meta.shared);
220
+ const recorded = getRecordedVersion(sessionId, slug);
221
+ if (recorded !== undefined && recorded !== meta.version && !force) {
222
+ return {
223
+ success: false,
224
+ content: "",
225
+ error: `${ARTIFACT_TOOL_NAME}: stale version — this artifact has been updated to version ${meta.version} since this session last saw version ${recorded}. Pass "force": true to overwrite it anyway.`,
226
+ };
227
+ }
228
+ }
229
+ // Permission check: first publish and shared-live redeploys require
230
+ // confirmation; republishing a file/artifact this session already
231
+ // confirmed once auto-allows (matching Claude Code's behavior).
232
+ const publishedThisSession = !!getArtifactByFilePath(sessionId, filePath) ||
233
+ (slug ? getRecordedVersion(sessionId, slug) !== undefined : false);
234
+ const needsConfirm = !publishedThisSession || sharedLive;
235
+ if (context.permissionManager && needsConfirm) {
236
+ const permissionContext = context.permissionManager.createContext(ARTIFACT_TOOL_NAME, context.permissionMode || "default", context.canUseToolCallback, {
237
+ file_path: filePath,
238
+ ...(favicon !== DEFAULT_FAVICON ? { favicon } : {}),
239
+ ...(label !== undefined ? { label } : {}),
240
+ ...(url !== undefined ? { url } : {}),
241
+ ...(force ? { force: true } : {}),
242
+ }, context.toolCallId);
243
+ if (sharedLive) {
244
+ permissionContext.warning =
245
+ "此 artifact 处于 shared-live 状态(共享且实时更新),重新部署后所有访问者都会立即看到新内容。";
246
+ permissionContext.hidePersistentOption = true;
247
+ }
248
+ const permissionResult = await context.permissionManager.checkPermission(permissionContext);
249
+ if (permissionResult.behavior === "deny") {
250
+ return {
251
+ success: false,
252
+ content: "",
253
+ error: `${ARTIFACT_TOOL_NAME} operation denied by user, reason: ${permissionResult.message || "No reason provided"}`,
254
+ };
255
+ }
256
+ }
257
+ // Deploy.
258
+ const serverUrl = authService.getServerUrl();
259
+ const authFetch = createAuthAwareFetch(globalThis.fetch);
260
+ const body = {
261
+ content,
262
+ favicon,
263
+ ...(label !== undefined ? { label } : {}),
264
+ ...(url !== undefined ? { url } : {}),
265
+ ...(serverVersion !== undefined ? { baseVersion: serverVersion } : {}),
266
+ ...(force ? { force: true } : {}),
267
+ };
268
+ let res;
269
+ try {
270
+ res = await authFetch(`${serverUrl}/api/frame/deploy/direct`, {
271
+ method: "POST",
272
+ headers: { "Content-Type": "application/json" },
273
+ body: JSON.stringify(body),
274
+ signal,
275
+ });
276
+ }
277
+ catch (err) {
278
+ logger?.warn("Artifact deploy request failed", { error: String(err) });
279
+ return {
280
+ success: false,
281
+ content: "",
282
+ error: `${ARTIFACT_TOOL_NAME}: failed to reach the server: ${err instanceof Error ? err.message : String(err)}`,
283
+ };
284
+ }
285
+ const data = (await res.json().catch(() => ({})));
286
+ if (res.status === 201) {
287
+ const deploy = data;
288
+ recordArtifact(sessionId, filePath, {
289
+ url: deploy.url,
290
+ slug: deploy.slug,
291
+ version: deploy.version,
292
+ });
293
+ const lines = [`Artifact published: ${deploy.url}`];
294
+ if (deploy.path)
295
+ lines.push(`Path: ${deploy.path}`);
296
+ if (deploy.title)
297
+ lines.push(`Title: ${deploy.title}`);
298
+ lines.push(`Version: ${deploy.version}`);
299
+ return {
300
+ success: true,
301
+ content: lines.join("\n"),
302
+ shortResult: `Published ${filePath} → ${deploy.url}`,
303
+ };
304
+ }
305
+ if (res.status === 409) {
306
+ const live = typeof data.live === "string" ? data.live : undefined;
307
+ if (live && slug) {
308
+ recordVersion(sessionId, slug, live);
309
+ }
310
+ const serverMessage = typeof data.message === "string"
311
+ ? data.message
312
+ : typeof data.error === "string"
313
+ ? data.error
314
+ : "the artifact has been updated by someone else";
315
+ return {
316
+ success: false,
317
+ content: "",
318
+ error: `${ARTIFACT_TOOL_NAME}: conflict detected — ${serverMessage}${live ? ` (live version: ${live})` : ""}. Pass "force": true to overwrite the live version.`,
319
+ };
320
+ }
321
+ if (res.status === 413) {
322
+ return {
323
+ success: false,
324
+ content: "",
325
+ error: `${ARTIFACT_TOOL_NAME}: the published content is too large (server limit is 16MB). Reduce the file or split it up.`,
326
+ };
327
+ }
328
+ if (res.status === 400) {
329
+ const serverMessage = typeof data.message === "string"
330
+ ? data.message
331
+ : typeof data.error === "string"
332
+ ? data.error
333
+ : "the server rejected the content";
334
+ return {
335
+ success: false,
336
+ content: "",
337
+ error: `${ARTIFACT_TOOL_NAME}: the server rejected the publish — ${serverMessage}`,
338
+ };
339
+ }
340
+ if (res.status === 401 || res.status === 403) {
341
+ return {
342
+ success: false,
343
+ content: "",
344
+ error: `${ARTIFACT_TOOL_NAME}: authentication failed (HTTP ${res.status}). Run /login again and retry.`,
345
+ };
346
+ }
347
+ logger?.warn("Artifact deploy unexpected status", {
348
+ status: res.status,
349
+ statusText: res.statusText,
350
+ });
351
+ return {
352
+ success: false,
353
+ content: "",
354
+ error: `${ARTIFACT_TOOL_NAME}: server returned HTTP ${res.status} ${res.statusText}`,
355
+ };
356
+ },
357
+ };