wave-agent-sdk 1.1.5 → 1.2.0
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.
- package/dist/agent.d.ts +70 -24
- package/dist/agent.js +110 -30
- package/dist/builtin/plugins.js +11 -20
- package/dist/builtin/skills/settings.js +6 -8
- package/dist/core/plugin.d.ts +4 -0
- package/dist/core/plugin.js +7 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/managers/aiManager.d.ts +0 -24
- package/dist/managers/aiManager.js +14 -181
- package/dist/managers/bashModeManager.d.ts +33 -0
- package/dist/managers/bashModeManager.js +110 -0
- package/dist/managers/hookManager.d.ts +5 -0
- package/dist/managers/hookManager.js +7 -0
- package/dist/managers/liveConfigManager.js +3 -3
- package/dist/managers/mcpManager.d.ts +23 -0
- package/dist/managers/mcpManager.js +162 -14
- package/dist/managers/messageManager.d.ts +12 -13
- package/dist/managers/messageManager.js +63 -60
- package/dist/managers/permissionManager.d.ts +29 -0
- package/dist/managers/permissionManager.js +158 -70
- package/dist/managers/planManager.d.ts +9 -0
- package/dist/managers/planManager.js +19 -1
- package/dist/managers/skillManager.d.ts +31 -0
- package/dist/managers/skillManager.js +122 -12
- package/dist/managers/slashCommandManager.js +9 -28
- package/dist/managers/subagentManager.d.ts +7 -0
- package/dist/managers/subagentManager.js +61 -9
- package/dist/managers/workflowManager.js +6 -0
- package/dist/prompts/index.d.ts +0 -1
- package/dist/prompts/index.js +0 -4
- package/dist/services/MarketplaceService.js +36 -14
- package/dist/services/configurationService.d.ts +34 -2
- package/dist/services/configurationService.js +123 -16
- package/dist/services/initializationService.js +2 -2
- package/dist/services/jsonlHandler.d.ts +14 -0
- package/dist/services/jsonlHandler.js +44 -1
- package/dist/services/memory.d.ts +14 -0
- package/dist/services/memory.js +33 -0
- package/dist/services/officialMarketplaceMirror.d.ts +85 -0
- package/dist/services/officialMarketplaceMirror.js +289 -0
- package/dist/services/remoteSettingsService.js +4 -4
- package/dist/services/session.js +30 -13
- package/dist/services/worktreeHooks.js +6 -1
- package/dist/stdio/index.d.ts +10 -0
- package/dist/stdio/index.js +10 -0
- package/dist/stdio/notificationRouter.d.ts +38 -0
- package/dist/stdio/notificationRouter.js +96 -0
- package/dist/stdio/rpcClient.d.ts +18 -0
- package/dist/stdio/rpcClient.js +10 -0
- package/dist/stdio/stdioAgent.d.ts +222 -0
- package/dist/stdio/stdioAgent.js +341 -0
- package/dist/tools/bashTool.js +2 -0
- package/dist/tools/exitPlanMode.js +10 -2
- package/dist/types/agent.d.ts +8 -0
- package/dist/types/commands.d.ts +7 -0
- package/dist/types/configuration.d.ts +6 -1
- package/dist/types/hooks.d.ts +1 -0
- package/dist/types/hooks.js +19 -0
- package/dist/types/mcp.d.ts +3 -0
- package/dist/types/messaging.d.ts +1 -8
- package/dist/types/skills.d.ts +11 -0
- package/dist/utils/bashParser.d.ts +17 -0
- package/dist/utils/bashParser.js +72 -0
- package/dist/utils/bashStructure/bashLexer.d.ts +96 -0
- package/dist/utils/bashStructure/bashLexer.js +676 -0
- package/dist/utils/bashStructure/bashParser.d.ts +144 -0
- package/dist/utils/bashStructure/bashParser.js +606 -0
- package/dist/utils/bashStructure/bashSemantics.d.ts +70 -0
- package/dist/utils/bashStructure/bashSemantics.js +477 -0
- package/dist/utils/bashStructure/index.d.ts +26 -0
- package/dist/utils/bashStructure/index.js +27 -0
- package/dist/utils/bashStructure/types.d.ts +62 -0
- package/dist/utils/bashStructure/types.js +47 -0
- package/dist/utils/containerSetup.js +5 -6
- package/dist/utils/fileUtils.d.ts +11 -0
- package/dist/utils/fileUtils.js +37 -0
- package/dist/utils/messageOperations.d.ts +0 -18
- package/dist/utils/messageOperations.js +0 -62
- package/dist/utils/subagentParser.js +9 -2
- package/dist/utils/tokenCalculation.js +0 -8
- package/dist/utils/worktreeUtils.d.ts +2 -1
- package/dist/utils/worktreeUtils.js +64 -34
- package/package.json +6 -1
- package/dist/managers/bangManager.d.ts +0 -26
- package/dist/managers/bangManager.js +0 -78
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { appendFile, readFile, writeFile, stat, mkdir } from "fs/promises";
|
|
6
6
|
import { dirname } from "path";
|
|
7
|
-
import { getLastLine, readFirstNLines } from "../utils/fileUtils.js";
|
|
7
|
+
import { getLastLine, readFirstNLines, readTailLines, } from "../utils/fileUtils.js";
|
|
8
|
+
import { extractLatestTotalTokens } from "../utils/tokenCalculation.js";
|
|
8
9
|
/**
|
|
9
10
|
* JSONL handler class for message persistence operations
|
|
10
11
|
*/
|
|
@@ -88,6 +89,10 @@ export class JsonlHandler {
|
|
|
88
89
|
async read(filePath) {
|
|
89
90
|
try {
|
|
90
91
|
const content = await readFile(filePath, "utf8");
|
|
92
|
+
// append() always terminates a written batch with "\n" (see append),
|
|
93
|
+
// so a file that does not end with a newline means the process was
|
|
94
|
+
// interrupted mid-write, leaving a partial trailing line behind.
|
|
95
|
+
const endsWithNewline = content.length === 0 || content.endsWith("\n");
|
|
91
96
|
const lines = content
|
|
92
97
|
.split(/\r?\n/)
|
|
93
98
|
.map((line) => line.trim())
|
|
@@ -108,6 +113,14 @@ export class JsonlHandler {
|
|
|
108
113
|
allMessages.push(message);
|
|
109
114
|
}
|
|
110
115
|
catch (error) {
|
|
116
|
+
// A parse failure on the final line of a file that lacks a trailing
|
|
117
|
+
// newline is the residue of an interrupted append (complete batches
|
|
118
|
+
// always end with "\n"): drop the partial line and keep every
|
|
119
|
+
// message that was fully written. Corruption on any earlier line is
|
|
120
|
+
// genuine corruption and still fails.
|
|
121
|
+
if (!endsWithNewline && i === lines.length - 1) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
111
124
|
// Throw error for invalid JSON lines with line number
|
|
112
125
|
throw new Error(`Invalid JSON at line ${i + 1}: ${error}`);
|
|
113
126
|
}
|
|
@@ -157,6 +170,36 @@ export class JsonlHandler {
|
|
|
157
170
|
throw new Error(`Failed to get last message from "${filePath}": ${error}`);
|
|
158
171
|
}
|
|
159
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Latest context-usage total for a session file.
|
|
175
|
+
*
|
|
176
|
+
* Sessions accumulate usage-less messages at the end — SessionStart hook
|
|
177
|
+
* meta messages are appended (and persisted) on every resume — so the last
|
|
178
|
+
* line alone cannot answer "how much context did this conversation use".
|
|
179
|
+
* Scan backwards over the file's tail window and take the newest message
|
|
180
|
+
* that carries usage.
|
|
181
|
+
*
|
|
182
|
+
* @param filePath - Path to the session JSONL file
|
|
183
|
+
* @returns total_tokens of the newest usage-bearing message, or 0 when the
|
|
184
|
+
* tail holds none (empty/unreadable file, no completed request yet)
|
|
185
|
+
*/
|
|
186
|
+
async getLatestTotalTokens(filePath) {
|
|
187
|
+
const messages = [];
|
|
188
|
+
for (const line of await readTailLines(filePath)) {
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(line);
|
|
191
|
+
// Metadata header line: not a message, skip
|
|
192
|
+
if (parsed.type === "metadata")
|
|
193
|
+
continue;
|
|
194
|
+
messages.push(parsed);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Partial line at the tail window's boundary — drop it.
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return extractLatestTotalTokens(messages);
|
|
202
|
+
}
|
|
160
203
|
/**
|
|
161
204
|
* Read the creation-time metadata from the session file's header line.
|
|
162
205
|
*
|
|
@@ -26,4 +26,18 @@ export declare class MemoryService {
|
|
|
26
26
|
getUserMemoryContent(): Promise<string>;
|
|
27
27
|
readMemoryFile(workdir: string): Promise<string>;
|
|
28
28
|
getCombinedMemoryContent(workdir: string): Promise<string>;
|
|
29
|
+
/**
|
|
30
|
+
* Persist the user-level memory file (~/.wave/AGENTS.md), creating it if
|
|
31
|
+
* missing. Clears the user-memory cache so subsequent reads see the new
|
|
32
|
+
* content (the default-template fallback to ~/.claude/AGENTS.md is also
|
|
33
|
+
* bypassed once the user writes real content).
|
|
34
|
+
*/
|
|
35
|
+
writeUserMemoryContent(content: string): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Persist the project-level memory file (<workdir>/AGENTS.md), creating it
|
|
38
|
+
* if missing. Reads fall back to CLAUDE.md but writes always target
|
|
39
|
+
* AGENTS.md (the canonical project memory file). Clears the project-memory
|
|
40
|
+
* cache so subsequent reads see the new content.
|
|
41
|
+
*/
|
|
42
|
+
writeProjectMemoryContent(workdir: string, content: string): Promise<void>;
|
|
29
43
|
}
|
package/dist/services/memory.js
CHANGED
|
@@ -215,4 +215,37 @@ export class MemoryService {
|
|
|
215
215
|
this._cachedCombinedMemory = combined;
|
|
216
216
|
return combined;
|
|
217
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* Persist the user-level memory file (~/.wave/AGENTS.md), creating it if
|
|
220
|
+
* missing. Clears the user-memory cache so subsequent reads see the new
|
|
221
|
+
* content (the default-template fallback to ~/.claude/AGENTS.md is also
|
|
222
|
+
* bypassed once the user writes real content).
|
|
223
|
+
*/
|
|
224
|
+
async writeUserMemoryContent(content) {
|
|
225
|
+
await this.ensureUserMemoryFile();
|
|
226
|
+
await fs.writeFile(USER_MEMORY_FILE, content, "utf-8");
|
|
227
|
+
this._cachedUserMemory = null;
|
|
228
|
+
this._cachedCombinedMemory = null;
|
|
229
|
+
logger.debug("User memory content written", {
|
|
230
|
+
userMemoryFile: USER_MEMORY_FILE,
|
|
231
|
+
contentLength: content.length,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Persist the project-level memory file (<workdir>/AGENTS.md), creating it
|
|
236
|
+
* if missing. Reads fall back to CLAUDE.md but writes always target
|
|
237
|
+
* AGENTS.md (the canonical project memory file). Clears the project-memory
|
|
238
|
+
* cache so subsequent reads see the new content.
|
|
239
|
+
*/
|
|
240
|
+
async writeProjectMemoryContent(workdir, content) {
|
|
241
|
+
const memoryFilePath = path.join(workdir, "AGENTS.md");
|
|
242
|
+
await fs.mkdir(workdir, { recursive: true });
|
|
243
|
+
await fs.writeFile(memoryFilePath, content, "utf-8");
|
|
244
|
+
this._cachedProjectMemory = null;
|
|
245
|
+
this._cachedCombinedMemory = null;
|
|
246
|
+
logger.debug("Project memory content written", {
|
|
247
|
+
memoryFilePath,
|
|
248
|
+
contentLength: content.length,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
218
251
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builtin official marketplace zip-snapshot mirror fetch.
|
|
3
|
+
*
|
|
4
|
+
* The official marketplace (wave-plugins-official, BUILTIN in MarketplaceService)
|
|
5
|
+
* is hosted on GitHub, which is poorly reachable from CN networks. Instead of a
|
|
6
|
+
* dumb-HTTP git endpoint we mirror the "content-addressed zip + latest pointer +
|
|
7
|
+
* local sentinel" model Claude Code uses for its official marketplace
|
|
8
|
+
* (officialMarketplaceGcs.ts, inc-5046):
|
|
9
|
+
*
|
|
10
|
+
* 1. GET `{base}/latest` (10s timeout) → content-addressed sha
|
|
11
|
+
* 2. Compare against the local sentinel `.wave-market-sha` at the market root
|
|
12
|
+
* — equal ⇒ no-op (idempotent, one ~40B request per run)
|
|
13
|
+
* 3. GET `{base}/{sha}.zip` (60s timeout) → extract into a `.staging` dir
|
|
14
|
+
* (path-traversal/absolute-path rejection, size limits, exec-bit restore
|
|
15
|
+
* from zip external attrs)
|
|
16
|
+
* 4. Atomic swap: rm old market dir + rename staging → market dir, write the
|
|
17
|
+
* sentinel back
|
|
18
|
+
*
|
|
19
|
+
* Any failure returns null; the caller decides whether to fall through to the
|
|
20
|
+
* existing git path (guarded by a kill switch, mirroring Claude's
|
|
21
|
+
* tengu_plugin_official_mkt_git_fallback flag).
|
|
22
|
+
*/
|
|
23
|
+
/** Sentinel file name stored at the market root holding the last fetched sha. */
|
|
24
|
+
export declare const OFFICIAL_MARKET_SENTINEL_FILE = ".wave-market-sha";
|
|
25
|
+
/** Env var override for the mirror base URL (highest precedence). */
|
|
26
|
+
export declare const OFFICIAL_MARKET_MIRROR_BASE_URL_ENV = "WAVE_OFFICIAL_MARKET_MIRROR_BASE_URL";
|
|
27
|
+
/**
|
|
28
|
+
* Kill switch for the git fallback after a mirror failure (default: allowed).
|
|
29
|
+
* Mirrors Claude Code's `tengu_plugin_official_mkt_git_fallback` flag. When
|
|
30
|
+
* false and the mirror fails, the builtin marketplace update is skipped
|
|
31
|
+
* (retried on next run) instead of hitting GitHub.
|
|
32
|
+
*/
|
|
33
|
+
export declare const ALLOW_OFFICIAL_MARKET_GIT_FALLBACK = true;
|
|
34
|
+
/**
|
|
35
|
+
* Resolves the mirror base URL: env var wins, otherwise the built-in constant.
|
|
36
|
+
* Returns null when neither is configured (mirror channel disabled).
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveOfficialMarketplaceMirrorBaseUrl(): string | null;
|
|
39
|
+
/**
|
|
40
|
+
* Rejects zip entry paths that would escape the extraction root: absolute
|
|
41
|
+
* paths (POSIX or Windows drive/UNC), `..` segments, and null bytes. Paths
|
|
42
|
+
* use either `/` or `\` as separator. Returns the normalized relative path on
|
|
43
|
+
* success or null for entries that must be skipped (empty/directory-only is
|
|
44
|
+
* handled by the caller via the trailing `/`).
|
|
45
|
+
*/
|
|
46
|
+
export declare function sanitizeZipEntryPath(entryPath: string): string | null;
|
|
47
|
+
/**
|
|
48
|
+
* Fetches the official marketplace from the zip-snapshot mirror and extracts
|
|
49
|
+
* it to installLocation. Idempotent — compares the local `.wave-market-sha`
|
|
50
|
+
* sentinel before downloading.
|
|
51
|
+
*
|
|
52
|
+
* @param installLocation where to extract (must be inside marketplacesCacheDir)
|
|
53
|
+
* @param marketplacesCacheDir the marketplace cache root (defense-in-depth:
|
|
54
|
+
* this function `rm`s installLocation during the atomic swap)
|
|
55
|
+
* @param baseUrl optional explicit base URL (tests); defaults to env/constant
|
|
56
|
+
* @returns the fetched sha on success (including no-op), null on any failure
|
|
57
|
+
* (unconfigured mirror, network, 404, corrupt zip, unsafe entries). Caller
|
|
58
|
+
* decides whether to fall through to git.
|
|
59
|
+
*/
|
|
60
|
+
export declare function fetchOfficialMarketplaceFromMirror(installLocation: string, marketplacesCacheDir: string, baseUrl?: string): Promise<string | null>;
|
|
61
|
+
interface ZipValidationState {
|
|
62
|
+
fileCount: number;
|
|
63
|
+
totalUncompressed: number;
|
|
64
|
+
compressedSize: number;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Validates one zip entry (called from the fflate filter). Throws to abort the
|
|
68
|
+
* whole extraction when an entry is unsafe or the archive exceeds limits.
|
|
69
|
+
*/
|
|
70
|
+
export declare function validateZipEntry(name: string, originalSize: number, state: ZipValidationState): void;
|
|
71
|
+
/**
|
|
72
|
+
* Parse Unix file modes from a zip's central directory.
|
|
73
|
+
*
|
|
74
|
+
* fflate's `unzipSync` returns only `Record<string, Uint8Array>` — it does not
|
|
75
|
+
* surface the external file attributes stored in the central directory, so
|
|
76
|
+
* executable bits would be lost (everything extracts as 0644). Returns
|
|
77
|
+
* `name → mode` for entries created on a Unix host (`versionMadeBy` high byte
|
|
78
|
+
* === 3); other entries are omitted and extract with default mode.
|
|
79
|
+
*
|
|
80
|
+
* Format per PKZIP APPNOTE.TXT §4.3.12 (central directory) and §4.3.16 (EOCD).
|
|
81
|
+
* ZIP64 is not handled — returns `{}` on archives >4GB or >65535 entries,
|
|
82
|
+
* which is fine for marketplace zips.
|
|
83
|
+
*/
|
|
84
|
+
export declare function parseZipModes(data: Uint8Array): Record<string, number>;
|
|
85
|
+
export {};
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builtin official marketplace zip-snapshot mirror fetch.
|
|
3
|
+
*
|
|
4
|
+
* The official marketplace (wave-plugins-official, BUILTIN in MarketplaceService)
|
|
5
|
+
* is hosted on GitHub, which is poorly reachable from CN networks. Instead of a
|
|
6
|
+
* dumb-HTTP git endpoint we mirror the "content-addressed zip + latest pointer +
|
|
7
|
+
* local sentinel" model Claude Code uses for its official marketplace
|
|
8
|
+
* (officialMarketplaceGcs.ts, inc-5046):
|
|
9
|
+
*
|
|
10
|
+
* 1. GET `{base}/latest` (10s timeout) → content-addressed sha
|
|
11
|
+
* 2. Compare against the local sentinel `.wave-market-sha` at the market root
|
|
12
|
+
* — equal ⇒ no-op (idempotent, one ~40B request per run)
|
|
13
|
+
* 3. GET `{base}/{sha}.zip` (60s timeout) → extract into a `.staging` dir
|
|
14
|
+
* (path-traversal/absolute-path rejection, size limits, exec-bit restore
|
|
15
|
+
* from zip external attrs)
|
|
16
|
+
* 4. Atomic swap: rm old market dir + rename staging → market dir, write the
|
|
17
|
+
* sentinel back
|
|
18
|
+
*
|
|
19
|
+
* Any failure returns null; the caller decides whether to fall through to the
|
|
20
|
+
* existing git path (guarded by a kill switch, mirroring Claude's
|
|
21
|
+
* tengu_plugin_official_mkt_git_fallback flag).
|
|
22
|
+
*/
|
|
23
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
|
|
24
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
25
|
+
import { unzipSync } from "fflate";
|
|
26
|
+
import { logWarn } from "../utils/globalLogger.js";
|
|
27
|
+
/** Sentinel file name stored at the market root holding the last fetched sha. */
|
|
28
|
+
export const OFFICIAL_MARKET_SENTINEL_FILE = ".wave-market-sha";
|
|
29
|
+
/** Suffix of the staging dir used for atomic swap into the market root. */
|
|
30
|
+
const STAGING_SUFFIX = ".staging";
|
|
31
|
+
/**
|
|
32
|
+
* Default mirror base URL (prod contract) — files hang directly off this base:
|
|
33
|
+
* `{base}/latest` (pure-text sha) and `{base}/{sha}.zip` (whole-tree snapshot).
|
|
34
|
+
* Trailing slash is normalized away when composing URLs.
|
|
35
|
+
*
|
|
36
|
+
* - PROD: `https://codechat.codewave.163.com/wave-plugins-official/`
|
|
37
|
+
* - TEST(验证用): `https://codechat.codewave-test.163yun.com/wave-plugins-official/`
|
|
38
|
+
*
|
|
39
|
+
* Override with env var `WAVE_OFFICIAL_MARKET_MIRROR_BASE_URL` (highest
|
|
40
|
+
* precedence). 注意:prod 该 URL 的 ingress/内容尚未上线,镜像请求会 404 → 按既有
|
|
41
|
+
* 设计回退 git 兜底(ALLOW_OFFICIAL_MARKET_GIT_FALLBACK),行为安全。
|
|
42
|
+
*/
|
|
43
|
+
const DEFAULT_OFFICIAL_MARKET_MIRROR_BASE_URL = "https://codechat.codewave.163.com/wave-plugins-official/";
|
|
44
|
+
/** Env var override for the mirror base URL (highest precedence). */
|
|
45
|
+
export const OFFICIAL_MARKET_MIRROR_BASE_URL_ENV = "WAVE_OFFICIAL_MARKET_MIRROR_BASE_URL";
|
|
46
|
+
/**
|
|
47
|
+
* Kill switch for the git fallback after a mirror failure (default: allowed).
|
|
48
|
+
* Mirrors Claude Code's `tengu_plugin_official_mkt_git_fallback` flag. When
|
|
49
|
+
* false and the mirror fails, the builtin marketplace update is skipped
|
|
50
|
+
* (retried on next run) instead of hitting GitHub.
|
|
51
|
+
*/
|
|
52
|
+
export const ALLOW_OFFICIAL_MARKET_GIT_FALLBACK = true;
|
|
53
|
+
const LATEST_TIMEOUT_MS = 10000;
|
|
54
|
+
const ZIP_TIMEOUT_MS = 60000;
|
|
55
|
+
// Sanity limits against corrupt/malicious archives (zip bombs). The publisher
|
|
56
|
+
// is first-party, so these are corruption guards rather than a trust boundary;
|
|
57
|
+
// values follow the Claude Code reference (utils/dxt/zip.ts).
|
|
58
|
+
const ZIP_LIMITS = {
|
|
59
|
+
MAX_SINGLE_FILE_SIZE: 512 * 1024 * 1024, // per-file uncompressed
|
|
60
|
+
MAX_TOTAL_SIZE: 1024 * 1024 * 1024, // total uncompressed
|
|
61
|
+
MAX_FILE_COUNT: 100000,
|
|
62
|
+
MAX_COMPRESSION_RATIO: 50, // uncompressed : compressed
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Resolves the mirror base URL: env var wins, otherwise the built-in constant.
|
|
66
|
+
* Returns null when neither is configured (mirror channel disabled).
|
|
67
|
+
*/
|
|
68
|
+
export function resolveOfficialMarketplaceMirrorBaseUrl() {
|
|
69
|
+
const envUrl = process.env[OFFICIAL_MARKET_MIRROR_BASE_URL_ENV];
|
|
70
|
+
const candidate = (envUrl && envUrl.trim()) || DEFAULT_OFFICIAL_MARKET_MIRROR_BASE_URL;
|
|
71
|
+
if (!candidate)
|
|
72
|
+
return null;
|
|
73
|
+
return candidate.replace(/\/+$/, ""); // normalize trailing slashes
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Rejects zip entry paths that would escape the extraction root: absolute
|
|
77
|
+
* paths (POSIX or Windows drive/UNC), `..` segments, and null bytes. Paths
|
|
78
|
+
* use either `/` or `\` as separator. Returns the normalized relative path on
|
|
79
|
+
* success or null for entries that must be skipped (empty/directory-only is
|
|
80
|
+
* handled by the caller via the trailing `/`).
|
|
81
|
+
*/
|
|
82
|
+
export function sanitizeZipEntryPath(entryPath) {
|
|
83
|
+
if (!entryPath || entryPath.includes("\0"))
|
|
84
|
+
return null;
|
|
85
|
+
if (entryPath.startsWith("/") || entryPath.startsWith("\\"))
|
|
86
|
+
return null;
|
|
87
|
+
const segments = entryPath.split(/[\\/]/);
|
|
88
|
+
// A drive-letter prefix (Windows "C:/…") must not slip through as relative.
|
|
89
|
+
if (segments[0] && /^[A-Za-z]:$/.test(segments[0]))
|
|
90
|
+
return null;
|
|
91
|
+
for (const seg of segments) {
|
|
92
|
+
if (seg === "..")
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return entryPath;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Fetches the official marketplace from the zip-snapshot mirror and extracts
|
|
99
|
+
* it to installLocation. Idempotent — compares the local `.wave-market-sha`
|
|
100
|
+
* sentinel before downloading.
|
|
101
|
+
*
|
|
102
|
+
* @param installLocation where to extract (must be inside marketplacesCacheDir)
|
|
103
|
+
* @param marketplacesCacheDir the marketplace cache root (defense-in-depth:
|
|
104
|
+
* this function `rm`s installLocation during the atomic swap)
|
|
105
|
+
* @param baseUrl optional explicit base URL (tests); defaults to env/constant
|
|
106
|
+
* @returns the fetched sha on success (including no-op), null on any failure
|
|
107
|
+
* (unconfigured mirror, network, 404, corrupt zip, unsafe entries). Caller
|
|
108
|
+
* decides whether to fall through to git.
|
|
109
|
+
*/
|
|
110
|
+
export async function fetchOfficialMarketplaceFromMirror(installLocation, marketplacesCacheDir, baseUrl) {
|
|
111
|
+
// Defense in depth: this function does `rm(installLocation, {recursive})`
|
|
112
|
+
// during the atomic swap. A corrupted known_marketplaces.json could point
|
|
113
|
+
// at the user's project. Refuse any path outside the marketplace cache dir.
|
|
114
|
+
// Same guard as Claude's officialMarketplaceGcs.ts:57-65.
|
|
115
|
+
const cacheDir = resolve(marketplacesCacheDir);
|
|
116
|
+
const resolvedLoc = resolve(installLocation);
|
|
117
|
+
if (resolvedLoc !== cacheDir && !resolvedLoc.startsWith(cacheDir + sep)) {
|
|
118
|
+
logWarn(`Official marketplace mirror: refusing install location outside marketplaces cache dir: ${installLocation}`);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const effectiveBase = baseUrl ?? resolveOfficialMarketplaceMirrorBaseUrl();
|
|
122
|
+
if (!effectiveBase)
|
|
123
|
+
return null; // mirror not configured
|
|
124
|
+
try {
|
|
125
|
+
// 1. Latest pointer — tiny request, hit on every startup.
|
|
126
|
+
const latestText = await httpGetText(`${effectiveBase}/latest`, LATEST_TIMEOUT_MS);
|
|
127
|
+
const sha = latestText.trim();
|
|
128
|
+
if (!sha) {
|
|
129
|
+
// Empty /latest body — mirror misconfigured. Bail (null), don't lock
|
|
130
|
+
// into a permanently-broken empty-sentinel state.
|
|
131
|
+
throw new Error("Official marketplace mirror: latest pointer returned empty body");
|
|
132
|
+
}
|
|
133
|
+
// sha is interpolated into a URL — keep it a sane opaque token.
|
|
134
|
+
if (!/^[^\s/\\]+$/.test(sha)) {
|
|
135
|
+
throw new Error(`Official marketplace mirror: invalid sha from latest pointer`);
|
|
136
|
+
}
|
|
137
|
+
// 2. Sentinel check.
|
|
138
|
+
const currentSha = await readFile(join(installLocation, OFFICIAL_MARKET_SENTINEL_FILE), "utf8")
|
|
139
|
+
.then((s) => s.trim())
|
|
140
|
+
.catch(() => null); // ENOENT — first fetch, proceed to download
|
|
141
|
+
if (currentSha === sha)
|
|
142
|
+
return sha;
|
|
143
|
+
// 3. Download zip and extract to a staging dir, then atomic-swap into
|
|
144
|
+
// place. A crash mid-extract leaves a .staging dir (rm'd on the next
|
|
145
|
+
// run) rather than a half-written installLocation.
|
|
146
|
+
const zipBuf = await httpGetBuffer(`${effectiveBase}/${sha}.zip`, ZIP_TIMEOUT_MS);
|
|
147
|
+
const zipState = {
|
|
148
|
+
fileCount: 0,
|
|
149
|
+
totalUncompressed: 0,
|
|
150
|
+
compressedSize: zipBuf.length,
|
|
151
|
+
};
|
|
152
|
+
const files = unzipSync(new Uint8Array(zipBuf), {
|
|
153
|
+
filter: (file) => {
|
|
154
|
+
validateZipEntry(file.name, file.originalSize, zipState);
|
|
155
|
+
return true;
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
// fflate doesn't surface external_attr, so parse the central directory
|
|
159
|
+
// ourselves to recover exec bits. Without this, hooks/scripts extract as
|
|
160
|
+
// 0644 and fail to execute — git clone preserves +x natively, so the zip
|
|
161
|
+
// path must too.
|
|
162
|
+
const modes = parseZipModes(zipBuf);
|
|
163
|
+
const staging = `${installLocation}${STAGING_SUFFIX}`;
|
|
164
|
+
await rm(staging, { recursive: true, force: true });
|
|
165
|
+
await mkdir(staging, { recursive: true });
|
|
166
|
+
for (const [entryName, data] of Object.entries(files)) {
|
|
167
|
+
if (!entryName || entryName.endsWith("/"))
|
|
168
|
+
continue; // dir entries
|
|
169
|
+
const rel = sanitizeZipEntryPath(entryName);
|
|
170
|
+
if (!rel)
|
|
171
|
+
continue; // validated in filter; skip defensively
|
|
172
|
+
const dest = join(staging, rel);
|
|
173
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
174
|
+
await writeFile(dest, data);
|
|
175
|
+
const mode = modes[entryName];
|
|
176
|
+
if (mode && mode & 0o111) {
|
|
177
|
+
// Only chmod when an exec bit is set — skip plain files to save
|
|
178
|
+
// syscalls. Swallow EPERM/ENOTSUP (read-only mounts) — losing +x is
|
|
179
|
+
// better than aborting mid-extraction.
|
|
180
|
+
await chmod(dest, mode & 0o777).catch(() => { });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
await writeFile(join(staging, OFFICIAL_MARKET_SENTINEL_FILE), sha);
|
|
184
|
+
// 4. Atomic swap: rm old, rename staging. Brief window where
|
|
185
|
+
// installLocation doesn't exist — acceptable for a background refresh
|
|
186
|
+
// (next run re-downloads if it crashes here).
|
|
187
|
+
await rm(installLocation, { recursive: true, force: true });
|
|
188
|
+
await rename(staging, installLocation);
|
|
189
|
+
return sha;
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
logWarn(`Official marketplace mirror fetch failed (falling back per caller policy): ${error instanceof Error ? error.message : String(error)}`);
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Validates one zip entry (called from the fflate filter). Throws to abort the
|
|
198
|
+
* whole extraction when an entry is unsafe or the archive exceeds limits.
|
|
199
|
+
*/
|
|
200
|
+
export function validateZipEntry(name, originalSize, state) {
|
|
201
|
+
state.fileCount++;
|
|
202
|
+
if (state.fileCount > ZIP_LIMITS.MAX_FILE_COUNT) {
|
|
203
|
+
throw new Error(`Official marketplace zip contains too many files (${state.fileCount}, max ${ZIP_LIMITS.MAX_FILE_COUNT})`);
|
|
204
|
+
}
|
|
205
|
+
const clean = sanitizeZipEntryPath(name);
|
|
206
|
+
if (!clean) {
|
|
207
|
+
// Reject unsafe entries (path traversal, absolute paths, null bytes).
|
|
208
|
+
// Directory entries are validated too — a `../` dir entry aborts.
|
|
209
|
+
throw new Error(`Official marketplace zip contains unsafe path: "${name}"`);
|
|
210
|
+
}
|
|
211
|
+
if (originalSize > ZIP_LIMITS.MAX_SINGLE_FILE_SIZE) {
|
|
212
|
+
throw new Error(`Official marketplace zip entry "${name}" too large (${originalSize} bytes, max ${ZIP_LIMITS.MAX_SINGLE_FILE_SIZE})`);
|
|
213
|
+
}
|
|
214
|
+
state.totalUncompressed += originalSize;
|
|
215
|
+
if (state.totalUncompressed > ZIP_LIMITS.MAX_TOTAL_SIZE) {
|
|
216
|
+
throw new Error(`Official marketplace zip total size exceeds ${ZIP_LIMITS.MAX_TOTAL_SIZE} bytes`);
|
|
217
|
+
}
|
|
218
|
+
if (state.compressedSize > 0 &&
|
|
219
|
+
state.totalUncompressed / state.compressedSize >
|
|
220
|
+
ZIP_LIMITS.MAX_COMPRESSION_RATIO) {
|
|
221
|
+
throw new Error(`Official marketplace zip suspicious compression ratio (>${ZIP_LIMITS.MAX_COMPRESSION_RATIO}:1) — possible zip bomb`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function httpGetText(url, timeoutMs) {
|
|
225
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
226
|
+
if (!response.ok) {
|
|
227
|
+
throw new Error(`HTTP ${response.status} fetching ${url}`);
|
|
228
|
+
}
|
|
229
|
+
return response.text();
|
|
230
|
+
}
|
|
231
|
+
async function httpGetBuffer(url, timeoutMs) {
|
|
232
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
233
|
+
if (!response.ok) {
|
|
234
|
+
throw new Error(`HTTP ${response.status} fetching ${url}`);
|
|
235
|
+
}
|
|
236
|
+
return Buffer.from(await response.arrayBuffer());
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Parse Unix file modes from a zip's central directory.
|
|
240
|
+
*
|
|
241
|
+
* fflate's `unzipSync` returns only `Record<string, Uint8Array>` — it does not
|
|
242
|
+
* surface the external file attributes stored in the central directory, so
|
|
243
|
+
* executable bits would be lost (everything extracts as 0644). Returns
|
|
244
|
+
* `name → mode` for entries created on a Unix host (`versionMadeBy` high byte
|
|
245
|
+
* === 3); other entries are omitted and extract with default mode.
|
|
246
|
+
*
|
|
247
|
+
* Format per PKZIP APPNOTE.TXT §4.3.12 (central directory) and §4.3.16 (EOCD).
|
|
248
|
+
* ZIP64 is not handled — returns `{}` on archives >4GB or >65535 entries,
|
|
249
|
+
* which is fine for marketplace zips.
|
|
250
|
+
*/
|
|
251
|
+
export function parseZipModes(data) {
|
|
252
|
+
const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
253
|
+
const modes = {};
|
|
254
|
+
// 1. Find the End of Central Directory record (sig 0x06054b50). Scan
|
|
255
|
+
// backwards from the trailing 22 + max-comment bytes.
|
|
256
|
+
const minEocd = Math.max(0, buf.length - 22 - 0xffff);
|
|
257
|
+
let eocd = -1;
|
|
258
|
+
for (let i = buf.length - 22; i >= minEocd; i--) {
|
|
259
|
+
if (buf.readUInt32LE(i) === 0x06054b50) {
|
|
260
|
+
eocd = i;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (eocd < 0)
|
|
265
|
+
return modes; // malformed — the unzip error surfaces separately
|
|
266
|
+
const entryCount = buf.readUInt16LE(eocd + 10);
|
|
267
|
+
let off = buf.readUInt32LE(eocd + 16);
|
|
268
|
+
// 2. Walk central directory entries (sig 0x02014b50). Each entry has a
|
|
269
|
+
// 46-byte fixed header followed by variable-length name/extra/comment.
|
|
270
|
+
for (let i = 0; i < entryCount; i++) {
|
|
271
|
+
if (off + 46 > buf.length || buf.readUInt32LE(off) !== 0x02014b50)
|
|
272
|
+
break;
|
|
273
|
+
const versionMadeBy = buf.readUInt16LE(off + 4);
|
|
274
|
+
const nameLen = buf.readUInt16LE(off + 28);
|
|
275
|
+
const extraLen = buf.readUInt16LE(off + 30);
|
|
276
|
+
const commentLen = buf.readUInt16LE(off + 32);
|
|
277
|
+
const externalAttr = buf.readUInt32LE(off + 38);
|
|
278
|
+
const name = buf.toString("utf8", off + 46, off + 46 + nameLen);
|
|
279
|
+
// versionMadeBy high byte = host OS. 3 = Unix. For Unix zips the high 16
|
|
280
|
+
// bits of externalAttr hold st_mode (file type + permission bits).
|
|
281
|
+
if (versionMadeBy >> 8 === 3) {
|
|
282
|
+
const mode = (externalAttr >>> 16) & 0xffff;
|
|
283
|
+
if (mode)
|
|
284
|
+
modes[name] = mode;
|
|
285
|
+
}
|
|
286
|
+
off += 46 + nameLen + extraLen + commentLen;
|
|
287
|
+
}
|
|
288
|
+
return modes;
|
|
289
|
+
}
|
|
@@ -268,8 +268,8 @@ export function mergeRemoteSettings(localMerged, remote) {
|
|
|
268
268
|
deny: lp.deny || rp.deny
|
|
269
269
|
? dedupe([...(lp.deny ?? []), ...(rp.deny ?? [])])
|
|
270
270
|
: undefined,
|
|
271
|
-
//
|
|
272
|
-
|
|
271
|
+
// defaultMode: remote wins (scalar)
|
|
272
|
+
defaultMode: rp.defaultMode ?? lp.defaultMode,
|
|
273
273
|
// additionalDirectories: concatenate + dedupe
|
|
274
274
|
additionalDirectories: lp.additionalDirectories || rp.additionalDirectories
|
|
275
275
|
? dedupe([
|
|
@@ -283,8 +283,8 @@ export function mergeRemoteSettings(localMerged, remote) {
|
|
|
283
283
|
delete result.permissions.allow;
|
|
284
284
|
if (!result.permissions.deny)
|
|
285
285
|
delete result.permissions.deny;
|
|
286
|
-
if (!result.permissions.
|
|
287
|
-
delete result.permissions.
|
|
286
|
+
if (!result.permissions.defaultMode)
|
|
287
|
+
delete result.permissions.defaultMode;
|
|
288
288
|
if (!result.permissions.additionalDirectories)
|
|
289
289
|
delete result.permissions.additionalDirectories;
|
|
290
290
|
}
|
package/dist/services/session.js
CHANGED
|
@@ -24,7 +24,7 @@ import { PathEncoder } from "../utils/pathEncoder.js";
|
|
|
24
24
|
import { JsonlHandler } from "../services/jsonlHandler.js";
|
|
25
25
|
import { extractLatestTotalTokens } from "../utils/tokenCalculation.js";
|
|
26
26
|
import { logger } from "../utils/globalLogger.js";
|
|
27
|
-
import { getMessageContent
|
|
27
|
+
import { getMessageContent } from "../utils/messageOperations.js";
|
|
28
28
|
const execFileAsync = promisify(execFile);
|
|
29
29
|
/**
|
|
30
30
|
* Generate a new session ID using Node.js native crypto.randomUUID()
|
|
@@ -215,8 +215,20 @@ export async function loadSessionFromJsonl(sessionId, workdir, sessionType = "ma
|
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
217
|
const allMessages = await jsonlHandler.read(resolvedPath);
|
|
218
|
-
//
|
|
219
|
-
|
|
218
|
+
// Transcripts written by older CLI versions re-appended the preserved
|
|
219
|
+
// rounds at each compaction point, so a message id can appear twice.
|
|
220
|
+
// Keep the first occurrence — duplicates would render twice once the
|
|
221
|
+
// full-history restore below feeds them to displayMessages. Append-only
|
|
222
|
+
// transcripts (current write side) have unique ids, making this a no-op.
|
|
223
|
+
const seenIds = new Set();
|
|
224
|
+
const messages = [];
|
|
225
|
+
for (const message of allMessages) {
|
|
226
|
+
if (message.id && seenIds.has(message.id))
|
|
227
|
+
continue;
|
|
228
|
+
if (message.id)
|
|
229
|
+
seenIds.add(message.id);
|
|
230
|
+
messages.push(message);
|
|
231
|
+
}
|
|
220
232
|
// Extract metadata from messages
|
|
221
233
|
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null;
|
|
222
234
|
const sessionData = {
|
|
@@ -227,9 +239,11 @@ export async function loadSessionFromJsonl(sessionId, workdir, sessionType = "ma
|
|
|
227
239
|
lastActiveAt: lastMessage
|
|
228
240
|
? lastMessage.timestamp
|
|
229
241
|
: new Date().toISOString(),
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
242
|
+
// Scan backwards for the last usage-bearing message instead of only
|
|
243
|
+
// testing the tail: a resumed conversation gets usage-less meta
|
|
244
|
+
// messages appended (SessionStart hooks), which would otherwise report
|
|
245
|
+
// 0 and blank the context-usage indicator for that conversation.
|
|
246
|
+
latestTotalTokens: extractLatestTotalTokens(messages),
|
|
233
247
|
},
|
|
234
248
|
};
|
|
235
249
|
return sessionData;
|
|
@@ -348,9 +362,9 @@ export async function listSessionsFromJsonl(workdir) {
|
|
|
348
362
|
? new Date(header.createdAt)
|
|
349
363
|
: new Date(),
|
|
350
364
|
lastActiveAt,
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
365
|
+
// Scans the file tail: a resumed session's last line is a usage-less
|
|
366
|
+
// hook message, so the line alone would report 0 tokens here.
|
|
367
|
+
latestTotalTokens: await jsonlHandler.getLatestTotalTokens(filePath),
|
|
354
368
|
branch: header?.gitBranch,
|
|
355
369
|
};
|
|
356
370
|
// Try to get first message content for display
|
|
@@ -535,9 +549,9 @@ export async function listAllSessions(options) {
|
|
|
535
549
|
? new Date(header.createdAt)
|
|
536
550
|
: new Date(),
|
|
537
551
|
lastActiveAt,
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
552
|
+
// Scans the file tail: a resumed session's last line is a
|
|
553
|
+
// usage-less hook message, so the line alone would report 0.
|
|
554
|
+
latestTotalTokens: await jsonlHandler.getLatestTotalTokens(filePath),
|
|
541
555
|
firstMessage,
|
|
542
556
|
branch: header?.gitBranch,
|
|
543
557
|
});
|
|
@@ -828,7 +842,10 @@ export async function handleSessionRestoration(restoreSessionId, continueLastSes
|
|
|
828
842
|
}
|
|
829
843
|
}
|
|
830
844
|
if (sessionToRestore) {
|
|
831
|
-
|
|
845
|
+
// Diagnostics only — never stdout: `wave --stdio`'s stdout carries the
|
|
846
|
+
// JSON-RPC channel, and a stray line there makes the host log a parse
|
|
847
|
+
// failure for every restore (the logger writes to the log file).
|
|
848
|
+
logger.info(`Restoring session: ${sessionToRestore.id}`);
|
|
832
849
|
// // Initialize from session data
|
|
833
850
|
// this.initializeFromSession();
|
|
834
851
|
return sessionToRestore;
|
|
@@ -58,7 +58,12 @@ export async function executeWorktreeRemoveHook(worktreePath, configuration, con
|
|
|
58
58
|
const results = await runWorktreeHooks("WorktreeRemove", hookConfigs, context, { worktreePath });
|
|
59
59
|
for (const result of results) {
|
|
60
60
|
if (!result.success) {
|
|
61
|
-
|
|
61
|
+
// The path identifies which worktree survived; exit/timeout/duration tell
|
|
62
|
+
// apart "the hook failed on its own" from "the hook was killed by the
|
|
63
|
+
// hook timeout" when failures are aggregated later.
|
|
64
|
+
logger?.error(`WorktreeRemove hook failed [${result.command}] worktree=${worktreePath} ` +
|
|
65
|
+
`exit=${result.exitCode ?? "n/a"} timedOut=${result.timedOut} ` +
|
|
66
|
+
`durationMs=${result.duration}: ${result.stderr || "no output"}`);
|
|
62
67
|
}
|
|
63
68
|
}
|
|
64
69
|
return true;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side stdio RPC layer shared by the VS Code extension and the desktop
|
|
3
|
+
* app: a typed StdioAgent wrapper (mirrors the CLI Agent API over JSON-RPC)
|
|
4
|
+
* plus the sessionId-demultiplexing NotificationRouter.
|
|
5
|
+
*
|
|
6
|
+
* Import via the `wave-agent-sdk/stdio` subpath.
|
|
7
|
+
*/
|
|
8
|
+
export * from "./rpcClient.js";
|
|
9
|
+
export * from "./notificationRouter.js";
|
|
10
|
+
export * from "./stdioAgent.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side stdio RPC layer shared by the VS Code extension and the desktop
|
|
3
|
+
* app: a typed StdioAgent wrapper (mirrors the CLI Agent API over JSON-RPC)
|
|
4
|
+
* plus the sessionId-demultiplexing NotificationRouter.
|
|
5
|
+
*
|
|
6
|
+
* Import via the `wave-agent-sdk/stdio` subpath.
|
|
7
|
+
*/
|
|
8
|
+
export * from "./rpcClient.js";
|
|
9
|
+
export * from "./notificationRouter.js";
|
|
10
|
+
export * from "./stdioAgent.js";
|