wave-agent-sdk 1.0.10 → 1.1.1

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 (48) hide show
  1. package/dist/agent.d.ts +16 -7
  2. package/dist/agent.js +44 -34
  3. package/dist/builtin/skills/settings.js +48 -8
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.js +2 -0
  6. package/dist/managers/aiManager.d.ts +23 -1
  7. package/dist/managers/aiManager.js +80 -30
  8. package/dist/managers/backgroundTaskManager.js +12 -0
  9. package/dist/managers/pluginScopeManager.js +13 -5
  10. package/dist/managers/subagentManager.d.ts +1 -0
  11. package/dist/managers/subagentManager.js +5 -1
  12. package/dist/services/GitService.js +6 -6
  13. package/dist/services/aiService.js +6 -2
  14. package/dist/services/configurationService.d.ts +4 -4
  15. package/dist/services/configurationService.js +14 -7
  16. package/dist/services/hook.js +41 -7
  17. package/dist/services/initializationService.js +0 -21
  18. package/dist/services/jsonlHandler.d.ts +37 -2
  19. package/dist/services/jsonlHandler.js +55 -6
  20. package/dist/services/session.d.ts +35 -4
  21. package/dist/services/session.js +233 -36
  22. package/dist/services/worktreeHooks.d.ts +45 -0
  23. package/dist/services/worktreeHooks.js +133 -0
  24. package/dist/tools/agentTool.js +36 -1
  25. package/dist/tools/bashTool.js +129 -58
  26. package/dist/tools/enterWorktreeTool.d.ts +1 -1
  27. package/dist/tools/enterWorktreeTool.js +44 -31
  28. package/dist/tools/exitWorktreeTool.js +21 -26
  29. package/dist/types/agent.d.ts +5 -0
  30. package/dist/types/config.d.ts +2 -1
  31. package/dist/types/hooks.d.ts +3 -2
  32. package/dist/types/skills.d.ts +0 -1
  33. package/dist/types/skills.js +0 -1
  34. package/dist/utils/asyncWorkRegistry.d.ts +32 -0
  35. package/dist/utils/asyncWorkRegistry.js +81 -0
  36. package/dist/utils/containerSetup.js +5 -0
  37. package/dist/utils/markdownParser.js +23 -5
  38. package/dist/utils/path.d.ts +9 -0
  39. package/dist/utils/path.js +32 -0
  40. package/dist/utils/skillParser.js +3 -6
  41. package/dist/utils/tokenCalculation.d.ts +19 -1
  42. package/dist/utils/tokenCalculation.js +64 -0
  43. package/dist/utils/windowsPaths.d.ts +28 -0
  44. package/dist/utils/windowsPaths.js +47 -0
  45. package/dist/utils/worktreeSession.d.ts +6 -0
  46. package/dist/utils/worktreeUtils.d.ts +7 -0
  47. package/dist/utils/worktreeUtils.js +88 -40
  48. package/package.json +5 -3
@@ -61,6 +61,13 @@ export class BackgroundTaskManager {
61
61
  // Create log file
62
62
  const logPath = path.join(os.tmpdir(), `wave-task-${id}.log`);
63
63
  const logStream = fs.createWriteStream(logPath, { flags: "w" });
64
+ // A failed open/write of the log file (e.g. EBUSY on Windows when a stale
65
+ // handle from a previous run still locks the temp file) must not surface as
66
+ // an uncaught stream error — the log is best-effort side output and the
67
+ // task itself keeps running regardless.
68
+ logStream.on("error", (error) => {
69
+ logger.warn(`Failed to write background task log ${logPath}:`, error);
70
+ });
64
71
  const shell = {
65
72
  id,
66
73
  type: "shell",
@@ -232,6 +239,11 @@ export class BackgroundTaskManager {
232
239
  // Create log file
233
240
  const logPath = path.join(os.tmpdir(), `wave-task-${id}.log`);
234
241
  const logStream = fs.createWriteStream(logPath, { flags: "w" });
242
+ // Same best-effort handling as startShell: a locked temp file (EBUSY on
243
+ // Windows) must not become an uncaught stream error.
244
+ logStream.on("error", (error) => {
245
+ logger.warn(`Failed to write background task log ${logPath}:`, error);
246
+ });
235
247
  // Write initial output to log file
236
248
  if (initialStdout) {
237
249
  logStream.write(stripAnsiColors(initialStdout));
@@ -32,15 +32,23 @@ export class PluginScopeManager {
32
32
  * Priority: local > project > user
33
33
  */
34
34
  findPluginScope(pluginId) {
35
- const projectPaths = this.configurationService.getConfigurationPaths(this.workdir).projectPaths; // [local, json]
36
- const userPaths = this.configurationService.getConfigurationPaths(this.workdir).userPaths; // [local, json]
35
+ const { projectPaths, userPaths } = this.configurationService.getConfigurationPaths(this.workdir);
36
+ const userPathSet = new Set(userPaths);
37
+ // When the workdir is the user's home directory, projectPaths overlaps
38
+ // userPaths (both point at ~/.wave/settings.json). Treat such a file as the
39
+ // user config, otherwise user-scope plugins would be mislabeled "project".
37
40
  const checkPaths = [
38
41
  { path: projectPaths[0], scope: "local" },
39
- { path: projectPaths[1], scope: "project" },
40
- { path: userPaths[0], scope: "user" }, // user local is still user scope
41
- { path: userPaths[1], scope: "user" },
42
+ ...(userPathSet.has(projectPaths[1])
43
+ ? []
44
+ : [{ path: projectPaths[1], scope: "project" }]),
45
+ ...userPaths.map((path) => ({ path, scope: "user" })),
42
46
  ];
47
+ const seen = new Set();
43
48
  for (const { path, scope } of checkPaths) {
49
+ if (!path || seen.has(path))
50
+ continue;
51
+ seen.add(path);
44
52
  const config = this.configurationService.loadWaveConfigFromFile(path);
45
53
  if (config?.enabledPlugins && pluginId in config.enabledPlugins) {
46
54
  return scope;
@@ -75,6 +75,7 @@ export declare class SubagentManager {
75
75
  private stream;
76
76
  constructor(container: Container, options: SubagentManagerOptions);
77
77
  private get configurationService();
78
+ private get asyncWorkRegistry();
78
79
  /**
79
80
  * Initialize the SubagentManager by loading and caching configurations
80
81
  */
@@ -23,6 +23,9 @@ export class SubagentManager {
23
23
  get configurationService() {
24
24
  return this.container.get("ConfigurationService");
25
25
  }
26
+ get asyncWorkRegistry() {
27
+ return this.container.get("AsyncWorkRegistry");
28
+ }
26
29
  /**
27
30
  * Initialize the SubagentManager by loading and caching configurations
28
31
  */
@@ -331,7 +334,7 @@ export class SubagentManager {
331
334
  instance.backgroundTaskId = taskId;
332
335
  // Execute in background
333
336
  // Note: notification enqueueing is handled by internalExecute when instance.backgroundTaskId is set
334
- (async () => {
337
+ const backgroundPromise = (async () => {
335
338
  try {
336
339
  const result = await this.internalExecute(instance, prompt, abortSignal);
337
340
  const task = backgroundTaskManager?.getTask(taskId);
@@ -360,6 +363,7 @@ export class SubagentManager {
360
363
  this.releaseInstance(instance.subagentId);
361
364
  }
362
365
  })();
366
+ this.asyncWorkRegistry?.track(backgroundPromise);
363
367
  return taskId;
364
368
  }
365
369
  const result = await this.internalExecute(instance, prompt, abortSignal);
@@ -1,6 +1,6 @@
1
- import { exec } from "child_process";
1
+ import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
- const execAsync = promisify(exec);
3
+ const execFileAsync = promisify(execFile);
4
4
  export class GitService {
5
5
  getTimeout() {
6
6
  const envTimeout = process.env.WAVE_PLUGIN_GIT_TIMEOUT_MS;
@@ -17,7 +17,7 @@ export class GitService {
17
17
  */
18
18
  async isGitAvailable() {
19
19
  try {
20
- await execAsync("git --version");
20
+ await execFileAsync("git", ["--version"]);
21
21
  return true;
22
22
  }
23
23
  catch {
@@ -43,8 +43,8 @@ export class GitService {
43
43
  url = `https://github.com/${urlOrRepo}.git`;
44
44
  }
45
45
  try {
46
- const refArgs = ref ? `-b "${ref}"` : "--depth 1";
47
- await execAsync(`git clone ${refArgs} "${url}" "${targetPath}"`, {
46
+ const refArgs = ref ? ["-b", ref] : ["--depth", "1"];
47
+ await execFileAsync("git", ["clone", ...refArgs, url, targetPath], {
48
48
  env: { ...process.env, LC_ALL: "C" },
49
49
  timeout: this.getTimeout(),
50
50
  });
@@ -64,7 +64,7 @@ export class GitService {
64
64
  throw new Error("Git is not installed or not found in PATH. Please install Git to use Git/GitHub marketplaces.");
65
65
  }
66
66
  try {
67
- await execAsync(`git -C "${targetPath}" pull`, {
67
+ await execFileAsync("git", ["-C", targetPath, "pull"], {
68
68
  env: { ...process.env, LC_ALL: "C" },
69
69
  timeout: this.getTimeout(),
70
70
  });
@@ -4,6 +4,7 @@ import { addOnceAbortListener } from "../utils/abortUtils.js";
4
4
  import { ConfigurationError, CONFIG_ERRORS } from "../types/index.js";
5
5
  import { transformMessagesForExplicitCache, extendUsageWithCacheMetrics, } from "../utils/cacheControlUtils.js";
6
6
  import { supportsPromptCaching } from "../utils/modelCapabilities.js";
7
+ import { DEFAULT_WAVE_MAX_OUTPUT_TOKENS } from "../utils/constants.js";
7
8
  import * as os from "os";
8
9
  import * as fs from "fs";
9
10
  import * as path from "path";
@@ -110,8 +111,11 @@ export async function callAgent(options) {
110
111
  fetchOptions: gatewayConfig.fetchOptions,
111
112
  fetch: gatewayConfig.fetch,
112
113
  });
113
- // Determine model early (needed for system prompt construction)
114
- const resolvedMaxTokens = options.maxTokens ?? modelConfig.maxTokens;
114
+ // Determine model early (needed for system prompt construction).
115
+ // Per-model overrides come from modelConfig.options.max_tokens (spread
116
+ // after this default below); callers pass the resolved global value
117
+ // (AgentOptions.maxTokens / WAVE_MAX_OUTPUT_TOKENS) via options.maxTokens.
118
+ const resolvedMaxTokens = options.maxTokens ?? DEFAULT_WAVE_MAX_OUTPUT_TOKENS;
115
119
  // Build system message content
116
120
  let systemMessage;
117
121
  if (Array.isArray(systemPrompt)) {
@@ -79,19 +79,19 @@ export declare class ConfigurationService {
79
79
  * Resolution priority: override > options > env (from settings.json) > process.env > default
80
80
  * @param model - Agent model override (optional)
81
81
  * @param fastModel - Fast model override (optional)
82
- * @param maxTokens - Max output tokens override (optional)
83
82
  * @param permissionMode - Permission mode override (optional)
84
83
  * @param visionModel - Vision model override (optional)
85
84
  * @returns Resolved model configuration with defaults
86
85
  */
87
- resolveModelConfig(model?: string, fastModel?: string, maxTokens?: number, permissionMode?: PermissionMode, visionModel?: string): ModelConfig;
86
+ resolveModelConfig(model?: string, fastModel?: string, permissionMode?: PermissionMode, visionModel?: string): ModelConfig;
88
87
  /**
89
88
  * Resolves token limit with fallbacks
90
- * Resolution priority: override > options > env (from settings.json) > process.env > default
89
+ * Resolution priority: override > options > models[model].maxInputTokens > env (from settings.json) > process.env > default
91
90
  * @param constructorLimit - Token limit override (optional)
91
+ * @param model - Model to resolve per-model maxInputTokens for (optional; falls back to the resolved agent model chain)
92
92
  * @returns Resolved token limit
93
93
  */
94
- resolveMaxInputTokens(constructorLimit?: number): number;
94
+ resolveMaxInputTokens(constructorLimit?: number, model?: string): number;
95
95
  /**
96
96
  * Resolves preferred language with fallbacks
97
97
  * Resolution priority: override > options > settings.json > undefined
@@ -457,12 +457,11 @@ export class ConfigurationService {
457
457
  * Resolution priority: override > options > env (from settings.json) > process.env > default
458
458
  * @param model - Agent model override (optional)
459
459
  * @param fastModel - Fast model override (optional)
460
- * @param maxTokens - Max output tokens override (optional)
461
460
  * @param permissionMode - Permission mode override (optional)
462
461
  * @param visionModel - Vision model override (optional)
463
462
  * @returns Resolved model configuration with defaults
464
463
  */
465
- resolveModelConfig(model, fastModel, maxTokens, permissionMode, visionModel) {
464
+ resolveModelConfig(model, fastModel, permissionMode, visionModel) {
466
465
  // Resolve agent model: override > options > currentConfiguration (settings.json model, possibly remote-merged) > process.env
467
466
  // Priority: user's explicit model field > admin's env.WAVE_MODEL default.
468
467
  // If admin wants hard enforcement, they set the `model` scalar field (overwrites local in mergeRemoteSettings).
@@ -478,13 +477,10 @@ export class ConfigurationService {
478
477
  const resolvedVisionModel = visionModel ||
479
478
  this.options.visionModel ||
480
479
  (this.envSnapshot.WAVE_VISION_MODEL ?? process.env.WAVE_VISION_MODEL);
481
- // Resolve max output tokens
482
- const resolvedMaxTokens = this.resolveMaxOutputTokens(maxTokens);
483
480
  const baseConfig = {
484
481
  model: resolvedAgentModel,
485
482
  fastModel: resolvedFastModel,
486
483
  visionModel: resolvedVisionModel,
487
- maxTokens: resolvedMaxTokens,
488
484
  permissionMode: permissionMode ?? this.options.permissionMode,
489
485
  };
490
486
  // Resolve fast model generation params from models[fastModel].options.
@@ -520,11 +516,12 @@ export class ConfigurationService {
520
516
  }
521
517
  /**
522
518
  * Resolves token limit with fallbacks
523
- * Resolution priority: override > options > env (from settings.json) > process.env > default
519
+ * Resolution priority: override > options > models[model].maxInputTokens > env (from settings.json) > process.env > default
524
520
  * @param constructorLimit - Token limit override (optional)
521
+ * @param model - Model to resolve per-model maxInputTokens for (optional; falls back to the resolved agent model chain)
525
522
  * @returns Resolved token limit
526
523
  */
527
- resolveMaxInputTokens(constructorLimit) {
524
+ resolveMaxInputTokens(constructorLimit, model) {
528
525
  // If override value provided, use it
529
526
  if (constructorLimit !== undefined) {
530
527
  return constructorLimit;
@@ -533,6 +530,16 @@ export class ConfigurationService {
533
530
  if (this.options.maxInputTokens !== undefined) {
534
531
  return this.options.maxInputTokens;
535
532
  }
533
+ // Per-model config (models[X].maxInputTokens). Resolve the model with the
534
+ // same chain as resolveModelConfig: caller > options > currentConfiguration > env.
535
+ const resolvedModel = model ||
536
+ this.options.model ||
537
+ this.currentConfiguration?.model ||
538
+ (this.envSnapshot.WAVE_MODEL ?? process.env.WAVE_MODEL);
539
+ const modelConfig = resolvedModel && this.currentConfiguration?.models?.[resolvedModel];
540
+ if (modelConfig && modelConfig.maxInputTokens !== undefined) {
541
+ return modelConfig.maxInputTokens;
542
+ }
536
543
  // Try env (settings.json snapshot) first, then process.env
537
544
  const envMaxInputTokens = this.envSnapshot.WAVE_MAX_INPUT_TOKENS ??
538
545
  process.env.WAVE_MAX_INPUT_TOKENS;
@@ -7,6 +7,8 @@
7
7
  */
8
8
  import { spawn } from "child_process";
9
9
  import { generateSessionFilePath } from "./session.js";
10
+ import { resolveShellPath } from "../utils/shellResolver.js";
11
+ import { toPosixCommand } from "../utils/windowsPaths.js";
10
12
  // =============================================================================
11
13
  // Hook Execution Functions
12
14
  // =============================================================================
@@ -56,10 +58,10 @@ async function buildHookJsonInput(context) {
56
58
  if (context.subagentType !== undefined) {
57
59
  jsonInput.subagent_type = context.subagentType;
58
60
  }
59
- // Add name field for WorktreeCreate events
61
+ // Add name field for WorktreeCreate events (aligned with Claude Code)
60
62
  if (context.event === "WorktreeCreate") {
61
- if (context.worktreeName !== undefined) {
62
- jsonInput.name = context.worktreeName;
63
+ if (context.name !== undefined) {
64
+ jsonInput.name = context.name;
63
65
  }
64
66
  }
65
67
  // Add worktree_path field for WorktreeRemove events
@@ -133,11 +135,43 @@ export async function executeCommand(command, context, options) {
133
135
  let stdout = "";
134
136
  let stderr = "";
135
137
  let timedOut = false;
136
- // Parse command for shell execution
138
+ // Parse command for shell execution.
139
+ //
140
+ // Windows uses Git Bash (POSIX shell) instead of cmd.exe: cmd.exe's `/c`
141
+ // does not strip quotes from arguments after the first token, so commands
142
+ // like `node "C:\path\script.js"` silently fail (issue #1773). Git Bash
143
+ // parses quotes correctly, and Windows paths in the command are converted
144
+ // to POSIX form first (Git Bash resolves `/c/Users/...` and translates it
145
+ // back to a Windows path when spawning native executables like node.exe).
146
+ // Falls back to cmd.exe when no Git Bash is installed.
137
147
  const isWindows = process.platform === "win32";
138
- const shell = isWindows ? "cmd.exe" : "/bin/sh";
139
- const shellFlag = isWindows ? "/c" : "-c";
140
- const childProcess = spawn(shell, [shellFlag, command], {
148
+ const bashPath = isWindows ? resolveShellPath() : undefined;
149
+ let shell;
150
+ let shellFlag;
151
+ let finalCommand;
152
+ if (bashPath) {
153
+ shell = bashPath;
154
+ shellFlag = "-c";
155
+ finalCommand = toPosixCommand(command);
156
+ // Windows .sh scripts aren't directly executable — run them via bash
157
+ // when the command itself is a .sh file (e.g. `${WAVE_PLUGIN_ROOT}/x.sh`).
158
+ const trimmed = finalCommand.trim();
159
+ const firstToken = trimmed.match(/^("([^"]*)"|'([^']*)'|\S+)/)?.[0] ?? "";
160
+ if (firstToken.endsWith(".sh") && !trimmed.startsWith("bash ")) {
161
+ finalCommand = `bash ${finalCommand}`;
162
+ }
163
+ }
164
+ else if (isWindows) {
165
+ shell = "cmd.exe";
166
+ shellFlag = "/c";
167
+ finalCommand = command;
168
+ }
169
+ else {
170
+ shell = "/bin/sh";
171
+ shellFlag = "-c";
172
+ finalCommand = command;
173
+ }
174
+ const childProcess = spawn(shell, [shellFlag, finalCommand], {
141
175
  stdio: ["pipe", "pipe", "pipe"],
142
176
  cwd: context.projectDir,
143
177
  env: {
@@ -127,27 +127,6 @@ export class InitializationService {
127
127
  catch (error) {
128
128
  logger?.warn("SessionStart hooks execution failed:", error);
129
129
  }
130
- // Trigger WorktreeCreate hook if this is a new worktree
131
- if (agentOptions.isNewWorktree && hookManager) {
132
- try {
133
- logger?.info(`Triggering WorktreeCreate hook for ${agentOptions.worktreeName}...`);
134
- const hookResults = await hookManager.executeHooks("WorktreeCreate", {
135
- event: "WorktreeCreate",
136
- projectDir: workdir,
137
- timestamp: new Date(),
138
- sessionId: messageManager.getSessionId(),
139
- transcriptPath: messageManager.getTranscriptPath(),
140
- cwd: workdir,
141
- worktreeName: agentOptions.worktreeName,
142
- env: Object.fromEntries(Object.entries(configurationService.getMergedEnv()).filter((e) => e[1] !== undefined)),
143
- });
144
- // Process hook results
145
- hookManager.processHookResults("WorktreeCreate", hookResults, messageManager);
146
- }
147
- catch (error) {
148
- logger?.warn("WorktreeCreate hooks execution failed:", error);
149
- }
150
- }
151
130
  // Resolve and validate configuration after loading settings.json
152
131
  resolveAndValidateConfig();
153
132
  // Initialize auto-memory directory
@@ -10,6 +10,20 @@ import type { SessionFilename } from "../types/session.js";
10
10
  export interface JsonlWriteOptions {
11
11
  atomic?: boolean;
12
12
  }
13
+ /**
14
+ * Creation-time metadata persisted in the session file's header line
15
+ * (`{"type":"metadata",...}`). The header is append-only — written once on
16
+ * session creation and never rewritten — so only fields that are fixed at
17
+ * creation time belong here.
18
+ */
19
+ export interface SessionMetadataHeader {
20
+ /** Real working directory (the encoded project dir name is lossy for paths containing "-"). */
21
+ workdir?: string;
22
+ /** ISO 8601 creation timestamp. */
23
+ createdAt?: string;
24
+ /** Git branch at creation time (`git branch --show-current`), when the directory is a git repo. */
25
+ gitBranch?: string;
26
+ }
13
27
  /**
14
28
  * JSONL handler class for message persistence operations
15
29
  */
@@ -17,9 +31,20 @@ export declare class JsonlHandler {
17
31
  private readonly defaultWriteOptions;
18
32
  constructor();
19
33
  /**
20
- * Create a new session file (simplified - no metadata header)
34
+ * Create a new session file.
35
+ *
36
+ * When `metadata` is provided, the first line is a metadata header
37
+ * recording creation-time facts about the session:
38
+ * `{"type":"metadata","workdir":...,"createdAt":...,"gitBranch":...}`. The
39
+ * encoded project dir name is lossy for paths containing "-", so persisting
40
+ * the real path lets session listing show it without decoding; `createdAt`
41
+ * and `gitBranch` similarly avoid lossy/fabricated reconstruction later.
42
+ * The header carries no `timestamp`, so message readers filter it out
43
+ * naturally.
44
+ *
45
+ * Legacy callers that omit `metadata` still get an empty file.
21
46
  */
22
- createSession(filePath: string): Promise<void>;
47
+ createSession(filePath: string, metadata?: SessionMetadataHeader): Promise<void>;
23
48
  /**
24
49
  * Append a single message to JSONL file
25
50
  */
@@ -40,6 +65,16 @@ export declare class JsonlHandler {
40
65
  * Get the last message from JSONL file using efficient file reading (simplified)
41
66
  */
42
67
  getLastMessage(filePath: string): Promise<Message | null>;
68
+ /**
69
+ * Read the creation-time metadata from the session file's header line.
70
+ *
71
+ * Newer session files start with a `{"type":"metadata",...}` line (see
72
+ * `createSession`). Legacy files have no header.
73
+ *
74
+ * @param filePath - Path to the session JSONL file
75
+ * @returns The persisted metadata, or null when the file has no header
76
+ */
77
+ readMetadata(filePath: string): Promise<SessionMetadataHeader | null>;
43
78
  /**
44
79
  * Validate messages before writing
45
80
  */
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { appendFile, readFile, writeFile, stat, mkdir } from "fs/promises";
6
6
  import { dirname } from "path";
7
- import { getLastLine } from "../utils/fileUtils.js";
7
+ import { getLastLine, readFirstNLines } from "../utils/fileUtils.js";
8
8
  /**
9
9
  * JSONL handler class for message persistence operations
10
10
  */
@@ -15,13 +15,26 @@ export class JsonlHandler {
15
15
  };
16
16
  }
17
17
  /**
18
- * Create a new session file (simplified - no metadata header)
18
+ * Create a new session file.
19
+ *
20
+ * When `metadata` is provided, the first line is a metadata header
21
+ * recording creation-time facts about the session:
22
+ * `{"type":"metadata","workdir":...,"createdAt":...,"gitBranch":...}`. The
23
+ * encoded project dir name is lossy for paths containing "-", so persisting
24
+ * the real path lets session listing show it without decoding; `createdAt`
25
+ * and `gitBranch` similarly avoid lossy/fabricated reconstruction later.
26
+ * The header carries no `timestamp`, so message readers filter it out
27
+ * naturally.
28
+ *
29
+ * Legacy callers that omit `metadata` still get an empty file.
19
30
  */
20
- async createSession(filePath) {
31
+ async createSession(filePath, metadata) {
21
32
  // Ensure directory exists
22
33
  await this.ensureDirectory(dirname(filePath));
23
- // Create empty file (no metadata line needed)
24
- await writeFile(filePath, "", "utf8");
34
+ const content = metadata && Object.keys(metadata).length > 0
35
+ ? `${JSON.stringify({ type: "metadata", ...metadata })}\n`
36
+ : "";
37
+ await writeFile(filePath, content, "utf8");
25
38
  }
26
39
  /**
27
40
  * Append a single message to JSONL file
@@ -83,11 +96,14 @@ export class JsonlHandler {
83
96
  return [];
84
97
  }
85
98
  const allMessages = [];
86
- // Parse all messages (no metadata line to skip)
99
+ // Parse all messages, skipping the metadata header line (if any)
87
100
  for (let i = 0; i < lines.length; i++) {
88
101
  const line = lines[i];
89
102
  try {
90
103
  const message = JSON.parse(line);
104
+ // Metadata header line: not a message, skip
105
+ if (message.type === "metadata")
106
+ continue;
91
107
  if (message.timestamp)
92
108
  allMessages.push(message);
93
109
  }
@@ -127,6 +143,10 @@ export class JsonlHandler {
127
143
  }
128
144
  try {
129
145
  const parsed = JSON.parse(lastLine);
146
+ // A file whose only line is the metadata header has no messages yet
147
+ if (parsed.type === "metadata") {
148
+ return null;
149
+ }
130
150
  return parsed;
131
151
  }
132
152
  catch (error) {
@@ -137,6 +157,35 @@ export class JsonlHandler {
137
157
  throw new Error(`Failed to get last message from "${filePath}": ${error}`);
138
158
  }
139
159
  }
160
+ /**
161
+ * Read the creation-time metadata from the session file's header line.
162
+ *
163
+ * Newer session files start with a `{"type":"metadata",...}` line (see
164
+ * `createSession`). Legacy files have no header.
165
+ *
166
+ * @param filePath - Path to the session JSONL file
167
+ * @returns The persisted metadata, or null when the file has no header
168
+ */
169
+ async readMetadata(filePath) {
170
+ try {
171
+ const lines = await readFirstNLines(filePath, 1);
172
+ if (lines.length === 0) {
173
+ return null;
174
+ }
175
+ const header = JSON.parse(lines[0]);
176
+ if (header?.type === "metadata") {
177
+ return {
178
+ workdir: header.workdir,
179
+ createdAt: header.createdAt,
180
+ gitBranch: header.gitBranch,
181
+ };
182
+ }
183
+ }
184
+ catch {
185
+ // Unreadable or invalid first line — treat as a legacy file
186
+ }
187
+ return null;
188
+ }
140
189
  /**
141
190
  * Validate messages before writing
142
191
  */
@@ -33,6 +33,8 @@ export interface SessionMetadata {
33
33
  lastActiveAt: Date;
34
34
  latestTotalTokens: number;
35
35
  firstMessage?: string;
36
+ /** Git branch at session creation time (from the metadata header). */
37
+ branch?: string;
36
38
  }
37
39
  /**
38
40
  * Generate a new session ID using Node.js native crypto.randomUUID()
@@ -123,11 +125,31 @@ export declare function listSessions(workdir: string): Promise<SessionMetadata[]
123
125
  */
124
126
  export declare function listSessionsFromJsonl(workdir: string): Promise<SessionMetadata[]>;
125
127
  /**
126
- * List all sessions across all project directories
128
+ * List all sessions across all project directories.
127
129
  *
128
- * @returns Promise that resolves to array of session metadata objects
129
- */
130
- export declare function listAllSessions(): Promise<SessionMetadata[]>;
130
+ * When `worktreePaths` is provided, only project directories that match a
131
+ * same-repo git worktree (plus the current working directory's own project
132
+ * dir) are scanned — used by the `wave -r` picker's worktree aggregation
133
+ * (`Ctrl+W`). When omitted, every project directory under the sessions dir
134
+ * is scanned (all-projects mode, `Ctrl+A`).
135
+ *
136
+ * No time-based filtering is applied — all sessions whose files still exist
137
+ * are listed, regardless of how long ago they were last active.
138
+ *
139
+ * @param options.worktreePaths - Absolute paths of same-repo worktrees
140
+ * (from `git worktree list`). When provided, scanning is limited to
141
+ * matching project directories.
142
+ * @param options.workdir - Current working directory. Its own project dir is
143
+ * always included in worktree mode so sessions created from a subdirectory
144
+ * inside a worktree are not missed.
145
+ * @returns Promise that resolves to array of session metadata objects,
146
+ * deduplicated by sessionId (newest lastActiveAt wins), sorted by
147
+ * lastActiveAt descending.
148
+ */
149
+ export declare function listAllSessions(options?: {
150
+ worktreePaths?: string[];
151
+ workdir?: string;
152
+ }): Promise<SessionMetadata[]>;
131
153
  /**
132
154
  * Clean up expired sessions older than 14 days based on file modification time
133
155
  *
@@ -161,6 +183,15 @@ export declare function cleanupMetaOnlySessions(): Promise<number>;
161
183
  * @returns Promise that resolves to true if session exists, false otherwise
162
184
  */
163
185
  export declare function sessionExistsInJsonl(sessionId: string, workdir: string, sessionType?: "main" | "subagent"): Promise<boolean>;
186
+ /**
187
+ * Get the content of the first non-meta message in a session file
188
+ * For user role: get text block content
189
+ * For assistant role: get compact block content
190
+ * Skips meta messages (isMeta: true) to find the first meaningful message
191
+ * @param filePath - Path to the session JSONL file
192
+ * @returns Promise that resolves to the first non-meta message content or null if not found
193
+ */
194
+ export declare function getFirstMessageContentFromFile(filePath: string): Promise<string | null>;
164
195
  /**
165
196
  * Get the content of the first non-meta message in a session
166
197
  * For user role: get text block content