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.
Files changed (86) hide show
  1. package/dist/agent.d.ts +70 -24
  2. package/dist/agent.js +110 -30
  3. package/dist/builtin/plugins.js +11 -20
  4. package/dist/builtin/skills/settings.js +6 -8
  5. package/dist/core/plugin.d.ts +4 -0
  6. package/dist/core/plugin.js +7 -1
  7. package/dist/index.d.ts +2 -1
  8. package/dist/index.js +2 -1
  9. package/dist/managers/aiManager.d.ts +0 -24
  10. package/dist/managers/aiManager.js +14 -181
  11. package/dist/managers/bashModeManager.d.ts +33 -0
  12. package/dist/managers/bashModeManager.js +110 -0
  13. package/dist/managers/hookManager.d.ts +5 -0
  14. package/dist/managers/hookManager.js +7 -0
  15. package/dist/managers/liveConfigManager.js +3 -3
  16. package/dist/managers/mcpManager.d.ts +23 -0
  17. package/dist/managers/mcpManager.js +162 -14
  18. package/dist/managers/messageManager.d.ts +12 -13
  19. package/dist/managers/messageManager.js +63 -60
  20. package/dist/managers/permissionManager.d.ts +29 -0
  21. package/dist/managers/permissionManager.js +158 -70
  22. package/dist/managers/planManager.d.ts +9 -0
  23. package/dist/managers/planManager.js +19 -1
  24. package/dist/managers/skillManager.d.ts +31 -0
  25. package/dist/managers/skillManager.js +122 -12
  26. package/dist/managers/slashCommandManager.js +9 -28
  27. package/dist/managers/subagentManager.d.ts +7 -0
  28. package/dist/managers/subagentManager.js +61 -9
  29. package/dist/managers/workflowManager.js +6 -0
  30. package/dist/prompts/index.d.ts +0 -1
  31. package/dist/prompts/index.js +0 -4
  32. package/dist/services/MarketplaceService.js +36 -14
  33. package/dist/services/configurationService.d.ts +34 -2
  34. package/dist/services/configurationService.js +123 -16
  35. package/dist/services/initializationService.js +2 -2
  36. package/dist/services/jsonlHandler.d.ts +14 -0
  37. package/dist/services/jsonlHandler.js +44 -1
  38. package/dist/services/memory.d.ts +14 -0
  39. package/dist/services/memory.js +33 -0
  40. package/dist/services/officialMarketplaceMirror.d.ts +85 -0
  41. package/dist/services/officialMarketplaceMirror.js +289 -0
  42. package/dist/services/remoteSettingsService.js +4 -4
  43. package/dist/services/session.js +30 -13
  44. package/dist/services/worktreeHooks.js +6 -1
  45. package/dist/stdio/index.d.ts +10 -0
  46. package/dist/stdio/index.js +10 -0
  47. package/dist/stdio/notificationRouter.d.ts +38 -0
  48. package/dist/stdio/notificationRouter.js +96 -0
  49. package/dist/stdio/rpcClient.d.ts +18 -0
  50. package/dist/stdio/rpcClient.js +10 -0
  51. package/dist/stdio/stdioAgent.d.ts +222 -0
  52. package/dist/stdio/stdioAgent.js +341 -0
  53. package/dist/tools/bashTool.js +2 -0
  54. package/dist/tools/exitPlanMode.js +10 -2
  55. package/dist/types/agent.d.ts +8 -0
  56. package/dist/types/commands.d.ts +7 -0
  57. package/dist/types/configuration.d.ts +6 -1
  58. package/dist/types/hooks.d.ts +1 -0
  59. package/dist/types/hooks.js +19 -0
  60. package/dist/types/mcp.d.ts +3 -0
  61. package/dist/types/messaging.d.ts +1 -8
  62. package/dist/types/skills.d.ts +11 -0
  63. package/dist/utils/bashParser.d.ts +17 -0
  64. package/dist/utils/bashParser.js +72 -0
  65. package/dist/utils/bashStructure/bashLexer.d.ts +96 -0
  66. package/dist/utils/bashStructure/bashLexer.js +676 -0
  67. package/dist/utils/bashStructure/bashParser.d.ts +144 -0
  68. package/dist/utils/bashStructure/bashParser.js +606 -0
  69. package/dist/utils/bashStructure/bashSemantics.d.ts +70 -0
  70. package/dist/utils/bashStructure/bashSemantics.js +477 -0
  71. package/dist/utils/bashStructure/index.d.ts +26 -0
  72. package/dist/utils/bashStructure/index.js +27 -0
  73. package/dist/utils/bashStructure/types.d.ts +62 -0
  74. package/dist/utils/bashStructure/types.js +47 -0
  75. package/dist/utils/containerSetup.js +5 -6
  76. package/dist/utils/fileUtils.d.ts +11 -0
  77. package/dist/utils/fileUtils.js +37 -0
  78. package/dist/utils/messageOperations.d.ts +0 -18
  79. package/dist/utils/messageOperations.js +0 -62
  80. package/dist/utils/subagentParser.js +9 -2
  81. package/dist/utils/tokenCalculation.js +0 -8
  82. package/dist/utils/worktreeUtils.d.ts +2 -1
  83. package/dist/utils/worktreeUtils.js +64 -34
  84. package/package.json +6 -1
  85. package/dist/managers/bangManager.d.ts +0 -26
  86. 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
  }
@@ -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
- // permissionMode: remote wins (scalar)
272
- permissionMode: rp.permissionMode ?? lp.permissionMode,
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.permissionMode)
287
- delete result.permissions.permissionMode;
286
+ if (!result.permissions.defaultMode)
287
+ delete result.permissions.defaultMode;
288
288
  if (!result.permissions.additionalDirectories)
289
289
  delete result.permissions.additionalDirectories;
290
290
  }
@@ -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, sliceFromLastCompact, } from "../utils/messageOperations.js";
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
- // Find the last compact boundary only return messages from there forward
219
- const messages = sliceFromLastCompact(allMessages);
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
- latestTotalTokens: lastMessage?.usage
231
- ? extractLatestTotalTokens([lastMessage])
232
- : 0,
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
- latestTotalTokens: lastMessage?.usage
352
- ? extractLatestTotalTokens([lastMessage])
353
- : 0,
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
- latestTotalTokens: lastMessage?.usage
539
- ? extractLatestTotalTokens([lastMessage])
540
- : 0,
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
- console.log(`Restoring session: ${sessionToRestore.id}`);
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
- logger?.error(`WorktreeRemove hook failed [${result.command}]: ${result.stderr || "no output"}`);
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";