diff --git a/build.zig b/build.zig index 3202e5a..aa7dd04 100644 --- a/build.zig +++ b/build.zig @@ -101,6 +101,14 @@ pub fn build(b: *std.Build) void { }), }); + const detect_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/cmds/detect.zig"), + .target = target, + .optimize = optimize, + }), + }); + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); const run_log_tests = b.addRunArtifact(log_tests); const run_git_tests = b.addRunArtifact(git_tests); @@ -109,6 +117,7 @@ pub fn build(b: *std.Build) void { const run_commit_tests = b.addRunArtifact(commit_tests); const run_diff_tests = b.addRunArtifact(diff_tests); const run_agent_tests = b.addRunArtifact(agent_tests); + const run_detect_tests = b.addRunArtifact(detect_tests); const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_exe_unit_tests.step); @@ -119,4 +128,5 @@ pub fn build(b: *std.Build) void { test_step.dependOn(&run_commit_tests.step); test_step.dependOn(&run_diff_tests.step); test_step.dependOn(&run_agent_tests.step); + test_step.dependOn(&run_detect_tests.step); } diff --git a/docs/ralph.md b/docs/ralph.md new file mode 100644 index 0000000..e603320 --- /dev/null +++ b/docs/ralph.md @@ -0,0 +1,233 @@ +# RALPH: Recursive Agent Loop Pattern for Humans + +RALPH is zagi's autonomous task execution system. It spawns AI agents to complete tasks sequentially, with each agent focused on a single task. + +## Architecture + +``` +┌────────────────────────────────────────────────────────���────────┐ +│ RALPH Orchestrator │ +│ (git agent run) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ Task 1 │───▶│ Task 2 │───▶│ Task 3 │───▶│ Task N │ │ +│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ +│ │ │ │ │ │ +│ ▼ ▼ ▼ ▼ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ Agent │ │ Agent │ │ Agent │ │ Agent │ │ +│ │ Process │ │ Process │ │ Process │ │ Process │ │ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +Each task spawns a **fresh agent process**. This isolation ensures: +- Clean context for each task (no state leakage) +- Independent success/failure tracking +- Parallel-safe execution (agents don't interfere) + +## The Loop + +``` +┌─────────────────────────────────────────────────────────┐ +│ 1. Load pending tasks from git refs │ +│ 2. Find next task with < 3 consecutive failures │ +│ 3. If no eligible task → exit loop │ +│ 4. Spawn agent process for the task │ +│ 5. Wait for agent to complete │ +│ 6. Check for COMPLETION PROMISE in output │ +│ 7. On success: reset failure counter, mark complete │ +│ On failure: increment failure counter │ +│ 8. If --once flag → exit loop │ +│ 9. Wait delay seconds, goto step 1 │ +└─────────────────────────────────────────────────────────┘ +``` + +## Agent Spawning + +For each task, RALPH spawns an executor process: + +| Executor | Command (headless mode) | +|----------|------------------------| +| `claude` | `claude --dangerously-skip-permissions -p ""` | +| `opencode` | `opencode run ""` | +| Custom | ` ""` | + +The spawned agent receives: +1. Task ID and description +2. Operator guidance (if provided via `git agent run "prompt"`) +3. Instructions to complete ONE task only +4. Path to `zagi tasks done ` command +5. Required COMPLETION PROMISE format + +## Task Prompt Format + +Each spawned agent receives this prompt: + +``` +Task ID: task-001 +Task: Implement feature X + +Operator guidance: +Focus on error handling, add tests + +Instructions: +1. Read AGENTS.md if it exists for project context +2. Complete this ONE task only +3. Verify your work (run tests if applicable) +4. Commit changes: git commit -m "" +5. Mark done: /path/to/zagi tasks done task-001 +6. Output the COMPLETION PROMISE below + +COMPLETION PROMISE (required - output this exactly when done): + +COMPLETION PROMISE: I confirm that: +- Tests pass: [which tests ran, or "N/A" if no tests] +- Build succeeds: [build command, or "N/A" if no build] +- Changes committed: [commit hash and message] +- Task completed: [brief summary of what was done] +-- I have not taken any shortcuts or skipped verification. + +Rules: +- NEVER git push +- Only work on this task +- Must output the completion promise when done +``` + +## Success Detection + +RALPH determines task success by checking for the COMPLETION PROMISE in the agent's output: + +``` +COMPLETION PROMISE: I confirm that: +... +-- I have not taken any shortcuts or skipped verification. +``` + +Both the start and end markers must be present. This ensures the agent explicitly confirms completion rather than just exiting. + +## Failure Handling + +RALPH tracks **consecutive failures** per task: + +| Failures | Behavior | +|----------|----------| +| 0-2 | Retry task on next iteration | +| 3+ | Skip task, move to next | + +Consecutive tracking means: +- A success resets the counter to 0 +- Transient failures don't permanently block tasks +- 3 consecutive failures indicates a systemic problem + +## Usage + +### Basic execution +```bash +git agent run +``` + +### With operator guidance +```bash +git agent run "focus on error handling, prioritize tests" +``` + +### Single task execution +```bash +git agent run --once +``` + +### Dry run (preview) +```bash +git agent run --dry-run +``` + +### With limits +```bash +git agent run --max-tasks 5 --delay 5 +``` + +## Observability + +### Log files +Output is streamed to `/tmp/zagi//.log`: + +```bash +# Follow live output +tail -f /tmp/zagi/myproject/*.log + +# View specific run +cat /tmp/zagi/myproject/abc123.log +``` + +### Task status +```bash +git tasks list # View all tasks +git tasks show task-001 # View specific task +``` + +## Executor Configuration + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `ZAGI_AGENT` | Executor type: `claude` (default), `opencode` | +| `ZAGI_AGENT_CMD` | Custom command override | + +### Examples + +```bash +# Use Claude (default) +git agent run + +# Use OpenCode +ZAGI_AGENT=opencode git agent run + +# Use custom Claude binary +ZAGI_AGENT=claude ZAGI_AGENT_CMD="~/my-claude --flag" git agent run +# → Executes: ~/my-claude --flag --dangerously-skip-permissions -p "" + +# Use Aider (no auto flags) +ZAGI_AGENT_CMD="aider --yes" git agent run +# → Executes: aider --yes "" +``` + +## Planning Integration + +RALPH works with `git agent plan` for a complete workflow: + +```bash +# 1. Interactive planning session +git agent plan "Add user authentication" +# → Agent explores codebase, asks questions, creates tasks + +# 2. Review tasks +git tasks list + +# 3. Execute tasks +git agent run + +# 4. Generate PR description +git tasks pr +``` + +## Safety Features + +- **No git push**: Agents commit but never push +- **Consecutive failure limit**: Tasks skipped after 3 failures +- **Max tasks limit**: Optional cap on tasks per run +- **Isolated processes**: Each task runs in fresh agent +- **Explicit completion**: Requires COMPLETION PROMISE + +## Permissions + +### Claude +Uses `--dangerously-skip-permissions` for autonomous execution. This bypasses the interactive permission prompts that would block headless operation. + +### OpenCode +The `run` subcommand auto-approves all permissions for non-interactive execution. No additional flags needed. + +See [OpenCode Permissions](https://opencode.ai/docs/permissions/) for details. diff --git a/src/cmds/agent.zig b/src/cmds/agent.zig index be75b31..f9273a0 100644 --- a/src/cmds/agent.zig +++ b/src/cmds/agent.zig @@ -20,10 +20,13 @@ pub const help = ; const run_help = - \\usage: git agent run [options] + \\usage: git agent run [options] [prompt] \\ \\Execute RALPH loop to automatically complete tasks. \\ + \\Arguments: + \\ prompt Optional guidance for all tasks (e.g., focus areas, constraints) + \\ \\Options: \\ --once Run only one task, then exit \\ --dry-run Show what would run without executing @@ -34,7 +37,7 @@ const run_help = \\Examples: \\ git agent run \\ git agent run --once - \\ git agent run --dry-run + \\ git agent run "focus on error handling, add tests" \\ ZAGI_AGENT=opencode git agent run \\ ; @@ -109,6 +112,7 @@ fn buildExecutorArgs( } else if (std.mem.eql(u8, executor, "claude")) { try args.append(allocator, "claude"); if (!interactive) { + try args.append(allocator, "--dangerously-skip-permissions"); try args.append(allocator, "-p"); } try args.append(allocator, prompt); @@ -135,7 +139,7 @@ fn formatExecutorCommand(executor: []const u8, agent_cmd: ?[]const u8, interacti // Custom command - shown as-is, user is responsible for including flags if (agent_cmd) |cmd| return cmd; if (std.mem.eql(u8, executor, "claude")) { - return if (interactive) "claude" else "claude -p"; + return if (interactive) "claude" else "claude --dangerously-skip-permissions -p"; } if (std.mem.eql(u8, executor, "opencode")) { return if (interactive) "opencode" else "opencode run"; @@ -474,6 +478,7 @@ fn runRun(allocator: std.mem.Allocator, args: [][:0]u8) Error!void { var dry_run = false; var delay: u32 = 2; var max_tasks: ?u32 = null; + var operator_prompt: ?[]const u8 = null; var i: usize = 3; // Start after "zagi agent run" while (i < args.len) { @@ -508,9 +513,12 @@ fn runRun(allocator: std.mem.Allocator, args: [][:0]u8) Error!void { } else if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) { stdout.print("{s}", .{run_help}) catch {}; return; - } else { + } else if (arg[0] == '-') { stdout.print("error: unknown option '{s}'\n", .{arg}) catch {}; return Error.InvalidCommand; + } else { + // Positional argument = operator prompt + operator_prompt = arg; } i += 1; } @@ -580,7 +588,11 @@ fn runRun(allocator: std.mem.Allocator, args: [][:0]u8) Error!void { if (dry_run) { stdout.print("(dry-run mode - no commands will be executed)\n", .{}) catch {}; } - stdout.print("Executor: {s}\n\n", .{executor}) catch {}; + stdout.print("Executor: {s}\n", .{executor}) catch {}; + if (operator_prompt) |p| { + stdout.print("Prompt: {s}\n", .{p}) catch {}; + } + stdout.print("\n", .{}) catch {}; while (true) { if (max_tasks) |max| { @@ -641,7 +653,7 @@ fn runRun(allocator: std.mem.Allocator, args: [][:0]u8) Error!void { stdout.print("\n", .{}) catch {}; tasks_completed += 1; } else { - const success = executeTask(allocator, executor, agent_cmd, exe_path, task.id, task.content, log_path) catch false; + const success = executeTask(allocator, executor, agent_cmd, exe_path, task.id, task.content, operator_prompt, log_path) catch false; if (success) { updateFailureCount(allocator, &consecutive_failures, task.id, 0); @@ -728,10 +740,16 @@ fn getPendingTasks(allocator: std.mem.Allocator) !PendingTasks { return PendingTasks{ .tasks = pending.toOwnedSlice(allocator) catch &.{} }; } -fn createPrompt(allocator: std.mem.Allocator, exe_path: []const u8, task_id: []const u8, task_content: []const u8) ![]u8 { +fn createPrompt(allocator: std.mem.Allocator, exe_path: []const u8, task_id: []const u8, task_content: []const u8, operator_prompt: ?[]const u8) ![]u8 { + const prompt_section = if (operator_prompt) |p| + std.fmt.allocPrint(allocator, "\nOperator guidance:\n{s}\n", .{p}) catch "" + else + ""; + defer if (operator_prompt != null and prompt_section.len > 0) allocator.free(prompt_section); + return std.fmt.allocPrint(allocator, \\Task ID: {0s} - \\Task: {1s} + \\Task: {1s}{3s} \\ \\Instructions: \\1. Read AGENTS.md if it exists for project context @@ -762,13 +780,13 @@ fn createPrompt(allocator: std.mem.Allocator, exe_path: []const u8, task_id: []c \\- NEVER git push \\- Only work on this task \\- Must output the completion promise when done - , .{ task_id, task_content, exe_path }); + , .{ task_id, task_content, exe_path, prompt_section }); } -fn executeTask(allocator: std.mem.Allocator, executor: []const u8, agent_cmd: ?[]const u8, exe_path: []const u8, task_id: []const u8, task_content: []const u8, log_path: ?[]const u8) !bool { +fn executeTask(allocator: std.mem.Allocator, executor: []const u8, agent_cmd: ?[]const u8, exe_path: []const u8, task_id: []const u8, task_content: []const u8, operator_prompt: ?[]const u8, log_path: ?[]const u8) !bool { const stderr_writer = std.fs.File.stderr().deprecatedWriter(); - const prompt = try createPrompt(allocator, exe_path, task_id, task_content); + const prompt = try createPrompt(allocator, exe_path, task_id, task_content, operator_prompt); defer allocator.free(prompt); // Use headless mode (interactive=false) for autonomous task execution @@ -854,14 +872,15 @@ fn executeTask(allocator: std.mem.Allocator, executor: []const u8, agent_cmd: ?[ // Tests for buildExecutorArgs const testing = std.testing; -test "buildExecutorArgs - claude headless includes -p" { +test "buildExecutorArgs - claude headless includes --dangerously-skip-permissions and -p" { var args = try buildExecutorArgs(testing.allocator, "claude", null, "test prompt", false); defer args.deinit(testing.allocator); - try testing.expectEqual(@as(usize, 3), args.items.len); + try testing.expectEqual(@as(usize, 4), args.items.len); try testing.expectEqualStrings("claude", args.items[0]); - try testing.expectEqualStrings("-p", args.items[1]); - try testing.expectEqualStrings("test prompt", args.items[2]); + try testing.expectEqualStrings("--dangerously-skip-permissions", args.items[1]); + try testing.expectEqualStrings("-p", args.items[2]); + try testing.expectEqualStrings("test prompt", args.items[3]); } test "buildExecutorArgs - claude interactive no -p" { diff --git a/src/cmds/commit.zig b/src/cmds/commit.zig index f367f9b..1eaf819 100644 --- a/src/cmds/commit.zig +++ b/src/cmds/commit.zig @@ -309,28 +309,82 @@ pub fn run(allocator: std.mem.Allocator, args: [][:0]u8) git.Error!void { ); } - // 3. Store session transcript in refs/notes/session (if available) + // 3. Store session transcript in refs/notes/session (delta since last checkpoint) var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; const cwd = std.fs.cwd().realpath(".", &cwd_buf) catch null; if (cwd) |c_path| { - if (detect.readCurrentSession(allocator, agent, c_path)) |session| { - defer allocator.free(session.path); - defer allocator.free(session.transcript); - - const session_z = allocator.allocSentinel(u8, session.transcript.len, 0) catch null; - if (session_z) |sz| { - defer allocator.free(sz); - @memcpy(sz, session.transcript); - _ = c.git_note_create( - ¬e_oid, - repo, - "refs/notes/session", - signature, - signature, - &commit_oid, - sz.ptr, - 0, - ); + // Get checkpoint from parent commit (if any) + var checkpoint_uuid: ?[]const u8 = null; + if (head_commit) |hc| { + const parent_oid = c.git_commit_id(hc); + if (parent_oid != null) { + var checkpoint_note: ?*c.git_note = null; + if (c.git_note_read(&checkpoint_note, repo, "refs/notes/session-checkpoint", parent_oid) == 0) { + defer c.git_note_free(checkpoint_note); + const note_msg = c.git_note_message(checkpoint_note); + if (note_msg != null) { + checkpoint_uuid = std.mem.sliceTo(note_msg, 0); + } + } + } + } + + // Read session entries after checkpoint (or all if no checkpoint) + if (detect.readSessionEntriesAfter(allocator, agent, c_path, checkpoint_uuid)) |result| { + defer allocator.free(result.session_path); + defer { + for (result.entries) |entry| { + allocator.free(entry.uuid); + allocator.free(entry.timestamp); + allocator.free(entry.entry_type); + if (entry.role) |r| allocator.free(r); + if (entry.content) |cont| allocator.free(cont); + if (entry.tool_name) |t| allocator.free(t); + } + allocator.free(result.entries); + } + if (result.last_uuid) |last| { + defer allocator.free(last); + + // Format as markdown + const markdown = detect.formatSessionMarkdown(allocator, result.entries) catch null; + if (markdown) |md| { + defer allocator.free(md); + + // Store markdown in refs/notes/session + const session_z = allocator.allocSentinel(u8, md.len, 0) catch null; + if (session_z) |sz| { + defer allocator.free(sz); + @memcpy(sz, md); + _ = c.git_note_create( + ¬e_oid, + repo, + "refs/notes/session", + signature, + signature, + &commit_oid, + sz.ptr, + 0, + ); + } + + // Store checkpoint (last UUID) for next commit + const checkpoint_z = allocator.allocSentinel(u8, last.len, 0) catch null; + if (checkpoint_z) |cz| { + defer allocator.free(cz); + @memcpy(cz, last); + _ = c.git_note_create( + ¬e_oid, + repo, + "refs/notes/session-checkpoint", + signature, + signature, + &commit_oid, + cz.ptr, + 0, + ); + } + } } } } diff --git a/src/cmds/detect.zig b/src/cmds/detect.zig index da95931..61b54fd 100644 --- a/src/cmds/detect.zig +++ b/src/cmds/detect.zig @@ -77,11 +77,15 @@ pub fn readCurrentSession(allocator: std.mem.Allocator, agent: Agent, cwd: []con fn readClaudeCodeSession(allocator: std.mem.Allocator, cwd: []const u8) ?Session { const home = std.posix.getenv("HOME") orelse return null; - // Convert cwd to project hash (replace / with -) + // Resolve to main repo path (handles worktrees) + const project_path = resolveMainRepoPath(allocator, cwd) orelse return null; + defer allocator.free(project_path); + + // Convert to project hash (replace / with -) // e.g., /Users/matt/Documents/Github/zagi -> -Users-matt-Documents-Github-zagi var project_hash_buf: [512]u8 = undefined; var hash_len: usize = 0; - for (cwd) |char| { + for (project_path) |char| { if (hash_len >= project_hash_buf.len) break; project_hash_buf[hash_len] = if (char == '/') '-' else char; hash_len += 1; @@ -244,7 +248,798 @@ fn readOpenCodeSession(allocator: std.mem.Allocator) ?Session { return null; } +// ============================================================================ +// Session Checkpoint Support - Structured JSONL Parsing & Markdown Formatting +// ============================================================================ + +/// Parsed session entry for checkpoint tracking +pub const SessionEntry = struct { + uuid: []const u8, + timestamp: []const u8, + entry_type: []const u8, // "user", "assistant", "tool_result", etc. + role: ?[]const u8, // "user" or "assistant" for message types + content: ?[]const u8, // message content (may be truncated) + tool_name: ?[]const u8, // for tool_use entries +}; + +/// Result of reading session entries +pub const SessionEntriesResult = struct { + entries: []SessionEntry, + last_uuid: ?[]const u8, // UUID of last entry (for checkpoint) + session_path: []const u8, +}; + +/// Read session entries after a checkpoint UUID (or all if null) +/// Returns structured entries for markdown formatting +pub fn readSessionEntriesAfter( + allocator: std.mem.Allocator, + agent: Agent, + cwd: []const u8, + after_uuid: ?[]const u8, +) ?SessionEntriesResult { + return switch (agent) { + .claude => readClaudeEntriesAfter(allocator, cwd, after_uuid), + .opencode => readOpenCodeEntriesAfter(allocator, cwd, after_uuid), + else => null, + }; +} + +/// Resolve the main repository path (handles worktrees) +/// For worktrees, returns the main repo path instead of the worktree path +fn resolveMainRepoPath(allocator: std.mem.Allocator, cwd: []const u8) ?[]const u8 { + // Check if .git is a file (worktree) or directory (main repo) + const git_path = std.fmt.allocPrint(allocator, "{s}/.git", .{cwd}) catch return null; + defer allocator.free(git_path); + + const stat = std.fs.cwd().statFile(git_path) catch { + // No .git found, return cwd as-is + return allocator.dupe(u8, cwd) catch null; + }; + + if (stat.kind == .file) { + // Worktree: .git is a file containing "gitdir: /path/to/.git/worktrees/name" + const git_file = std.fs.cwd().openFile(git_path, .{}) catch return null; + defer git_file.close(); + + var buf: [1024]u8 = undefined; + const bytes_read = git_file.readAll(&buf) catch return null; + const content = std.mem.trim(u8, buf[0..bytes_read], " \t\r\n"); + + // Parse "gitdir: /path/to/.git/worktrees/name" + if (std.mem.startsWith(u8, content, "gitdir: ")) { + const gitdir = content[8..]; + // Find the main .git directory (remove /worktrees/name suffix) + if (std.mem.indexOf(u8, gitdir, "/worktrees/")) |idx| { + const main_git_dir = gitdir[0..idx]; + // Remove /.git suffix to get main repo path + if (std.mem.endsWith(u8, main_git_dir, "/.git")) { + return allocator.dupe(u8, main_git_dir[0 .. main_git_dir.len - 5]) catch null; + } + } + } + } + + // Regular repo or couldn't parse worktree, return cwd + return allocator.dupe(u8, cwd) catch null; +} + +/// Read Claude Code session entries after a checkpoint +fn readClaudeEntriesAfter( + allocator: std.mem.Allocator, + cwd: []const u8, + after_uuid: ?[]const u8, +) ?SessionEntriesResult { + const home = std.posix.getenv("HOME") orelse return null; + + // Resolve to main repo path (handles worktrees) + const project_path = resolveMainRepoPath(allocator, cwd) orelse return null; + defer allocator.free(project_path); + + // Convert to project hash + var project_hash_buf: [512]u8 = undefined; + var hash_len: usize = 0; + for (project_path) |char| { + if (hash_len >= project_hash_buf.len) break; + project_hash_buf[hash_len] = if (char == '/') '-' else char; + hash_len += 1; + } + const project_hash = project_hash_buf[0..hash_len]; + + // Build project directory path + const project_dir = std.fmt.allocPrint(allocator, "{s}/.claude/projects/{s}", .{ home, project_hash }) catch return null; + defer allocator.free(project_dir); + + // Find most recent .jsonl file + var dir = std.fs.cwd().openDir(project_dir, .{ .iterate = true }) catch return null; + defer dir.close(); + + var most_recent_path: ?[]const u8 = null; + var most_recent_mtime: i128 = 0; + + var iter = dir.iterate(); + while (iter.next() catch null) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.name, ".jsonl")) continue; + + const stat = dir.statFile(entry.name) catch continue; + const mtime = stat.mtime; + + if (most_recent_path == null or mtime > most_recent_mtime) { + if (most_recent_path) |old| allocator.free(old); + most_recent_path = std.fmt.allocPrint(allocator, "{s}/{s}", .{ project_dir, entry.name }) catch continue; + most_recent_mtime = mtime; + } + } + + const session_path = most_recent_path orelse return null; + + // Read file content + const file = std.fs.cwd().openFile(session_path, .{}) catch { + allocator.free(session_path); + return null; + }; + defer file.close(); + + // Use a large limit - session files can grow to hundreds of MB + const content = file.readToEndAlloc(allocator, 500 * 1024 * 1024) catch { + allocator.free(session_path); + return null; + }; + defer allocator.free(content); + + // Parse JSONL lines into entries + var entries = std.array_list.AlignedManaged(SessionEntry, null).init(allocator); + var found_checkpoint = after_uuid == null; // If no checkpoint, include all + var last_uuid: ?[]const u8 = null; + + var lines = std.mem.splitScalar(u8, content, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (trimmed.len == 0) continue; + + const entry = parseJsonlEntry(allocator, trimmed) catch continue; + + // Check if we've passed the checkpoint + if (!found_checkpoint) { + if (after_uuid) |checkpoint| { + if (std.mem.eql(u8, entry.uuid, checkpoint)) { + found_checkpoint = true; + } + } + // Free entry since we're skipping it + allocator.free(entry.uuid); + allocator.free(entry.timestamp); + allocator.free(entry.entry_type); + if (entry.role) |r| allocator.free(r); + if (entry.content) |c| allocator.free(c); + if (entry.tool_name) |t| allocator.free(t); + continue; + } + + // Skip internal entry types + if (std.mem.eql(u8, entry.entry_type, "summary") or + std.mem.eql(u8, entry.entry_type, "queue-operation")) + { + allocator.free(entry.uuid); + allocator.free(entry.timestamp); + allocator.free(entry.entry_type); + if (entry.role) |r| allocator.free(r); + if (entry.content) |c| allocator.free(c); + if (entry.tool_name) |t| allocator.free(t); + continue; + } + + // Track last UUID for checkpoint + if (last_uuid) |old| allocator.free(old); + last_uuid = allocator.dupe(u8, entry.uuid) catch null; + + entries.append(entry) catch continue; + } + + if (entries.items.len == 0) { + entries.deinit(); + if (last_uuid) |u| allocator.free(u); + allocator.free(session_path); + return null; + } + + return SessionEntriesResult{ + .entries = entries.toOwnedSlice() catch { + entries.deinit(); + if (last_uuid) |u| allocator.free(u); + allocator.free(session_path); + return null; + }, + .last_uuid = last_uuid, + .session_path = session_path, + }; +} + +/// Read OpenCode session entries after a checkpoint +/// OpenCode stores data in: +/// - ~/.local/share/opencode/storage/project/{project_id}.json -> contains worktree path +/// - ~/.local/share/opencode/storage/session/{project_id}/{session_id}.json -> session metadata with directory +/// - ~/.local/share/opencode/storage/message/{session_id}/msg_*.json -> message metadata +/// - ~/.local/share/opencode/storage/part/{message_id}/prt_*.json -> message content/parts +fn readOpenCodeEntriesAfter( + allocator: std.mem.Allocator, + cwd: []const u8, + after_uuid: ?[]const u8, +) ?SessionEntriesResult { + const home = std.posix.getenv("HOME") orelse return null; + + const base_dir = std.fmt.allocPrint(allocator, "{s}/.local/share/opencode/storage", .{home}) catch return null; + defer allocator.free(base_dir); + + // Find the project ID and most recent session for this cwd + const session_info = findOpenCodeSession(allocator, base_dir, cwd) orelse return null; + defer allocator.free(session_info.session_id); + + // Build message directory path + const message_dir = std.fmt.allocPrint(allocator, "{s}/message/{s}", .{ base_dir, session_info.session_id }) catch return null; + defer allocator.free(message_dir); + + // Build session path for return value (used as checkpoint reference) + const session_path = std.fmt.allocPrint(allocator, "{s}/session/{s}", .{ base_dir, session_info.session_id }) catch return null; + + // Read all messages and parts + var entries = std.array_list.AlignedManaged(SessionEntry, null).init(allocator); + var found_checkpoint = after_uuid == null; + var last_uuid: ?[]const u8 = null; + + // Open and iterate message directory + var dir = std.fs.cwd().openDir(message_dir, .{ .iterate = true }) catch { + allocator.free(session_path); + return null; + }; + defer dir.close(); + + // Collect message files and sort by name (which contains timestamp) + var msg_files = std.array_list.AlignedManaged([]const u8, null).init(allocator); + defer { + for (msg_files.items) |f| allocator.free(f); + msg_files.deinit(); + } + + var iter = dir.iterate(); + while (iter.next() catch null) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.name, ".json")) continue; + if (!std.mem.startsWith(u8, entry.name, "msg_")) continue; + msg_files.append(allocator.dupe(u8, entry.name) catch continue) catch continue; + } + + // Sort messages by filename (IDs are time-ordered) + std.mem.sort([]const u8, msg_files.items, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lessThan); + + // Process each message + for (msg_files.items) |msg_filename| { + const msg_content = dir.readFileAlloc(allocator, msg_filename, 100 * 1024) catch continue; + defer allocator.free(msg_content); + + const entry = parseOpenCodeMessage(allocator, base_dir, msg_content) catch continue; + + // Check if we've passed the checkpoint + if (!found_checkpoint) { + if (after_uuid) |checkpoint| { + if (std.mem.eql(u8, entry.uuid, checkpoint)) { + found_checkpoint = true; + } + } + // Free entry since we're skipping it + allocator.free(entry.uuid); + allocator.free(entry.timestamp); + allocator.free(entry.entry_type); + if (entry.role) |r| allocator.free(r); + if (entry.content) |cont| allocator.free(cont); + if (entry.tool_name) |t| allocator.free(t); + continue; + } + + // Track last UUID for checkpoint + if (last_uuid) |old| allocator.free(old); + last_uuid = allocator.dupe(u8, entry.uuid) catch null; + + entries.append(entry) catch continue; + } + + if (entries.items.len == 0) { + entries.deinit(); + if (last_uuid) |u| allocator.free(u); + allocator.free(session_path); + return null; + } + + return SessionEntriesResult{ + .entries = entries.toOwnedSlice() catch { + entries.deinit(); + if (last_uuid) |u| allocator.free(u); + allocator.free(session_path); + return null; + }, + .last_uuid = last_uuid, + .session_path = session_path, + }; +} + +/// Information about an OpenCode session +const OpenCodeSessionInfo = struct { + session_id: []const u8, +}; + +/// Find the most recent OpenCode session for a given working directory +fn findOpenCodeSession(allocator: std.mem.Allocator, base_dir: []const u8, cwd: []const u8) ?OpenCodeSessionInfo { + // First, find all project directories and check their sessions for matching cwd + const session_base = std.fmt.allocPrint(allocator, "{s}/session", .{base_dir}) catch return null; + defer allocator.free(session_base); + + var session_dir = std.fs.cwd().openDir(session_base, .{ .iterate = true }) catch return null; + defer session_dir.close(); + + var most_recent_session: ?[]const u8 = null; + var most_recent_time: i128 = 0; + + // Iterate through all project directories + var dir_iter = session_dir.iterate(); + while (dir_iter.next() catch null) |project_entry| { + if (project_entry.kind != .directory) continue; + + // Open project session directory + var project_dir = session_dir.openDir(project_entry.name, .{ .iterate = true }) catch continue; + defer project_dir.close(); + + // Check each session file in this project + var sess_iter = project_dir.iterate(); + while (sess_iter.next() catch null) |sess_entry| { + if (sess_entry.kind != .file) continue; + if (!std.mem.endsWith(u8, sess_entry.name, ".json")) continue; + if (!std.mem.startsWith(u8, sess_entry.name, "ses_")) continue; + + // Read session file to check directory + const sess_content = project_dir.readFileAlloc(allocator, sess_entry.name, 10 * 1024) catch continue; + defer allocator.free(sess_content); + + const parsed = std.json.parseFromSlice(std.json.Value, allocator, sess_content, .{}) catch continue; + defer parsed.deinit(); + + if (parsed.value != .object) continue; + const obj = parsed.value.object; + + // Check if directory matches cwd + const dir_val = obj.get("directory") orelse continue; + if (dir_val != .string) continue; + + if (!std.mem.eql(u8, dir_val.string, cwd)) continue; + + // Get session ID + const id_val = obj.get("id") orelse continue; + if (id_val != .string) continue; + + // Get update time for sorting + var update_time: i128 = 0; + if (obj.get("time")) |time_val| { + if (time_val == .object) { + if (time_val.object.get("updated")) |updated| { + switch (updated) { + .integer => |i| update_time = i, + .number_string => |s| update_time = std.fmt.parseInt(i128, s, 10) catch 0, + else => {}, + } + } + } + } + + if (most_recent_session == null or update_time > most_recent_time) { + if (most_recent_session) |old| allocator.free(old); + most_recent_session = allocator.dupe(u8, id_val.string) catch continue; + most_recent_time = update_time; + } + } + } + + if (most_recent_session) |session_id| { + return OpenCodeSessionInfo{ + .session_id = session_id, + }; + } + + return null; +} + +/// Parse an OpenCode message file and its parts into a SessionEntry +fn parseOpenCodeMessage(allocator: std.mem.Allocator, base_dir: []const u8, msg_json: []const u8) !SessionEntry { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, msg_json, .{}) catch return error.ParseError; + defer parsed.deinit(); + + if (parsed.value != .object) return error.ParseError; + const obj = parsed.value.object; + + // Extract message ID (required) + const id_val = obj.get("id") orelse return error.MissingId; + const msg_id = switch (id_val) { + .string => |s| s, + else => return error.MissingId, + }; + + // Extract role (required) + const role_val = obj.get("role") orelse return error.MissingRole; + const role = switch (role_val) { + .string => |s| s, + else => return error.MissingRole, + }; + + // Extract timestamp from time.created + var timestamp_buf: [32]u8 = undefined; + var timestamp: []const u8 = "1970-01-01T00:00:00.000Z"; + if (obj.get("time")) |time_val| { + if (time_val == .object) { + if (time_val.object.get("created")) |created| { + switch (created) { + .integer => |ms| { + // Convert epoch ms to ISO format + timestamp = formatEpochMs(ms, ×tamp_buf); + }, + .number_string => |s| { + const ms = std.fmt.parseInt(i64, s, 10) catch 0; + timestamp = formatEpochMs(ms, ×tamp_buf); + }, + else => {}, + } + } + } + } + + // Determine entry type based on role + const entry_type = role; + + // Read parts to get content and tool info + var content: ?[]const u8 = null; + var tool_name: ?[]const u8 = null; + + const part_dir = std.fmt.allocPrint(allocator, "{s}/part/{s}", .{ base_dir, msg_id }) catch return error.ParseError; + defer allocator.free(part_dir); + + var dir = std.fs.cwd().openDir(part_dir, .{ .iterate = true }) catch { + // No parts directory - return entry with just metadata + return SessionEntry{ + .uuid = try allocator.dupe(u8, msg_id), + .timestamp = try allocator.dupe(u8, timestamp), + .entry_type = try allocator.dupe(u8, entry_type), + .role = try allocator.dupe(u8, role), + .content = null, + .tool_name = null, + }; + }; + defer dir.close(); + + // Read parts + var part_iter = dir.iterate(); + while (part_iter.next() catch null) |part_entry| { + if (part_entry.kind != .file) continue; + if (!std.mem.endsWith(u8, part_entry.name, ".json")) continue; + if (!std.mem.startsWith(u8, part_entry.name, "prt_")) continue; + + const part_content = dir.readFileAlloc(allocator, part_entry.name, 50 * 1024) catch continue; + defer allocator.free(part_content); + + const part_parsed = std.json.parseFromSlice(std.json.Value, allocator, part_content, .{}) catch continue; + defer part_parsed.deinit(); + + if (part_parsed.value != .object) continue; + const part_obj = part_parsed.value.object; + + // Get part type + const part_type_val = part_obj.get("type") orelse continue; + if (part_type_val != .string) continue; + const part_type = part_type_val.string; + + if (std.mem.eql(u8, part_type, "text")) { + // Text content + if (part_obj.get("text")) |text_val| { + if (text_val == .string) { + const text = text_val.string; + const max_len: usize = 2000; + if (text.len > max_len) { + content = allocator.dupe(u8, text[0..max_len]) catch null; + } else { + content = allocator.dupe(u8, text) catch null; + } + } + } + } else if (std.mem.eql(u8, part_type, "tool")) { + // Tool usage + if (part_obj.get("tool")) |tool_val| { + if (tool_val == .string) { + tool_name = allocator.dupe(u8, tool_val.string) catch null; + } + } + } + } + + return SessionEntry{ + .uuid = try allocator.dupe(u8, msg_id), + .timestamp = try allocator.dupe(u8, timestamp), + .entry_type = try allocator.dupe(u8, entry_type), + .role = try allocator.dupe(u8, role), + .content = content, + .tool_name = tool_name, + }; +} + +/// Format epoch milliseconds to ISO 8601 timestamp string +fn formatEpochMs(ms: i64, buf: *[32]u8) []const u8 { + // Convert milliseconds to seconds and remaining ms + const secs: u64 = @intCast(@divFloor(ms, 1000)); + const rem_ms: u64 = @intCast(@mod(ms, 1000)); + + // Use Zig's epoch seconds conversion + const epoch = std.time.epoch.EpochSeconds{ .secs = secs }; + const day_secs = epoch.getDaySeconds(); + const year_day = epoch.getEpochDay().calculateYearDay(); + + const hour = day_secs.getHoursIntoDay(); + const minute = day_secs.getMinutesIntoHour(); + const second = day_secs.getSecondsIntoMinute(); + + const month_day = year_day.calculateMonthDay(); + const year = year_day.year; + const month = month_day.month.numeric(); + const day = month_day.day_index + 1; + + const len = std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}.{d:0>3}Z", .{ + year, month, day, hour, minute, second, rem_ms, + }) catch return "1970-01-01T00:00:00.000Z"; + + return len; +} + +/// Parse a single JSONL line into a SessionEntry +/// Uses std.json.Value for flexible content parsing (content can be string or array) +fn parseJsonlEntry(allocator: std.mem.Allocator, line: []const u8) !SessionEntry { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, line, .{}) catch return error.ParseError; + defer parsed.deinit(); + + const root = parsed.value; + if (root != .object) return error.ParseError; + + const obj = root.object; + + // Extract uuid (required) + const uuid_val = obj.get("uuid") orelse return error.MissingUuid; + const uuid = switch (uuid_val) { + .string => |s| s, + else => return error.MissingUuid, + }; + + // Extract timestamp (required) + const timestamp_val = obj.get("timestamp") orelse return error.MissingTimestamp; + const timestamp = switch (timestamp_val) { + .string => |s| s, + else => return error.MissingTimestamp, + }; + + // Extract type + const entry_type_val = obj.get("type"); + const entry_type = if (entry_type_val) |v| switch (v) { + .string => |s| s, + else => "unknown", + } else "unknown"; + + // Extract content from message + var content_text: ?[]const u8 = null; + var role: ?[]const u8 = null; + var tool_name: ?[]const u8 = null; + + if (obj.get("message")) |msg_val| { + if (msg_val == .object) { + const msg = msg_val.object; + + // Get role + if (msg.get("role")) |role_val| { + if (role_val == .string) { + role = try allocator.dupe(u8, role_val.string); + } + } + + // Get content (can be string or array) + if (msg.get("content")) |content_val| { + switch (content_val) { + .string => |s| { + // User messages typically have string content + const max_len: usize = 2000; + if (s.len > max_len) { + content_text = try allocator.dupe(u8, s[0..max_len]); + } else { + content_text = try allocator.dupe(u8, s); + } + }, + .array => |blocks| { + // Assistant messages have array of content blocks + for (blocks.items) |block_val| { + if (block_val != .object) continue; + const block = block_val.object; + + // Get block type + const block_type_val = block.get("type") orelse continue; + if (block_type_val != .string) continue; + const block_type = block_type_val.string; + + if (std.mem.eql(u8, block_type, "text")) { + // Extract text content + if (block.get("text")) |text_val| { + if (text_val == .string) { + const text = text_val.string; + const max_len: usize = 2000; + if (text.len > max_len) { + content_text = try allocator.dupe(u8, text[0..max_len]); + } else { + content_text = try allocator.dupe(u8, text); + } + break; + } + } + } else if (std.mem.eql(u8, block_type, "tool_use")) { + // Extract tool name + if (block.get("name")) |name_val| { + if (name_val == .string) { + tool_name = try allocator.dupe(u8, name_val.string); + } + } + } + } + }, + else => {}, + } + } + } + } + + return SessionEntry{ + .uuid = try allocator.dupe(u8, uuid), + .timestamp = try allocator.dupe(u8, timestamp), + .entry_type = try allocator.dupe(u8, entry_type), + .role = role, + .content = content_text, + .tool_name = tool_name, + }; +} + +/// Format session entries as GitHub-flavored markdown +pub fn formatSessionMarkdown( + allocator: std.mem.Allocator, + entries: []const SessionEntry, +) ![]const u8 { + if (entries.len == 0) { + return try allocator.dupe(u8, "_No new session activity_"); + } + + var result = std.array_list.AlignedManaged(u8, null).init(allocator); + errdefer result.deinit(); + + // Get time range from first and last entries + const first_time = formatTimestamp(entries[0].timestamp); + const last_time = formatTimestamp(entries[entries.len - 1].timestamp); + + // Count message types for summary + var user_count: usize = 0; + var assistant_count: usize = 0; + var tool_count: usize = 0; + for (entries) |entry| { + if (entry.role) |role| { + if (std.mem.eql(u8, role, "user")) { + user_count += 1; + } else if (std.mem.eql(u8, role, "assistant")) { + assistant_count += 1; + } + } + if (entry.tool_name != null) { + tool_count += 1; + } + } + + // Write header + try result.appendSlice("
\nSession ("); + var count_buf: [32]u8 = undefined; + const count_str = std.fmt.bufPrint(&count_buf, "{d} user, {d} assistant", .{ user_count, assistant_count }) catch "messages"; + try result.appendSlice(count_str); + if (tool_count > 0) { + const tool_str = std.fmt.bufPrint(&count_buf, ", {d} tools", .{tool_count}) catch ""; + try result.appendSlice(tool_str); + } + try result.appendSlice(" | "); + try result.appendSlice(&first_time); + try result.appendSlice(" - "); + try result.appendSlice(&last_time); + try result.appendSlice(")\n\n"); + + // Write each entry + var current_tools = std.array_list.AlignedManaged([]const u8, null).init(allocator); + defer current_tools.deinit(); + + for (entries) |entry| { + // Collect tool names for assistant messages + if (entry.tool_name) |tool| { + current_tools.append(tool) catch {}; + continue; + } + + if (entry.role) |role| { + const time_str = formatTimestamp(entry.timestamp); + + if (std.mem.eql(u8, role, "user")) { + // Flush any pending tools before user message + if (current_tools.items.len > 0) { + try result.appendSlice("**Tools:** "); + for (current_tools.items, 0..) |tool, i| { + if (i > 0) try result.appendSlice(", "); + try result.appendSlice(tool); + } + try result.appendSlice("\n\n"); + current_tools.clearRetainingCapacity(); + } + + try result.appendSlice("### User _"); + try result.appendSlice(&time_str); + try result.appendSlice("_\n"); + if (entry.content) |content| { + try result.appendSlice(content); + if (content.len == 2000) { + try result.appendSlice("..."); + } + } + try result.appendSlice("\n\n"); + } else if (std.mem.eql(u8, role, "assistant")) { + try result.appendSlice("### Assistant _"); + try result.appendSlice(&time_str); + try result.appendSlice("_\n"); + if (entry.content) |content| { + try result.appendSlice(content); + if (content.len == 2000) { + try result.appendSlice("..."); + } + } + try result.appendSlice("\n\n"); + } + } + } + + // Flush any remaining tools + if (current_tools.items.len > 0) { + try result.appendSlice("**Tools:** "); + for (current_tools.items, 0..) |tool, i| { + if (i > 0) try result.appendSlice(", "); + try result.appendSlice(tool); + } + try result.appendSlice("\n\n"); + } + + try result.appendSlice("
"); + + return result.toOwnedSlice(); +} + +/// Format ISO timestamp to short time string (HH:MM) +fn formatTimestamp(timestamp: []const u8) [5]u8 { + // ISO format: 2026-01-09T13:18:32.503Z + // Extract HH:MM starting at position 11 + var result: [5]u8 = "??:??".*; + if (timestamp.len >= 16) { + result[0] = timestamp[11]; + result[1] = timestamp[12]; + result[2] = ':'; + result[3] = timestamp[14]; + result[4] = timestamp[15]; + } + return result; +} + // Tests +const testing = std.testing; + test "isAgentMode returns false when no env vars set" { // Note: This test assumes env vars are not set in test environment // In practice, we can't easily unset env vars in Zig tests @@ -257,3 +1052,892 @@ test "detectAgent returns based on env vars" { // Without mocking, this will return based on actual env _ = agent.name(); } + +// ============================================================================ +// parseJsonlEntry tests +// ============================================================================ + +test "parseJsonlEntry parses user message with string content" { + const allocator = testing.allocator; + const jsonl = + \\{"uuid":"abc-123","timestamp":"2026-01-09T13:18:32.503Z","type":"user","message":{"role":"user","content":"Hello world"}} + ; + + const entry = try parseJsonlEntry(allocator, jsonl); + defer { + allocator.free(entry.uuid); + allocator.free(entry.timestamp); + allocator.free(entry.entry_type); + if (entry.role) |r| allocator.free(r); + if (entry.content) |c| allocator.free(c); + if (entry.tool_name) |t| allocator.free(t); + } + + try testing.expectEqualStrings("abc-123", entry.uuid); + try testing.expectEqualStrings("2026-01-09T13:18:32.503Z", entry.timestamp); + try testing.expectEqualStrings("user", entry.entry_type); + try testing.expectEqualStrings("user", entry.role.?); + try testing.expectEqualStrings("Hello world", entry.content.?); + try testing.expect(entry.tool_name == null); +} + +test "parseJsonlEntry parses assistant message with text block" { + const allocator = testing.allocator; + const jsonl = + \\{"uuid":"def-456","timestamp":"2026-01-09T13:19:00.000Z","type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I can help with that."}]}} + ; + + const entry = try parseJsonlEntry(allocator, jsonl); + defer { + allocator.free(entry.uuid); + allocator.free(entry.timestamp); + allocator.free(entry.entry_type); + if (entry.role) |r| allocator.free(r); + if (entry.content) |c| allocator.free(c); + if (entry.tool_name) |t| allocator.free(t); + } + + try testing.expectEqualStrings("def-456", entry.uuid); + try testing.expectEqualStrings("assistant", entry.entry_type); + try testing.expectEqualStrings("assistant", entry.role.?); + try testing.expectEqualStrings("I can help with that.", entry.content.?); +} + +test "parseJsonlEntry parses assistant message with tool_use block" { + const allocator = testing.allocator; + const jsonl = + \\{"uuid":"ghi-789","timestamp":"2026-01-09T13:20:00.000Z","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"path":"/test.txt"}}]}} + ; + + const entry = try parseJsonlEntry(allocator, jsonl); + defer { + allocator.free(entry.uuid); + allocator.free(entry.timestamp); + allocator.free(entry.entry_type); + if (entry.role) |r| allocator.free(r); + if (entry.content) |c| allocator.free(c); + if (entry.tool_name) |t| allocator.free(t); + } + + try testing.expectEqualStrings("ghi-789", entry.uuid); + try testing.expectEqualStrings("Read", entry.tool_name.?); + try testing.expect(entry.content == null); +} + +test "parseJsonlEntry captures first text block and stops (text before tool)" { + const allocator = testing.allocator; + // When text comes before tool_use, only text is captured (implementation breaks after text) + const jsonl = + \\{"uuid":"jkl-012","timestamp":"2026-01-09T13:21:00.000Z","type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Let me read that file."},{"type":"tool_use","name":"Read","input":{}}]}} + ; + + const entry = try parseJsonlEntry(allocator, jsonl); + defer { + allocator.free(entry.uuid); + allocator.free(entry.timestamp); + allocator.free(entry.entry_type); + if (entry.role) |r| allocator.free(r); + if (entry.content) |c| allocator.free(c); + if (entry.tool_name) |t| allocator.free(t); + } + + // First text block should be captured, then break happens + try testing.expectEqualStrings("Let me read that file.", entry.content.?); + // Tool name not captured because break happens after text block + try testing.expect(entry.tool_name == null); +} + +test "parseJsonlEntry captures tool when it comes before text" { + const allocator = testing.allocator; + // When tool_use comes before text, tool is captured, then text is captured (and breaks) + const jsonl = + \\{"uuid":"mno-345","timestamp":"2026-01-09T13:21:30.000Z","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Write","input":{}},{"type":"text","text":"Done writing."}]}} + ; + + const entry = try parseJsonlEntry(allocator, jsonl); + defer { + allocator.free(entry.uuid); + allocator.free(entry.timestamp); + allocator.free(entry.entry_type); + if (entry.role) |r| allocator.free(r); + if (entry.content) |c| allocator.free(c); + if (entry.tool_name) |t| allocator.free(t); + } + + // Tool name is captured first + try testing.expectEqualStrings("Write", entry.tool_name.?); + // Then text is captured (and breaks) + try testing.expectEqualStrings("Done writing.", entry.content.?); +} + +test "parseJsonlEntry returns error on missing uuid" { + const allocator = testing.allocator; + const jsonl = + \\{"timestamp":"2026-01-09T13:18:32.503Z","type":"user"} + ; + + try testing.expectError(error.MissingUuid, parseJsonlEntry(allocator, jsonl)); +} + +test "parseJsonlEntry returns error on missing timestamp" { + const allocator = testing.allocator; + const jsonl = + \\{"uuid":"abc-123","type":"user"} + ; + + try testing.expectError(error.MissingTimestamp, parseJsonlEntry(allocator, jsonl)); +} + +test "parseJsonlEntry returns error on invalid json" { + const allocator = testing.allocator; + const jsonl = "not valid json"; + + try testing.expectError(error.ParseError, parseJsonlEntry(allocator, jsonl)); +} + +test "parseJsonlEntry handles entry without message field" { + const allocator = testing.allocator; + const jsonl = + \\{"uuid":"xyz-999","timestamp":"2026-01-09T13:22:00.000Z","type":"summary"} + ; + + const entry = try parseJsonlEntry(allocator, jsonl); + defer { + allocator.free(entry.uuid); + allocator.free(entry.timestamp); + allocator.free(entry.entry_type); + if (entry.role) |r| allocator.free(r); + if (entry.content) |c| allocator.free(c); + if (entry.tool_name) |t| allocator.free(t); + } + + try testing.expectEqualStrings("xyz-999", entry.uuid); + try testing.expectEqualStrings("summary", entry.entry_type); + try testing.expect(entry.role == null); + try testing.expect(entry.content == null); +} + +test "parseJsonlEntry truncates long content to 2000 chars" { + const allocator = testing.allocator; + + // Create content longer than 2000 chars + var long_content: [2500]u8 = undefined; + for (&long_content) |*c| { + c.* = 'a'; + } + + var json_buf: [3000]u8 = undefined; + const json_str = std.fmt.bufPrint(&json_buf, "{{\"uuid\":\"long-001\",\"timestamp\":\"2026-01-09T13:23:00.000Z\",\"type\":\"user\",\"message\":{{\"role\":\"user\",\"content\":\"{s}\"}}}}", .{long_content}) catch unreachable; + + const entry = try parseJsonlEntry(allocator, json_str); + defer { + allocator.free(entry.uuid); + allocator.free(entry.timestamp); + allocator.free(entry.entry_type); + if (entry.role) |r| allocator.free(r); + if (entry.content) |c| allocator.free(c); + if (entry.tool_name) |t| allocator.free(t); + } + + try testing.expect(entry.content.?.len == 2000); +} + +// ============================================================================ +// formatTimestamp tests +// ============================================================================ + +test "formatTimestamp extracts HH:MM from ISO timestamp" { + const result = formatTimestamp("2026-01-09T13:18:32.503Z"); + try testing.expectEqualStrings("13:18", &result); +} + +test "formatTimestamp handles midnight" { + const result = formatTimestamp("2026-01-09T00:00:00.000Z"); + try testing.expectEqualStrings("00:00", &result); +} + +test "formatTimestamp handles end of day" { + const result = formatTimestamp("2026-01-09T23:59:59.999Z"); + try testing.expectEqualStrings("23:59", &result); +} + +test "formatTimestamp returns placeholder for short string" { + const result = formatTimestamp("short"); + try testing.expectEqualStrings("??:??", &result); +} + +test "formatTimestamp returns placeholder for empty string" { + const result = formatTimestamp(""); + try testing.expectEqualStrings("??:??", &result); +} + +// ============================================================================ +// formatSessionMarkdown tests +// ============================================================================ + +test "formatSessionMarkdown returns no activity message for empty entries" { + const allocator = testing.allocator; + const entries: []const SessionEntry = &[_]SessionEntry{}; + + const result = try formatSessionMarkdown(allocator, entries); + defer allocator.free(result); + + try testing.expectEqualStrings("_No new session activity_", result); +} + +test "formatSessionMarkdown formats single user message" { + const allocator = testing.allocator; + + var entries = [_]SessionEntry{ + .{ + .uuid = "uuid-1", + .timestamp = "2026-01-09T10:30:00.000Z", + .entry_type = "user", + .role = "user", + .content = "Hello there", + .tool_name = null, + }, + }; + + const result = try formatSessionMarkdown(allocator, &entries); + defer allocator.free(result); + + // Check for expected structure + try testing.expect(std.mem.indexOf(u8, result, "
") != null); + try testing.expect(std.mem.indexOf(u8, result, "
") != null); + try testing.expect(std.mem.indexOf(u8, result, "1 user, 0 assistant") != null); + try testing.expect(std.mem.indexOf(u8, result, "### User _10:30_") != null); + try testing.expect(std.mem.indexOf(u8, result, "Hello there") != null); +} + +test "formatSessionMarkdown formats user and assistant messages" { + const allocator = testing.allocator; + + var entries = [_]SessionEntry{ + .{ + .uuid = "uuid-1", + .timestamp = "2026-01-09T10:30:00.000Z", + .entry_type = "user", + .role = "user", + .content = "What is 2+2?", + .tool_name = null, + }, + .{ + .uuid = "uuid-2", + .timestamp = "2026-01-09T10:31:00.000Z", + .entry_type = "assistant", + .role = "assistant", + .content = "2+2 equals 4.", + .tool_name = null, + }, + }; + + const result = try formatSessionMarkdown(allocator, &entries); + defer allocator.free(result); + + try testing.expect(std.mem.indexOf(u8, result, "1 user, 1 assistant") != null); + try testing.expect(std.mem.indexOf(u8, result, "### User _10:30_") != null); + try testing.expect(std.mem.indexOf(u8, result, "### Assistant _10:31_") != null); + try testing.expect(std.mem.indexOf(u8, result, "What is 2+2?") != null); + try testing.expect(std.mem.indexOf(u8, result, "2+2 equals 4.") != null); +} + +test "formatSessionMarkdown includes tool count in summary" { + const allocator = testing.allocator; + + var entries = [_]SessionEntry{ + .{ + .uuid = "uuid-1", + .timestamp = "2026-01-09T10:30:00.000Z", + .entry_type = "user", + .role = "user", + .content = "Read the file", + .tool_name = null, + }, + .{ + .uuid = "uuid-2", + .timestamp = "2026-01-09T10:31:00.000Z", + .entry_type = "assistant", + .role = "assistant", + .content = null, + .tool_name = "Read", + }, + .{ + .uuid = "uuid-3", + .timestamp = "2026-01-09T10:32:00.000Z", + .entry_type = "assistant", + .role = "assistant", + .content = "Here is the file content.", + .tool_name = null, + }, + }; + + const result = try formatSessionMarkdown(allocator, &entries); + defer allocator.free(result); + + try testing.expect(std.mem.indexOf(u8, result, "1 tools") != null); + try testing.expect(std.mem.indexOf(u8, result, "**Tools:** Read") != null); +} + +test "formatSessionMarkdown shows time range in header" { + const allocator = testing.allocator; + + var entries = [_]SessionEntry{ + .{ + .uuid = "uuid-1", + .timestamp = "2026-01-09T09:00:00.000Z", + .entry_type = "user", + .role = "user", + .content = "First message", + .tool_name = null, + }, + .{ + .uuid = "uuid-2", + .timestamp = "2026-01-09T11:30:00.000Z", + .entry_type = "assistant", + .role = "assistant", + .content = "Last message", + .tool_name = null, + }, + }; + + const result = try formatSessionMarkdown(allocator, &entries); + defer allocator.free(result); + + // Should show time range from first to last entry + try testing.expect(std.mem.indexOf(u8, result, "09:00 - 11:30") != null); +} + +test "formatSessionMarkdown adds ellipsis for truncated content" { + const allocator = testing.allocator; + + // Content exactly 2000 chars (will have ellipsis added) + var content_2000: [2000]u8 = undefined; + for (&content_2000) |*c| { + c.* = 'x'; + } + + var entries = [_]SessionEntry{ + .{ + .uuid = "uuid-1", + .timestamp = "2026-01-09T10:30:00.000Z", + .entry_type = "user", + .role = "user", + .content = &content_2000, + .tool_name = null, + }, + }; + + const result = try formatSessionMarkdown(allocator, &entries); + defer allocator.free(result); + + // Should have ellipsis after the 2000-char content + try testing.expect(std.mem.indexOf(u8, result, "...") != null); +} + +test "formatSessionMarkdown handles multiple tools in sequence" { + const allocator = testing.allocator; + + var entries = [_]SessionEntry{ + .{ + .uuid = "uuid-1", + .timestamp = "2026-01-09T10:30:00.000Z", + .entry_type = "user", + .role = "user", + .content = "Edit these files", + .tool_name = null, + }, + .{ + .uuid = "uuid-2", + .timestamp = "2026-01-09T10:31:00.000Z", + .entry_type = "assistant", + .role = "assistant", + .content = null, + .tool_name = "Read", + }, + .{ + .uuid = "uuid-3", + .timestamp = "2026-01-09T10:31:30.000Z", + .entry_type = "assistant", + .role = "assistant", + .content = null, + .tool_name = "Edit", + }, + .{ + .uuid = "uuid-4", + .timestamp = "2026-01-09T10:32:00.000Z", + .entry_type = "assistant", + .role = "assistant", + .content = null, + .tool_name = "Write", + }, + .{ + .uuid = "uuid-5", + .timestamp = "2026-01-09T10:33:00.000Z", + .entry_type = "user", + .role = "user", + .content = "Thanks", + .tool_name = null, + }, + }; + + const result = try formatSessionMarkdown(allocator, &entries); + defer allocator.free(result); + + // Should show "3 tools" in summary + try testing.expect(std.mem.indexOf(u8, result, "3 tools") != null); + // Should list all tools before the next user message + try testing.expect(std.mem.indexOf(u8, result, "**Tools:** Read, Edit, Write") != null); +} + +test "formatSessionMarkdown handles messages with null content" { + const allocator = testing.allocator; + + var entries = [_]SessionEntry{ + .{ + .uuid = "uuid-1", + .timestamp = "2026-01-09T10:30:00.000Z", + .entry_type = "user", + .role = "user", + .content = null, // No content + .tool_name = null, + }, + .{ + .uuid = "uuid-2", + .timestamp = "2026-01-09T10:31:00.000Z", + .entry_type = "assistant", + .role = "assistant", + .content = null, // No content + .tool_name = null, + }, + }; + + const result = try formatSessionMarkdown(allocator, &entries); + defer allocator.free(result); + + // Should still produce valid markdown structure + try testing.expect(std.mem.indexOf(u8, result, "
") != null); + try testing.expect(std.mem.indexOf(u8, result, "
") != null); + try testing.expect(std.mem.indexOf(u8, result, "### User _10:30_") != null); + try testing.expect(std.mem.indexOf(u8, result, "### Assistant _10:31_") != null); +} + +test "formatSessionMarkdown skips entries without role" { + const allocator = testing.allocator; + + var entries = [_]SessionEntry{ + .{ + .uuid = "uuid-1", + .timestamp = "2026-01-09T10:30:00.000Z", + .entry_type = "user", + .role = "user", + .content = "Hello", + .tool_name = null, + }, + .{ + .uuid = "uuid-2", + .timestamp = "2026-01-09T10:31:00.000Z", + .entry_type = "summary", // Internal type with no role + .role = null, + .content = "Summary content", + .tool_name = null, + }, + .{ + .uuid = "uuid-3", + .timestamp = "2026-01-09T10:32:00.000Z", + .entry_type = "assistant", + .role = "assistant", + .content = "Goodbye", + .tool_name = null, + }, + }; + + const result = try formatSessionMarkdown(allocator, &entries); + defer allocator.free(result); + + // Should only count messages with roles + try testing.expect(std.mem.indexOf(u8, result, "1 user, 1 assistant") != null); + // Summary content should NOT appear (no role means entry is skipped) + try testing.expect(std.mem.indexOf(u8, result, "Summary content") == null); +} + +test "formatSessionMarkdown handles interleaved conversation" { + const allocator = testing.allocator; + + var entries = [_]SessionEntry{ + .{ + .uuid = "uuid-1", + .timestamp = "2026-01-09T10:00:00.000Z", + .entry_type = "user", + .role = "user", + .content = "Question 1", + .tool_name = null, + }, + .{ + .uuid = "uuid-2", + .timestamp = "2026-01-09T10:01:00.000Z", + .entry_type = "assistant", + .role = "assistant", + .content = "Answer 1", + .tool_name = null, + }, + .{ + .uuid = "uuid-3", + .timestamp = "2026-01-09T10:02:00.000Z", + .entry_type = "user", + .role = "user", + .content = "Question 2", + .tool_name = null, + }, + .{ + .uuid = "uuid-4", + .timestamp = "2026-01-09T10:03:00.000Z", + .entry_type = "assistant", + .role = "assistant", + .content = "Answer 2", + .tool_name = null, + }, + }; + + const result = try formatSessionMarkdown(allocator, &entries); + defer allocator.free(result); + + // Should count correctly + try testing.expect(std.mem.indexOf(u8, result, "2 user, 2 assistant") != null); + + // Check order is preserved (Question 1 appears before Answer 1, etc.) + const q1_pos = std.mem.indexOf(u8, result, "Question 1").?; + const a1_pos = std.mem.indexOf(u8, result, "Answer 1").?; + const q2_pos = std.mem.indexOf(u8, result, "Question 2").?; + const a2_pos = std.mem.indexOf(u8, result, "Answer 2").?; + + try testing.expect(q1_pos < a1_pos); + try testing.expect(a1_pos < q2_pos); + try testing.expect(q2_pos < a2_pos); +} + +test "formatSessionMarkdown flushes tools at end of entries" { + const allocator = testing.allocator; + + // Conversation ends with tool usage (no subsequent user message to trigger flush) + var entries = [_]SessionEntry{ + .{ + .uuid = "uuid-1", + .timestamp = "2026-01-09T10:30:00.000Z", + .entry_type = "user", + .role = "user", + .content = "Do something", + .tool_name = null, + }, + .{ + .uuid = "uuid-2", + .timestamp = "2026-01-09T10:31:00.000Z", + .entry_type = "assistant", + .role = "assistant", + .content = null, + .tool_name = "Bash", + }, + .{ + .uuid = "uuid-3", + .timestamp = "2026-01-09T10:32:00.000Z", + .entry_type = "assistant", + .role = "assistant", + .content = null, + .tool_name = "Read", + }, + }; + + const result = try formatSessionMarkdown(allocator, &entries); + defer allocator.free(result); + + // Tools should be flushed at end + try testing.expect(std.mem.indexOf(u8, result, "**Tools:** Bash, Read") != null); +} + +test "formatSessionMarkdown handles content less than 2000 chars without ellipsis" { + const allocator = testing.allocator; + + var entries = [_]SessionEntry{ + .{ + .uuid = "uuid-1", + .timestamp = "2026-01-09T10:30:00.000Z", + .entry_type = "user", + .role = "user", + .content = "Short content", + .tool_name = null, + }, + }; + + const result = try formatSessionMarkdown(allocator, &entries); + defer allocator.free(result); + + // Should NOT have ellipsis for short content + try testing.expect(std.mem.indexOf(u8, result, "Short content") != null); + // Check that we don't have "Short content..." (with ellipsis) + try testing.expect(std.mem.indexOf(u8, result, "Short content...") == null); +} + +// ============================================================================ +// OpenCode parsing tests +// ============================================================================ + +test "formatEpochMs formats epoch milliseconds to ISO timestamp" { + var buf: [32]u8 = undefined; + // 2026-01-09T16:32:56.094Z = 1767976376094 ms + const result = formatEpochMs(1767976376094, &buf); + try testing.expectEqualStrings("2026-01-09T16:32:56.094Z", result); +} + +test "formatEpochMs handles zero" { + var buf: [32]u8 = undefined; + const result = formatEpochMs(0, &buf); + try testing.expectEqualStrings("1970-01-01T00:00:00.000Z", result); +} + +test "formatEpochMs handles midnight" { + var buf: [32]u8 = undefined; + // 2026-01-01T00:00:00.000Z = 1767225600000 ms + const result = formatEpochMs(1767225600000, &buf); + try testing.expectEqualStrings("2026-01-01T00:00:00.000Z", result); +} + +test "formatEpochMs handles end of day" { + var buf: [32]u8 = undefined; + // 2026-01-01T23:59:59.999Z = 1767311999999 ms + const result = formatEpochMs(1767311999999, &buf); + try testing.expectEqualStrings("2026-01-01T23:59:59.999Z", result); +} + +test "parseOpenCodeMessage parses user message" { + const allocator = testing.allocator; + const msg_json = + \\{"id":"msg_test123","sessionID":"ses_abc","role":"user","time":{"created":1767976376094}} + ; + + // Use empty base_dir since we're not reading parts in this test + const entry = try parseOpenCodeMessage(allocator, "/nonexistent", msg_json); + defer { + allocator.free(entry.uuid); + allocator.free(entry.timestamp); + allocator.free(entry.entry_type); + if (entry.role) |r| allocator.free(r); + if (entry.content) |c| allocator.free(c); + if (entry.tool_name) |t| allocator.free(t); + } + + try testing.expectEqualStrings("msg_test123", entry.uuid); + try testing.expectEqualStrings("2026-01-09T16:32:56.094Z", entry.timestamp); + try testing.expectEqualStrings("user", entry.entry_type); + try testing.expectEqualStrings("user", entry.role.?); + try testing.expect(entry.content == null); + try testing.expect(entry.tool_name == null); +} + +test "parseOpenCodeMessage parses assistant message" { + const allocator = testing.allocator; + const msg_json = + \\{"id":"msg_assist456","sessionID":"ses_abc","role":"assistant","time":{"created":1767976380000}} + ; + + const entry = try parseOpenCodeMessage(allocator, "/nonexistent", msg_json); + defer { + allocator.free(entry.uuid); + allocator.free(entry.timestamp); + allocator.free(entry.entry_type); + if (entry.role) |r| allocator.free(r); + if (entry.content) |c| allocator.free(c); + if (entry.tool_name) |t| allocator.free(t); + } + + try testing.expectEqualStrings("msg_assist456", entry.uuid); + try testing.expectEqualStrings("assistant", entry.entry_type); + try testing.expectEqualStrings("assistant", entry.role.?); +} + +test "parseOpenCodeMessage returns error on missing id" { + const allocator = testing.allocator; + const msg_json = + \\{"sessionID":"ses_abc","role":"user","time":{"created":1767976376094}} + ; + + try testing.expectError(error.MissingId, parseOpenCodeMessage(allocator, "/nonexistent", msg_json)); +} + +test "parseOpenCodeMessage returns error on missing role" { + const allocator = testing.allocator; + const msg_json = + \\{"id":"msg_test123","sessionID":"ses_abc","time":{"created":1767976376094}} + ; + + try testing.expectError(error.MissingRole, parseOpenCodeMessage(allocator, "/nonexistent", msg_json)); +} + +test "parseOpenCodeMessage returns error on invalid json" { + const allocator = testing.allocator; + const msg_json = "not valid json"; + + try testing.expectError(error.ParseError, parseOpenCodeMessage(allocator, "/nonexistent", msg_json)); +} + +test "parseOpenCodeMessage handles missing time field" { + const allocator = testing.allocator; + const msg_json = + \\{"id":"msg_notime","sessionID":"ses_abc","role":"user"} + ; + + const entry = try parseOpenCodeMessage(allocator, "/nonexistent", msg_json); + defer { + allocator.free(entry.uuid); + allocator.free(entry.timestamp); + allocator.free(entry.entry_type); + if (entry.role) |r| allocator.free(r); + if (entry.content) |c| allocator.free(c); + if (entry.tool_name) |t| allocator.free(t); + } + + try testing.expectEqualStrings("msg_notime", entry.uuid); + // Should use default timestamp when time is missing + try testing.expectEqualStrings("1970-01-01T00:00:00.000Z", entry.timestamp); +} + +test "parseOpenCodeMessage handles string timestamp fallback" { + const allocator = testing.allocator; + // String timestamps (quoted numbers) are not parsed as numbers by Zig's JSON parser + // and should fall through to default timestamp + const msg_json = + \\{"id":"msg_strtime","sessionID":"ses_abc","role":"assistant","time":{"created":"1767976376094"}} + ; + + const entry = try parseOpenCodeMessage(allocator, "/nonexistent", msg_json); + defer { + allocator.free(entry.uuid); + allocator.free(entry.timestamp); + allocator.free(entry.entry_type); + if (entry.role) |r| allocator.free(r); + if (entry.content) |c| allocator.free(c); + if (entry.tool_name) |t| allocator.free(t); + } + + try testing.expectEqualStrings("msg_strtime", entry.uuid); + // String timestamps fall back to default (not parsed as numbers) + try testing.expectEqualStrings("1970-01-01T00:00:00.000Z", entry.timestamp); +} + +test "parseOpenCodeMessage handles empty time object" { + const allocator = testing.allocator; + const msg_json = + \\{"id":"msg_emptytime","sessionID":"ses_abc","role":"user","time":{}} + ; + + const entry = try parseOpenCodeMessage(allocator, "/nonexistent", msg_json); + defer { + allocator.free(entry.uuid); + allocator.free(entry.timestamp); + allocator.free(entry.entry_type); + if (entry.role) |r| allocator.free(r); + if (entry.content) |c| allocator.free(c); + if (entry.tool_name) |t| allocator.free(t); + } + + try testing.expectEqualStrings("msg_emptytime", entry.uuid); + // Should use default timestamp when created field is missing + try testing.expectEqualStrings("1970-01-01T00:00:00.000Z", entry.timestamp); +} + +test "parseOpenCodeMessage handles extra fields gracefully" { + const allocator = testing.allocator; + const msg_json = + \\{"id":"msg_extra","sessionID":"ses_abc","role":"user","time":{"created":1767976376094},"model":"claude-3-opus","tokens":1234,"unknown_field":"ignored"} + ; + + const entry = try parseOpenCodeMessage(allocator, "/nonexistent", msg_json); + defer { + allocator.free(entry.uuid); + allocator.free(entry.timestamp); + allocator.free(entry.entry_type); + if (entry.role) |r| allocator.free(r); + if (entry.content) |c| allocator.free(c); + if (entry.tool_name) |t| allocator.free(t); + } + + try testing.expectEqualStrings("msg_extra", entry.uuid); + try testing.expectEqualStrings("user", entry.role.?); +} + +// ============================================================================ +// convertJsonlToArray tests +// ============================================================================ + +test "convertJsonlToArray converts single line" { + const allocator = testing.allocator; + const jsonl = + \\{"key":"value"} + ; + + const result = try convertJsonlToArray(allocator, jsonl); + defer allocator.free(result); + + try testing.expectEqualStrings("[{\"key\":\"value\"}]", result); +} + +test "convertJsonlToArray converts multiple lines" { + const allocator = testing.allocator; + const jsonl = + \\{"a":1} + \\{"b":2} + \\{"c":3} + ; + + const result = try convertJsonlToArray(allocator, jsonl); + defer allocator.free(result); + + try testing.expectEqualStrings("[{\"a\":1},{\"b\":2},{\"c\":3}]", result); +} + +test "convertJsonlToArray handles empty input" { + const allocator = testing.allocator; + const jsonl = ""; + + const result = try convertJsonlToArray(allocator, jsonl); + defer allocator.free(result); + + try testing.expectEqualStrings("[]", result); +} + +test "convertJsonlToArray handles blank lines" { + const allocator = testing.allocator; + const jsonl = + \\{"first":1} + \\ + \\{"second":2} + \\ + \\{"third":3} + ; + + const result = try convertJsonlToArray(allocator, jsonl); + defer allocator.free(result); + + try testing.expectEqualStrings("[{\"first\":1},{\"second\":2},{\"third\":3}]", result); +} + +test "convertJsonlToArray handles trailing newline" { + const allocator = testing.allocator; + const jsonl = "{\"key\":\"value\"}\n"; + + const result = try convertJsonlToArray(allocator, jsonl); + defer allocator.free(result); + + try testing.expectEqualStrings("[{\"key\":\"value\"}]", result); +} + +test "convertJsonlToArray preserves json structure" { + const allocator = testing.allocator; + const jsonl = + \\{"nested":{"inner":"value"},"array":[1,2,3]} + ; + + const result = try convertJsonlToArray(allocator, jsonl); + defer allocator.free(result); + + try testing.expectEqualStrings("[{\"nested\":{\"inner\":\"value\"},\"array\":[1,2,3]}]", result); +} diff --git a/src/cmds/log.zig b/src/cmds/log.zig index fad69cd..80efba0 100644 --- a/src/cmds/log.zig +++ b/src/cmds/log.zig @@ -261,7 +261,7 @@ fn printCommit( note = null; } - // Show session transcript if requested (with offset/limit pagination) + // Show session transcript if requested if (opts.show_session) { if (c.git_note_read(¬e, repo, "refs/notes/session", oid) == 0) { defer c.git_note_free(note); @@ -270,26 +270,31 @@ fn printCommit( const session_text = std.mem.sliceTo(msg, 0); const total_len = session_text.len; - // Apply offset and limit - if (opts.session_offset >= total_len) { - try writer.print(" session: (offset {d} beyond end, total {d} bytes)\n", .{ opts.session_offset, total_len }); + // Check if it's new markdown format (starts with
) + if (std.mem.startsWith(u8, session_text, "
")) { + // Markdown format - display directly (already human-readable) + try writer.print(" session:\n{s}\n", .{session_text}); } else { - const start = opts.session_offset; - const remaining = total_len - start; - const display_len = @min(remaining, opts.session_limit); - const end = start + display_len; - - if (start > 0 or end < total_len) { - // Show range info when using offset or truncated - try writer.print(" session [{d}-{d} of {d} bytes]:\n ", .{ start, end, total_len }); + // Legacy JSON format - use byte pagination + if (opts.session_offset >= total_len) { + try writer.print(" session: (offset {d} beyond end, total {d} bytes)\n", .{ opts.session_offset, total_len }); } else { - try writer.print(" session:\n ", .{}); - } - try writer.print("{s}", .{session_text[start..end]}); - if (end < total_len) { - try writer.print("\n ... ({d} more bytes, use --session-offset={d})\n", .{ total_len - end, end }); - } else { - try writer.print("\n", .{}); + const start = opts.session_offset; + const remaining = total_len - start; + const display_len = @min(remaining, opts.session_limit); + const end = start + display_len; + + if (start > 0 or end < total_len) { + try writer.print(" session [{d}-{d} of {d} bytes]:\n ", .{ start, end, total_len }); + } else { + try writer.print(" session:\n ", .{}); + } + try writer.print("{s}", .{session_text[start..end]}); + if (end < total_len) { + try writer.print("\n ... ({d} more bytes, use --session-offset={d})\n", .{ total_len - end, end }); + } else { + try writer.print("\n", .{}); + } } } } diff --git a/test/package-lock.json b/test/package-lock.json new file mode 100644 index 0000000..5cf400e --- /dev/null +++ b/test/package-lock.json @@ -0,0 +1,1520 @@ +{ + "name": "zagi-bench", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zagi-bench", + "devDependencies": { + "@types/bun": "latest", + "vitest": "^4.0.16" + }, + "peerDependencies": { + "typescript": "^5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/bun": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.5.tgz", + "integrity": "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.5" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.16", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.16", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bun-types": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.5.tgz", + "integrity": "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/test/src/agent.test.ts b/test/src/agent.test.ts index 860b415..407ca0c 100644 --- a/test/src/agent.test.ts +++ b/test/src/agent.test.ts @@ -410,7 +410,7 @@ describe("zagi agent executor paths", () => { expect(result).toContain("Executor: claude"); expect(result).toContain("Would execute:"); - expect(result).toContain("claude -p"); + expect(result).toContain("claude --dangerously-skip-permissions -p"); }); test("ZAGI_AGENT=claude uses claude executor", () => { @@ -422,7 +422,7 @@ describe("zagi agent executor paths", () => { }); expect(result).toContain("Executor: claude"); - expect(result).toContain("claude -p"); + expect(result).toContain("claude --dangerously-skip-permissions -p"); }); test("ZAGI_AGENT=opencode uses opencode executor", () => { @@ -495,10 +495,11 @@ describe("zagi agent executor paths", () => { cwd: REPO_DIR }); - // In plan mode, claude runs interactively (no -p flag) + // In plan mode, claude runs interactively (no --dangerously-skip-permissions or -p) expect(result).toContain("Would execute:"); expect(result).toContain("claude"); - expect(result).not.toMatch(/claude -p/); + expect(result).not.toContain("--dangerously-skip-permissions"); + expect(result).not.toMatch(/claude.*-p/); }); test("plan subcommand uses opencode in interactive mode (no run subcommand)", () => {