From 74e0bf8f3f2751448cc15332beb285d5064efca6 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sun, 12 Apr 2026 14:27:52 +0000 Subject: [PATCH 1/3] feat(cli): move headless -p under chat subcommand with --resume and --json support (#611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify the headless prompt and interactive resume code paths so scripted multi-turn conversations work. `netclaw chat -p --resume "prompt"` creates-or-resumes a named session in headless mode, enabling multi-turn evals, KV cache benchmarking, and compaction regression testing. - Remove top-level `netclaw -p` — headless is now `netclaw chat -p` - Add `--resume` support to headless mode via DaemonClient.ResumeSessionAsync with configurable ChannelType (was hardcoded to TUI) - Add `--json` flag for structured output (sessionId, response, toolCalls, usage) - Add multi-turn + JSON smoke tests to scripts/smoke/check.sh - Update evals, docs, and README references from `netclaw -p` to `netclaw chat -p` --- README.md | 2 +- docs/runbooks/tool-approval-gates.md | 4 +- evals/README.md | 8 +- evals/run-evals.sh | 2 +- scripts/smoke/check.sh | 31 +++- .../Cli/CliArgsParserTests.cs | 32 +--- .../Cli/DaemonClientSessionTests.cs | 2 +- src/Netclaw.Cli/CliArgsParser.cs | 12 +- src/Netclaw.Cli/Daemon/DaemonClient.cs | 5 +- src/Netclaw.Cli/HeadlessChannel.cs | 172 +++++++++++++++--- src/Netclaw.Cli/HeadlessOptions.cs | 11 ++ src/Netclaw.Cli/Program.cs | 77 ++++++-- src/Netclaw.Cli/Tui/ChatViewModel.cs | 2 +- 13 files changed, 263 insertions(+), 97 deletions(-) create mode 100644 src/Netclaw.Cli/HeadlessOptions.cs diff --git a/README.md b/README.md index d09490906..9f62f16de 100644 --- a/README.md +++ b/README.md @@ -406,7 +406,7 @@ When no config files exist, the daemon defaults to: ``` netclaw chat Interactive TUI chat session -netclaw -p "prompt" Headless single-prompt mode +netclaw chat -p "prompt" Headless single-prompt mode netclaw daemon start Start the daemon as a background process netclaw daemon stop Gracefully stop the daemon (SIGTERM) netclaw daemon status Show daemon PID and uptime diff --git a/docs/runbooks/tool-approval-gates.md b/docs/runbooks/tool-approval-gates.md index 85e2c8e2c..9abea1028 100644 --- a/docs/runbooks/tool-approval-gates.md +++ b/docs/runbooks/tool-approval-gates.md @@ -67,7 +67,7 @@ Existing configs without `ApprovalPolicy` are unaffected — all tools remain in ### Headless mode -Headless mode (`netclaw -p "prompt"`) cannot ask for approval — there is no +Headless mode (`netclaw chat -p "prompt"`) cannot ask for approval — there is no interactive user. Approval-gated tools are **automatically denied** in headless mode. If you need unrestricted shell in headless scripts, explicitly set `shell_execute` to `Auto`: @@ -186,7 +186,7 @@ Custom patterns are added to the defaults — they don't replace them. | Slack | Yes | Text prompt with ABC options (Block Kit buttons planned) | | TUI (`netclaw chat`) | Yes | Inline prompt | | SignalR (web client) | Yes | Inline prompt | -| Headless (`netclaw -p`) | No — auto-deny | N/A | +| Headless (`netclaw chat -p`) | No — auto-deny | N/A | | Reminders | No — auto-deny | N/A | | Webhooks | No — auto-deny | N/A | diff --git a/evals/README.md b/evals/README.md index b54c820e2..4852f8476 100644 --- a/evals/README.md +++ b/evals/README.md @@ -55,7 +55,7 @@ reduces `--network host` to bridge mode). ## What It Tests -The suite runs prompts via `netclaw -p` against the eval container and +The suite runs prompts via `netclaw chat -p` against the eval container and verifies both **stdout output** (tool calls, text content) and **daemon log patterns** (skill loading, memory recall, checkpoint formation). @@ -75,7 +75,7 @@ phrasing — not just one magic prompt. ### Assertion Types -- **stdout assertions** — check `netclaw -p` output for tool calls +- **stdout assertions** — check `netclaw chat -p` output for tool calls (`[tool:call]`), text content, or absence of hallucinated content. - **daemon log assertions** — check the daemon's file log (tailed from `$EVAL_HOME/logs/daemon-$(date +%F).log`) for structured patterns like @@ -214,8 +214,8 @@ skips persistence. depends on inheriting the host's DNS resolver. Docker Desktop (macOS/Windows) degrades `--network host` to bridge mode; set `NETCLAW_EVAL_PROVIDER_ENDPOINT` to a reachable IP/hostname instead. -- **Single-turn only**: `netclaw -p` is one prompt per session. Multi-turn - conversation evals are deferred. +- **Multi-turn support**: `netclaw chat -p --resume ` enables multi-turn + scripted conversations against a named session. - **Identity is borrowed from host**: the container does not self-bootstrap identity. CI will need a committed fixture under `evals/fixtures/identity/` — tracked as a follow-up. diff --git a/evals/run-evals.sh b/evals/run-evals.sh index f80c3745c..4a458fc9d 100755 --- a/evals/run-evals.sh +++ b/evals/run-evals.sh @@ -381,7 +381,7 @@ run_prompt() { # daemon and keep CLI-side path resolution inside the eval sandbox. NETCLAW_DAEMON_ENDPOINT="http://127.0.0.1:$EVAL_PORT" \ NETCLAW_HOME="$EVAL_HOME" \ - timeout "$PROMPT_TIMEOUT" "$NETCLAW_BIN" -p "$prompt" \ + timeout "$PROMPT_TIMEOUT" "$NETCLAW_BIN" chat -p "$prompt" \ > "$STDOUT_FILE" 2>&1 || true # Brief pause for daemon log flush diff --git a/scripts/smoke/check.sh b/scripts/smoke/check.sh index 9684f28d8..bb8b1ea3b 100755 --- a/scripts/smoke/check.sh +++ b/scripts/smoke/check.sh @@ -249,7 +249,7 @@ echo "Waiting for daemon health endpoint..." wait_for_health echo "Sending a headless prompt to create a session..." -run_sandbox_timed "$STEP_TIMEOUT_SECONDS" netclaw -p "Say hello in one word" || true +run_sandbox_timed "$STEP_TIMEOUT_SECONDS" netclaw chat -p "Say hello in one word" || true echo "Checking session catalog via REST API..." sessions_output="$(run_sandbox_timed "$STEP_TIMEOUT_SECONDS" curl -fsS http://127.0.0.1:5199/api/sessions)" @@ -274,6 +274,35 @@ if [[ "$resume_help" != *"--resume"* ]]; then echo "Expected chat help to include --resume flag." exit 1 fi +if [[ "$resume_help" != *"-p"* ]]; then + echo "Expected chat help to include -p flag." + exit 1 +fi + +# ── Multi-turn headless resume smoke test ── +# Validates that chat -p --resume creates, resumes, and maintains +# conversation state through the daemon's persistence layer. + +MULTI_TURN_SESSION="smoke/multi-turn-$$" + +echo "Testing multi-turn: Turn 1 (create named session)..." +run_sandbox_timed "$STEP_TIMEOUT_SECONDS" netclaw chat -p --resume "$MULTI_TURN_SESSION" "hello" || true + +echo "Testing multi-turn: Turn 2 (resume and verify continuity)..." +turn2_output="$(run_sandbox_timed "$STEP_TIMEOUT_SECONDS" netclaw chat -p --resume "$MULTI_TURN_SESSION" "what was my first message?" || true)" +echo "$turn2_output" +if ! echo "$turn2_output" | grep -qi "hello"; then + echo "[WARN] Multi-turn continuity: response did not reference 'hello'." + echo "This may be a model quality issue, not a CLI bug. Continuing..." +fi + +echo "Testing headless --json output..." +json_output="$(run_sandbox_timed "$STEP_TIMEOUT_SECONDS" netclaw chat -p --json "Say hello in one word" || true)" +echo "$json_output" +if [[ "$json_output" != *"sessionId"* ]]; then + echo "Expected --json output to include sessionId field." + exit 1 +fi # ── Stats smoke tests ── # Verify the stats command returns data from the running daemon. diff --git a/src/Netclaw.Cli.Tests/Cli/CliArgsParserTests.cs b/src/Netclaw.Cli.Tests/Cli/CliArgsParserTests.cs index e2da39ff1..995ba53db 100644 --- a/src/Netclaw.Cli.Tests/Cli/CliArgsParserTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/CliArgsParserTests.cs @@ -60,6 +60,8 @@ public void Parse_known_commands_returns_Known_with_mode(string command) [InlineData("bar")] [InlineData("unknown-command")] [InlineData("frobble")] + [InlineData("-p")] + [InlineData("--prompt")] public void Parse_unknown_commands_returns_Unknown_with_mode(string command) { var result = CliArgsParser.Parse([command]); @@ -67,36 +69,6 @@ public void Parse_unknown_commands_returns_Unknown_with_mode(string command) Assert.Equal(command, result.Mode); } - [Fact] - public void Parse_short_prompt_flag_with_arg_returns_Headless() - { - var result = CliArgsParser.Parse(["-p", "hello world"]); - Assert.Equal(CliParseKind.Headless, result.Kind); - Assert.Equal("hello world", result.HeadlessPrompt); - } - - [Fact] - public void Parse_long_prompt_flag_with_arg_returns_Headless() - { - var result = CliArgsParser.Parse(["--prompt", "some query"]); - Assert.Equal(CliParseKind.Headless, result.Kind); - Assert.Equal("some query", result.HeadlessPrompt); - } - - [Fact] - public void Parse_prompt_flag_without_arg_returns_MissingPromptArg() - { - var result = CliArgsParser.Parse(["-p"]); - Assert.Equal(CliParseKind.MissingPromptArg, result.Kind); - } - - [Fact] - public void Parse_long_prompt_flag_without_arg_returns_MissingPromptArg() - { - var result = CliArgsParser.Parse(["--prompt"]); - Assert.Equal(CliParseKind.MissingPromptArg, result.Kind); - } - /// /// Guard test: asserts that KnownCommands contains exactly the expected set. /// If a new command is added to CliArgsParser.KnownCommands, this test fails, diff --git a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs index f09614e70..04e358458 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs @@ -37,7 +37,7 @@ public async Task ResumeSessionAsync_reattaches_to_existing_session_via_EnsureSe outputReceived.TrySetResult(); }); - var resumedSessionId = await client2.ResumeSessionAsync(originalSessionId, TestContext.Current.CancellationToken); + var resumedSessionId = await client2.ResumeSessionAsync(originalSessionId, Netclaw.Actors.Channels.ChannelType.Tui, TestContext.Current.CancellationToken); // EnsureSession should return the same session ID, not create a new one Assert.Equal(originalSessionId, resumedSessionId); diff --git a/src/Netclaw.Cli/CliArgsParser.cs b/src/Netclaw.Cli/CliArgsParser.cs index 32c56f709..703326978 100644 --- a/src/Netclaw.Cli/CliArgsParser.cs +++ b/src/Netclaw.Cli/CliArgsParser.cs @@ -5,18 +5,15 @@ public enum CliParseKind NoArgs, Help, Version, - Headless, Known, Unknown, - MissingPromptArg, } -public record CliParseResult(CliParseKind Kind, string? Mode = null, string? HeadlessPrompt = null) +public record CliParseResult(CliParseKind Kind, string? Mode = null) { public static readonly CliParseResult NoArgs = new(CliParseKind.NoArgs); public static readonly CliParseResult Help = new(CliParseKind.Help); public static readonly CliParseResult Version = new(CliParseKind.Version); - public static readonly CliParseResult MissingPromptArg = new(CliParseKind.MissingPromptArg); } /// Classifies top-level command-line arguments for the netclaw CLI. @@ -50,13 +47,6 @@ public static CliParseResult Parse(string[] args) if (first is "version" or "--version" or "-V") return CliParseResult.Version; - if (first is "-p" or "--prompt") - { - if (args.Length < 2) - return CliParseResult.MissingPromptArg; - return new CliParseResult(CliParseKind.Headless, "headless", HeadlessPrompt: args[1]); - } - if (KnownCommands.Contains(first)) return new CliParseResult(CliParseKind.Known, first); diff --git a/src/Netclaw.Cli/Daemon/DaemonClient.cs b/src/Netclaw.Cli/Daemon/DaemonClient.cs index de028c0fb..307c8c830 100644 --- a/src/Netclaw.Cli/Daemon/DaemonClient.cs +++ b/src/Netclaw.Cli/Daemon/DaemonClient.cs @@ -244,11 +244,12 @@ public async Task EnsureSessionAsync( /// public async Task ResumeSessionAsync( string sessionId, + Actors.Channels.ChannelType channelType, CancellationToken cancellationToken = default) { - _channelType = TuiChannelType; + _channelType = channelType; _sessionId = sessionId; - return await EnsureSessionInternalAsync(TuiChannelType, cancellationToken); + return await EnsureSessionInternalAsync(channelType, cancellationToken); } public async Task SendAsync(ChannelInput input, CancellationToken cancellationToken = default) diff --git a/src/Netclaw.Cli/HeadlessChannel.cs b/src/Netclaw.Cli/HeadlessChannel.cs index 6bbd50f5f..613428792 100644 --- a/src/Netclaw.Cli/HeadlessChannel.cs +++ b/src/Netclaw.Cli/HeadlessChannel.cs @@ -1,3 +1,5 @@ +using System.Text; +using System.Text.Json; using Microsoft.Extensions.AI; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -10,7 +12,7 @@ namespace Netclaw.Cli; /// -/// Headless channel for single-prompt mode (-p / --prompt). +/// Headless channel for single-prompt mode (chat -p). /// Sends one message to the LLM session, streams all output to stdout, /// and exits on . /// @@ -21,12 +23,20 @@ public sealed class HeadlessChannel : IChannel private readonly IHostApplicationLifetime _lifetime; private readonly TimeProvider _timeProvider; private readonly string _prompt; + private readonly string? _resumeSessionId; + private readonly bool _jsonOutput; private readonly ILogger _logger; private bool _isConnected; private bool _receivedTextDeltaInCurrentTurn; private bool _receivedThinkingDeltaInCurrentTurn; + // JSON output accumulation + private readonly StringBuilder _responseBuffer = new(); + private readonly List _toolCalls = []; + private JsonUsage? _usage; + private string? _resolvedSessionId; + public Actors.Channels.ChannelType ChannelType => Actors.Channels.ChannelType.Headless; public string DisplayName => "Headless Prompt"; @@ -44,14 +54,16 @@ public HeadlessChannel( NetclawPaths paths, IHostApplicationLifetime lifetime, TimeProvider timeProvider, - string prompt, + HeadlessOptions options, ILogger logger) { _daemonClient = daemonClient; _paths = paths; _lifetime = lifetime; _timeProvider = timeProvider; - _prompt = prompt; + _prompt = options.Prompt; + _resumeSessionId = options.ResumeSessionId; + _jsonOutput = options.JsonOutput; _logger = logger; } @@ -71,22 +83,18 @@ private async Task RunHeadlessAsync(CancellationToken stopping) { try { - var sessionId = new SessionId($"headless/{Guid.NewGuid():N}"); - - // Set up session log file _paths.EnsureDirectoriesExist(); - var logFileName = $"{sessionId.Value.Replace("/", "-", StringComparison.Ordinal)}.log"; - var logPath = Path.Combine(_paths.LogsDirectory, logFileName); - await using var logWriter = new StreamWriter(logPath, append: false) { AutoFlush = true }; - - logWriter.WriteLine($"[{_timeProvider.GetUtcNow():o}] Headless session started: {sessionId}"); - logWriter.WriteLine($"[{_timeProvider.GetUtcNow():o}] PROMPT: {_prompt}"); var turnCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + // Log writer is deferred until we know the session ID (after create/resume). + // The closure captures this mutable ref so early events are still handled. + StreamWriter? logWriter = null; + using var connectionSubscription = _daemonClient.ConnectionEvents.Subscribe(evt => { - Log(logWriter, $"CONNECTION: {evt.Message}"); + if (logWriter is not null) + Log(logWriter, $"CONNECTION: {evt.Message}"); }); using var subscription = _daemonClient.SessionOutput.Subscribe(output => @@ -99,7 +107,27 @@ private async Task RunHeadlessAsync(CancellationToken stopping) await _daemonClient.ConnectAsync(stopping); _isConnected = true; - sessionId = new SessionId(await _daemonClient.CreateSessionAsync(ChannelType, stopping)); + // Create or resume session + string sessionIdValue; + if (_resumeSessionId is not null) + { + sessionIdValue = await _daemonClient.ResumeSessionAsync( + _resumeSessionId, ChannelType, stopping); + } + else + { + sessionIdValue = await _daemonClient.CreateSessionAsync(ChannelType, stopping); + } + + _resolvedSessionId = sessionIdValue; + var sessionId = new SessionId(sessionIdValue); + + var logFileName = $"{sessionId.Value.Replace("/", "-", StringComparison.Ordinal)}.log"; + var logPath = Path.Combine(_paths.LogsDirectory, logFileName); + logWriter = new StreamWriter(logPath, append: true) { AutoFlush = true }; + + logWriter.WriteLine($"[{_timeProvider.GetUtcNow():o}] Headless session started: {sessionId}"); + logWriter.WriteLine($"[{_timeProvider.GetUtcNow():o}] PROMPT: {_prompt}"); await _daemonClient.SendAsync(new Netclaw.Actors.Channels.ChannelInput { @@ -111,6 +139,7 @@ await _daemonClient.SendAsync(new Netclaw.Actors.Channels.ChannelInput _logger.LogInformation("Headless session started: {SessionId} (log: {LogPath})", sessionId, logPath); await turnCompleted.Task.WaitAsync(stopping); + await logWriter.DisposeAsync(); _lifetime.StopApplication(); } catch (OperationCanceledException ex) @@ -128,7 +157,7 @@ await _daemonClient.SendAsync(new Netclaw.Actors.Channels.ChannelInput } } - private void HandleOutput(SessionOutput output, StreamWriter log) + private void HandleOutput(SessionOutput output, StreamWriter? log) { switch (output) { @@ -143,13 +172,19 @@ private void HandleOutput(SessionOutput output, StreamWriter log) break; } - Console.WriteLine(msg.Text); + if (_jsonOutput) + _responseBuffer.Append(msg.Text); + else + Console.WriteLine(msg.Text); Log(log, $"ASSISTANT: {msg.Text}"); break; case TextDeltaOutput msg: _receivedTextDeltaInCurrentTurn = true; - Console.Write(msg.Delta); + if (_jsonOutput) + _responseBuffer.Append(msg.Delta); + else + Console.Write(msg.Delta); Log(log, $"ASSISTANT_DELTA: {msg.Delta}"); break; @@ -170,17 +205,44 @@ private void HandleOutput(SessionOutput output, StreamWriter log) break; case ToolCallOutput msg: - Console.WriteLine($"[tool:call] {msg.ToolName}({msg.ArgumentsJson ?? ""})"); + if (_jsonOutput) + { + _toolCalls.Add(new JsonToolCall + { + CallId = msg.CallId, + ToolName = msg.ToolName, + ArgumentsJson = msg.ArgumentsJson + }); + } + else + { + Console.WriteLine($"[tool:call] {msg.ToolName}({msg.ArgumentsJson ?? ""})"); + } Log(log, $"TOOL_CALL: {msg.ToolName} call_id={msg.CallId} args={msg.ArgumentsJson ?? "{}"}"); break; case ToolResultOutput msg: - Console.WriteLine($"[tool:result] {msg.ToolName} \u2192 {msg.Result}"); + if (!_jsonOutput) + Console.WriteLine($"[tool:result] {msg.ToolName} \u2192 {msg.Result}"); Log(log, $"TOOL_RESULT: {msg.ToolName} call_id={msg.CallId} result={msg.Result}"); break; case UsageOutput msg: - Console.WriteLine($"[usage] in={msg.InputTokens} out={msg.OutputTokens} total={msg.TotalTokens}"); + if (_jsonOutput) + { + _usage = new JsonUsage + { + InputTokens = msg.InputTokens, + OutputTokens = msg.OutputTokens, + TotalTokens = msg.TotalTokens, + CachedInputTokens = msg.CachedInputTokens, + ReasoningTokens = msg.ReasoningTokens + }; + } + else + { + Console.WriteLine($"[usage] in={msg.InputTokens} out={msg.OutputTokens} total={msg.TotalTokens}"); + } Log(log, $"USAGE: in={msg.InputTokens} out={msg.OutputTokens} total={msg.TotalTokens} cached={msg.CachedInputTokens} reasoning={msg.ReasoningTokens} context_window={msg.ContextWindowTokens}"); break; @@ -192,7 +254,14 @@ private void HandleOutput(SessionOutput output, StreamWriter log) break; case TurnCompleted msg: - Console.WriteLine(); + if (_jsonOutput) + { + WriteJsonEnvelope(); + } + else + { + Console.WriteLine(); + } Log(log, $"TURN_COMPLETED: turn={msg.TurnNumber}"); Log(log, "SESSION_ENDED"); _receivedTextDeltaInCurrentTurn = false; @@ -200,34 +269,57 @@ private void HandleOutput(SessionOutput output, StreamWriter log) break; case FileOutput msg: - Console.WriteLine($"[file] {msg.FileName} \u2192 {msg.FilePath}"); + if (!_jsonOutput) + Console.WriteLine($"[file] {msg.FileName} \u2192 {msg.FilePath}"); Log(log, $"FILE: name={msg.FileName} path={msg.FilePath} mime={msg.MimeType}"); break; case SubAgentOutput msg: if (msg.Phase == Netclaw.Actors.SubAgents.SubAgentPhase.Started) { - Console.WriteLine($"[subagent:start] {msg.AgentName} ({msg.ToolCount} tools)"); + if (!_jsonOutput) + Console.WriteLine($"[subagent:start] {msg.AgentName} ({msg.ToolCount} tools)"); Log(log, $"SUBAGENT_START: name={msg.AgentName} tools={msg.ToolCount}"); } else { var status = msg.Success ? "success" : "failed"; - Console.WriteLine($"[subagent:done] {msg.AgentName} ({status}, {msg.Duration.TotalSeconds:F1}s)"); + if (!_jsonOutput) + Console.WriteLine($"[subagent:done] {msg.AgentName} ({status}, {msg.Duration.TotalSeconds:F1}s)"); Log(log, $"SUBAGENT_DONE: name={msg.AgentName} success={msg.Success} duration={msg.Duration.TotalSeconds:F1}s"); } break; case CompactionOutput msg: - Console.WriteLine($"[compaction] {msg.MessagesBefore} \u2192 {msg.MessagesAfter} messages (keep={msg.KeepCountUsed}, context={msg.PreCompactionInputTokens}/{msg.ContextWindowTokens} tokens)"); + if (!_jsonOutput) + Console.WriteLine($"[compaction] {msg.MessagesBefore} \u2192 {msg.MessagesAfter} messages (keep={msg.KeepCountUsed}, context={msg.PreCompactionInputTokens}/{msg.ContextWindowTokens} tokens)"); Log(log, $"COMPACTION: before={msg.MessagesBefore} after={msg.MessagesAfter} tool_results_cleared={msg.ToolResultsCleared} summarized={msg.Summarized} context_window={msg.ContextWindowTokens} input_tokens={msg.PreCompactionInputTokens} keep_count={msg.KeepCountUsed}"); break; } } - private void Log(StreamWriter log, string message) + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }; + + private void WriteJsonEnvelope() { - log.WriteLine($"[{_timeProvider.GetUtcNow():o}] {message}"); + var envelope = new JsonEnvelope + { + SessionId = _resolvedSessionId!, + Response = _responseBuffer.ToString(), + ToolCalls = _toolCalls.Count > 0 ? _toolCalls : null, + Usage = _usage + }; + + Console.WriteLine(JsonSerializer.Serialize(envelope, s_jsonOptions)); + } + + private void Log(StreamWriter? log, string message) + { + log?.WriteLine($"[{_timeProvider.GetUtcNow():o}] {message}"); } private void WriteFailureLog(string kind, Exception ex) @@ -244,4 +336,30 @@ private void WriteFailureLog(string kind, Exception ex) Console.Error.WriteLine($"[headless:error] Failed to write failure log: {logEx.Message}"); } } + + // ── JSON output types ── + + private sealed class JsonEnvelope + { + public required string SessionId { get; init; } + public required string Response { get; init; } + public List? ToolCalls { get; init; } + public JsonUsage? Usage { get; init; } + } + + private sealed class JsonToolCall + { + public required string CallId { get; init; } + public required string ToolName { get; init; } + public string? ArgumentsJson { get; init; } + } + + private sealed class JsonUsage + { + public long? InputTokens { get; init; } + public long? OutputTokens { get; init; } + public long? TotalTokens { get; init; } + public long? CachedInputTokens { get; init; } + public long? ReasoningTokens { get; init; } + } } diff --git a/src/Netclaw.Cli/HeadlessOptions.cs b/src/Netclaw.Cli/HeadlessOptions.cs new file mode 100644 index 000000000..3fac3c6f6 --- /dev/null +++ b/src/Netclaw.Cli/HeadlessOptions.cs @@ -0,0 +1,11 @@ +namespace Netclaw.Cli; + +/// +/// Configuration for headless (chat -p) mode. +/// +public sealed record HeadlessOptions +{ + public required string Prompt { get; init; } + public string? ResumeSessionId { get; init; } + public bool JsonOutput { get; init; } +} diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index b9ad6fed4..ec0377cb2 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -55,20 +55,11 @@ static async Task RunAsync(string[] args) case CliParseKind.Version: Console.WriteLine($"netclaw {BuildInfo.Version} (commit {BuildInfo.CommitHash}, built {BuildInfo.BuildTimestamp})"); return; - case CliParseKind.MissingPromptArg: - Console.Error.WriteLine("netclaw: -p/--prompt requires an argument."); - Console.Error.WriteLine("Usage: netclaw -p \"your prompt here\""); - Environment.ExitCode = 1; - return; case CliParseKind.Unknown: Console.Error.WriteLine($"netclaw: '{parseResult.Mode}' is not a netclaw command. See 'netclaw --help'."); WriteGeneralHelp(); Environment.ExitCode = 2; return; - case CliParseKind.Headless: - headlessPrompt = parseResult.HeadlessPrompt; - mode = "headless"; - break; default: // CliParseKind.Known mode = parseResult.Mode!; break; @@ -833,10 +824,14 @@ static async Task RunAsync(string[] args) } } - // ── Parse --resume flag for chat mode ── + // ── Parse chat flags: --resume, -p/--prompt, --json ── string? resumeSessionId = null; + bool chatJsonOutput = false; if (mode is "chat") { + bool chatHeadless = false; + string? chatPrompt = null; + for (var i = 1; i < args.Length; i++) { if (args[i] is "--resume" or "-r") @@ -859,11 +854,44 @@ static async Task RunAsync(string[] args) continue; } + if (args[i] is "-p" or "--prompt") + { + chatHeadless = true; + continue; + } + + if (args[i] is "--json") + { + chatJsonOutput = true; + continue; + } + if (IsHelpToken(args[i])) { WriteChatHelp(); return; } + + // Positional argument: prompt text (when -p is specified) + if (chatPrompt is null) + { + chatPrompt = args[i]; + } + } + + if (chatHeadless) + { + if (chatPrompt is null) + { + Console.Error.WriteLine("netclaw: chat -p requires a prompt argument."); + Console.Error.WriteLine("Usage: netclaw chat -p \"your prompt here\""); + WriteChatHelp(); + Environment.ExitCode = 1; + return; + } + + headlessPrompt = chatPrompt; + mode = "headless"; } } @@ -901,8 +929,14 @@ static async Task RunAsync(string[] args) break; case "headless": + var headlessOpts = new HeadlessOptions + { + Prompt = headlessPrompt!, + ResumeSessionId = resumeSessionId, + JsonOutput = chatJsonOutput, + }; webBuilder.Services.AddSingleton(sp => - ActivatorUtilities.CreateInstance(sp, headlessPrompt!)); + ActivatorUtilities.CreateInstance(sp, headlessOpts)); webBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); webBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); break; @@ -938,9 +972,9 @@ static void WriteGeneralHelp() Console.WriteLine("Commands:"); Console.WriteLine(" chat Interactive TUI chat"); Console.WriteLine(" chat --resume Resume an existing session by ID"); + Console.WriteLine(" chat -p Headless single-prompt mode (supports --resume, --json)"); Console.WriteLine(" sessions Browse and resume recent sessions (TUI)"); Console.WriteLine(" sessions --once List sessions and exit (no TUI, plain text or JSON)"); - Console.WriteLine(" -p, --prompt Headless single-prompt mode"); Console.WriteLine(" doctor Configuration diagnostics (offline)"); Console.WriteLine(" status Runtime status from daemon health JSON endpoint"); Console.WriteLine(" stats Usage activity statistics from daemon"); @@ -1024,13 +1058,24 @@ static void WriteDoctorHelp() static void WriteChatHelp() { - Console.WriteLine("Usage: netclaw chat [options]"); + Console.WriteLine("Usage: netclaw chat [options] [prompt]"); Console.WriteLine(); - Console.WriteLine("Start an interactive TUI chat session with the daemon."); + Console.WriteLine("Start an interactive TUI chat session, or send a headless prompt."); Console.WriteLine(); Console.WriteLine("Options:"); - Console.WriteLine(" --resume, -r Resume an existing session by its catalog ID"); - Console.WriteLine(" Use `netclaw sessions` to browse available sessions"); + Console.WriteLine(" --resume, -r Resume (or create) a session by ID"); + Console.WriteLine(" -p, --prompt Send a single headless prompt (non-interactive)"); + Console.WriteLine(" --json Output structured JSON (headless mode only)"); + Console.WriteLine(" Includes sessionId, response, toolCalls, and usage"); + Console.WriteLine(); + Console.WriteLine("Examples:"); + Console.WriteLine(" netclaw chat Interactive TUI"); + Console.WriteLine(" netclaw chat --resume abc123 Resume session in TUI"); + Console.WriteLine(" netclaw chat -p \"hello\" Headless single prompt"); + Console.WriteLine(" netclaw chat -p --resume my-session \"hello\" Named session, headless"); + Console.WriteLine(" netclaw chat -p --resume my-session --json \"hello\" JSON output, named session"); + Console.WriteLine(); + Console.WriteLine("Use `netclaw sessions` to browse available sessions."); } static void WriteStatusHelp() diff --git a/src/Netclaw.Cli/Tui/ChatViewModel.cs b/src/Netclaw.Cli/Tui/ChatViewModel.cs index 054538e6d..356a6e64e 100644 --- a/src/Netclaw.Cli/Tui/ChatViewModel.cs +++ b/src/Netclaw.Cli/Tui/ChatViewModel.cs @@ -313,7 +313,7 @@ private async Task EnsureSessionAndFlushAsync() var resumeId = _resumeSessionId; _resumeSessionId = null; var sessionId = resumeId is not null - ? await _daemonClient.ResumeSessionAsync(resumeId) + ? await _daemonClient.ResumeSessionAsync(resumeId, DaemonClient.TuiChannelType) : await _daemonClient.EnsureSessionAsync(DaemonClient.TuiChannelType); SessionIdDisplay.Value = sessionId; _sessionReady = true; From 5e16834934e5641928db106e7e256942f920ae2a Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sun, 12 Apr 2026 14:33:54 +0000 Subject: [PATCH 2/3] fix(cli): thread-safe logWriter + proper disposal in HeadlessChannel The deferred logWriter captured in R3 subscription closures lacked a memory barrier between the assigning thread and the callback thread. Use Volatile.Read/Write for cross-thread visibility. Move logWriter declaration outside try and add a finally block so the writer is disposed even if an exception occurs after creation. --- src/Netclaw.Cli/HeadlessChannel.cs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/Netclaw.Cli/HeadlessChannel.cs b/src/Netclaw.Cli/HeadlessChannel.cs index 613428792..03a7279b1 100644 --- a/src/Netclaw.Cli/HeadlessChannel.cs +++ b/src/Netclaw.Cli/HeadlessChannel.cs @@ -81,25 +81,26 @@ public async Task StopAsync(CancellationToken cancellationToken) private async Task RunHeadlessAsync(CancellationToken stopping) { + // Log writer is deferred until we know the session ID (after create/resume). + // Volatile ensures the subscription callback sees the assigned value across threads. + StreamWriter? logWriter = null; + try { _paths.EnsureDirectoriesExist(); var turnCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - // Log writer is deferred until we know the session ID (after create/resume). - // The closure captures this mutable ref so early events are still handled. - StreamWriter? logWriter = null; - using var connectionSubscription = _daemonClient.ConnectionEvents.Subscribe(evt => { - if (logWriter is not null) - Log(logWriter, $"CONNECTION: {evt.Message}"); + var lw = Volatile.Read(ref logWriter); + if (lw is not null) + Log(lw, $"CONNECTION: {evt.Message}"); }); using var subscription = _daemonClient.SessionOutput.Subscribe(output => { - HandleOutput(output, logWriter); + HandleOutput(output, Volatile.Read(ref logWriter)); if (output is TurnCompleted) turnCompleted.TrySetResult(); }); @@ -124,9 +125,9 @@ private async Task RunHeadlessAsync(CancellationToken stopping) var logFileName = $"{sessionId.Value.Replace("/", "-", StringComparison.Ordinal)}.log"; var logPath = Path.Combine(_paths.LogsDirectory, logFileName); - logWriter = new StreamWriter(logPath, append: true) { AutoFlush = true }; + Volatile.Write(ref logWriter, new StreamWriter(logPath, append: true) { AutoFlush = true }); - logWriter.WriteLine($"[{_timeProvider.GetUtcNow():o}] Headless session started: {sessionId}"); + logWriter!.WriteLine($"[{_timeProvider.GetUtcNow():o}] Headless session started: {sessionId}"); logWriter.WriteLine($"[{_timeProvider.GetUtcNow():o}] PROMPT: {_prompt}"); await _daemonClient.SendAsync(new Netclaw.Actors.Channels.ChannelInput @@ -139,7 +140,6 @@ await _daemonClient.SendAsync(new Netclaw.Actors.Channels.ChannelInput _logger.LogInformation("Headless session started: {SessionId} (log: {LogPath})", sessionId, logPath); await turnCompleted.Task.WaitAsync(stopping); - await logWriter.DisposeAsync(); _lifetime.StopApplication(); } catch (OperationCanceledException ex) @@ -155,6 +155,11 @@ await _daemonClient.SendAsync(new Netclaw.Actors.Channels.ChannelInput Environment.ExitCode = 1; _lifetime.StopApplication(); } + finally + { + if (logWriter is not null) + await logWriter.DisposeAsync(); + } } private void HandleOutput(SessionOutput output, StreamWriter? log) From de7ddd25359c58f0f4d481a50d3de9c17fcb5551 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sun, 12 Apr 2026 14:53:48 +0000 Subject: [PATCH 3/3] feat(evals): build from source by default + multi-turn verification test The eval harness now builds the Docker image and CLI binary from the current source tree before running evals, ensuring tests always reflect the branch under development rather than a stale published image. - Add build_local_image() step that calls scripts/docker/build-image.sh - Default NETCLAW_IMAGE to :dev (locally built) and NETCLAW_BIN to ./publish/cli/netclaw - Add NETCLAW_EVAL_NO_BUILD=1 to skip build for repeated runs - Add evals/quick-multi-turn-test.sh: standalone 2-turn resume test that validates context carryover and --json output in an isolated container --- evals/quick-multi-turn-test.sh | 192 +++++++++++++++++++++++++++++++++ evals/run-evals.sh | 51 +++++++-- 2 files changed, 235 insertions(+), 8 deletions(-) create mode 100755 evals/quick-multi-turn-test.sh diff --git a/evals/quick-multi-turn-test.sh b/evals/quick-multi-turn-test.sh new file mode 100755 index 000000000..a62d32dbb --- /dev/null +++ b/evals/quick-multi-turn-test.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# Quick multi-turn verification test for chat -p --resume. +# Builds from source, starts an isolated container, runs a 2-turn conversation, +# and verifies context carryover + JSON output. +# +# Usage: +# NETCLAW_EVAL_PROVIDER_TYPE=openai-compatible \ +# NETCLAW_EVAL_PROVIDER_ENDPOINT=https://llm.example.com \ +# NETCLAW_EVAL_MODEL_ID=my-model \ +# ./evals/quick-multi-turn-test.sh +# +# Set NETCLAW_EVAL_NO_BUILD=1 to skip build if image/binaries already exist. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +EVAL_PORT="${NETCLAW_EVAL_PORT:-5399}" +CONTAINER_NAME="netclaw-multi-turn-test-$$" +IMAGE="${NETCLAW_IMAGE:-ghcr.io/aaronontheweb/netclawd:dev}" +NETCLAW_BIN="$REPO_ROOT/publish/cli/netclaw" +NO_BUILD="${NETCLAW_EVAL_NO_BUILD:-0}" +PROMPT_TIMEOUT=90 + +# Provider config — required +PROVIDER_TYPE="${NETCLAW_EVAL_PROVIDER_TYPE:-}" +PROVIDER_ENDPOINT="${NETCLAW_EVAL_PROVIDER_ENDPOINT:-}" +MODEL_ID="${NETCLAW_EVAL_MODEL_ID:-}" + +if [[ -z "$PROVIDER_TYPE" || -z "$PROVIDER_ENDPOINT" || -z "$MODEL_ID" ]]; then + echo "ERROR: Provider configuration required." >&2 + echo " Set NETCLAW_EVAL_PROVIDER_TYPE, NETCLAW_EVAL_PROVIDER_ENDPOINT, NETCLAW_EVAL_MODEL_ID" >&2 + exit 1 +fi + +# ─── Cleanup ────────────────────────────────────────────────────────────────── + +EVAL_HOME="" +cleanup() { + echo "" + echo "→ Cleaning up..." + docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true + if [[ -n "$EVAL_HOME" && -d "$EVAL_HOME" ]]; then + rm -rf "$EVAL_HOME" 2>/dev/null || \ + docker run --rm -v "$EVAL_HOME:/target" alpine:latest \ + sh -c 'rm -rf /target/..?* /target/.[!.]* /target/*' >/dev/null 2>&1 || true + rmdir "$EVAL_HOME" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# ─── Build ──────────────────────────────────────────────────────────────────── + +if [[ "$NO_BUILD" != "1" ]]; then + echo "→ Building from source..." + "$REPO_ROOT/scripts/docker/build-image.sh" +else + echo "→ Skipping build (NO_BUILD=1)" +fi + +if [[ ! -x "$NETCLAW_BIN" ]]; then + echo "ERROR: CLI binary not found at $NETCLAW_BIN" >&2 + exit 1 +fi + +# ─── Start Container ───────────────────────────────────────────────────────── + +EVAL_HOME=$(mktemp -d -t netclaw-mt-test-XXXXXX) +mkdir -p "$EVAL_HOME/identity" "$EVAL_HOME/logs" + +if [[ -d "$HOME/.netclaw/identity" ]]; then + cp -r "$HOME/.netclaw/identity/." "$EVAL_HOME/identity/" +else + echo "WARN: No identity at ~/.netclaw/identity — container will use defaults" +fi + +echo "→ Starting eval container on port $EVAL_PORT..." +docker run -d --rm \ + --name "$CONTAINER_NAME" \ + --network host \ + -v "$EVAL_HOME/identity:/root/.netclaw/identity" \ + -v "$EVAL_HOME/logs:/root/.netclaw/logs" \ + -e "NETCLAW_Daemon__Host=127.0.0.1" \ + -e "NETCLAW_Daemon__Port=$EVAL_PORT" \ + -e "NETCLAW_Providers__eval__Type=$PROVIDER_TYPE" \ + -e "NETCLAW_Providers__eval__Endpoint=$PROVIDER_ENDPOINT" \ + -e "NETCLAW_Models__Main__Provider=eval" \ + -e "NETCLAW_Models__Main__ModelId=$MODEL_ID" \ + -e "NETCLAW_Models__Fallback__Provider=eval" \ + -e "NETCLAW_Models__Fallback__ModelId=$MODEL_ID" \ + -e "NETCLAW_Models__Compaction__Provider=eval" \ + -e "NETCLAW_Models__Compaction__ModelId=$MODEL_ID" \ + "$IMAGE" >/dev/null + +# Wait for healthy +echo "→ Waiting for daemon health..." +deadline=$((SECONDS + 60)) +while (( SECONDS < deadline )); do + if curl -fsS "http://127.0.0.1:$EVAL_PORT/api/health/ready" >/dev/null 2>&1; then + echo "→ Daemon ready" + break + fi + running=$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || echo "false") + if [[ "$running" != "true" ]]; then + echo "ERROR: Container exited during startup" >&2 + docker logs "$CONTAINER_NAME" 2>&1 || true + exit 2 + fi + sleep 1 +done + +if ! curl -fsS "http://127.0.0.1:$EVAL_PORT/api/health/ready" >/dev/null 2>&1; then + echo "ERROR: Daemon did not become healthy within 60s" >&2 + docker logs "$CONTAINER_NAME" 2>&1 | tail -30 || true + exit 2 +fi + +# ─── Tests ──────────────────────────────────────────────────────────────────── + +PASSED=0 +FAILED=0 +SESSION_ID="test/multi-turn-$$" + +run_headless() { + local extra_args=("$@") + NETCLAW_DAEMON_ENDPOINT="http://127.0.0.1:$EVAL_PORT" \ + NETCLAW_HOME="$EVAL_HOME" \ + timeout "$PROMPT_TIMEOUT" "$NETCLAW_BIN" "${extra_args[@]}" 2>&1 +} + +assert_contains() { + local label="$1" output="$2" pattern="$3" + if echo "$output" | grep -qi "$pattern"; then + echo " ✓ $label" + PASSED=$((PASSED + 1)) + else + echo " ✗ $label (expected '$pattern' in output)" + echo " Output: $(echo "$output" | head -3)" + FAILED=$((FAILED + 1)) + fi +} + +assert_json_field() { + local label="$1" output="$2" field="$3" + if echo "$output" | python3 -c "import json,sys; d=json.load(sys.stdin); assert '$field' in d" 2>/dev/null; then + echo " ✓ $label" + PASSED=$((PASSED + 1)) + else + echo " ✗ $label (field '$field' not found in JSON)" + echo " Output: $(echo "$output" | head -1)" + FAILED=$((FAILED + 1)) + fi +} + +echo "" +echo "=== Multi-Turn Resume Test ===" +echo "Session: $SESSION_ID" +echo "" + +# Test 1: Create a named session with a memorable fact +echo "Turn 1: Establishing context..." +turn1=$(run_headless chat -p --resume "$SESSION_ID" "My favorite color is chartreuse. Just acknowledge that and nothing else.") +echo " Response: $(echo "$turn1" | head -1)" +assert_contains "Turn 1 produced output" "$turn1" "." + +# Test 2: Resume the session and ask about the fact +echo "Turn 2: Verifying context carryover..." +turn2=$(run_headless chat -p --resume "$SESSION_ID" "What is my favorite color? Answer in one word.") +echo " Response: $(echo "$turn2" | head -1)" +assert_contains "Turn 2 references chartreuse" "$turn2" "chartreuse" + +# Test 3: JSON output mode +echo "JSON output test..." +json_out=$(run_headless chat -p --json "Say hello in one word.") +echo " Output: $(echo "$json_out" | head -1)" +assert_json_field "JSON has sessionId" "$json_out" "sessionId" +assert_json_field "JSON has response" "$json_out" "response" + +# Test 4: JSON output with --resume +echo "JSON + resume test..." +json_resume=$(run_headless chat -p --json --resume "test/json-resume-$$" "Say goodbye in one word.") +echo " Output: $(echo "$json_resume" | head -1)" +assert_json_field "JSON+resume has sessionId" "$json_resume" "sessionId" + +echo "" +echo "─────────────────────────────────────────────────" +echo "Results: $PASSED passed, $FAILED failed" +echo "─────────────────────────────────────────────────" + +if [[ "$FAILED" -gt 0 ]]; then + exit 1 +fi diff --git a/evals/run-evals.sh b/evals/run-evals.sh index 4a458fc9d..3457743a8 100755 --- a/evals/run-evals.sh +++ b/evals/run-evals.sh @@ -21,26 +21,37 @@ # NETCLAW_EVAL_COMPACTION_MODEL_ID # # Container + runtime: -# NETCLAW_IMAGE Image ref (default: ghcr.io/aaronontheweb/netclawd:latest) +# NETCLAW_IMAGE Image ref (default: ghcr.io/aaronontheweb/netclawd:dev — built locally) # NETCLAW_EVAL_PORT Host-side port for the eval daemon (default 5299) # NETCLAW_EVAL_CONTEXT_WINDOW Override model context window (future compaction evals) # +# Build: +# NETCLAW_EVAL_NO_BUILD Set to 1 to skip `dotnet publish` + `docker build` +# (reuse existing ./publish output and image) +# NETCLAW_BIN Path to netclaw CLI (default: ./publish/cli/netclaw) +# # Eval suite knobs: # NETCLAW_EVAL_RUNS Runs per case (default: 5) # NETCLAW_EVAL_THRESHOLD Pass threshold 0.0-1.0 (default: 0.80) # NETCLAW_EVAL_TIMEOUT Per-prompt timeout in seconds (default: 60) -# NETCLAW_BIN Path to netclaw CLI (default: netclaw) set -euo pipefail # ─── Configuration ──────────────────────────────────────────────────────────── +# Repo root — derived from this script's location (evals/ is one level deep). +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + RUNS="${NETCLAW_EVAL_RUNS:-5}" THRESHOLD="${NETCLAW_EVAL_THRESHOLD:-0.80}" PROMPT_TIMEOUT="${NETCLAW_EVAL_TIMEOUT:-60}" -NETCLAW_BIN="${NETCLAW_BIN:-netclaw}" -NETCLAW_IMAGE="${NETCLAW_IMAGE:-ghcr.io/aaronontheweb/netclawd:latest}" EVAL_PORT="${NETCLAW_EVAL_PORT:-5299}" EVAL_CONTAINER_NAME="netclaw-eval-$$" +NO_BUILD="${NETCLAW_EVAL_NO_BUILD:-0}" + +# Image and CLI binary default to the locally-built artifacts. Evals should +# always test the current source tree, not a stale published image. +NETCLAW_IMAGE="${NETCLAW_IMAGE:-ghcr.io/aaronontheweb/netclawd:dev}" +NETCLAW_BIN="${NETCLAW_BIN:-$REPO_ROOT/publish/cli/netclaw}" # Eval target — resolved by check_prerequisites after optional interactive prompt. EVAL_PROVIDER_TYPE="${NETCLAW_EVAL_PROVIDER_TYPE:-}" @@ -74,10 +85,8 @@ DAEMON_LOG_LINES_BEFORE=0 # ─── Prerequisites ──────────────────────────────────────────────────────────── check_prerequisites() { - if ! command -v "$NETCLAW_BIN" >/dev/null 2>&1; then - echo "ERROR: '$NETCLAW_BIN' not found in PATH" >&2 - exit 1 - fi + # NETCLAW_BIN existence is verified after build_local_image (the binary + # may not exist yet when the default points to ./publish/cli/netclaw). if ! command -v timeout >/dev/null 2>&1; then echo "ERROR: 'timeout' command not found (install coreutils)" >&2 @@ -211,6 +220,24 @@ force_rmrf() { rmdir "$path" 2>/dev/null || true } +# ─── Local Build ───────────────────────────────────────────────────────────── + +build_local_image() { + if [[ "$NO_BUILD" == "1" ]]; then + echo "→ NETCLAW_EVAL_NO_BUILD=1 — skipping local build" + if [[ ! -x "$NETCLAW_BIN" ]]; then + echo "ERROR: NO_BUILD=1 but CLI binary not found at $NETCLAW_BIN" >&2 + echo " Run without NO_BUILD or publish the CLI first." >&2 + exit 1 + fi + return 0 + fi + + echo "→ Building netclaw from source (image + CLI)..." + "$REPO_ROOT/scripts/docker/build-image.sh" + echo "→ Local build complete: $NETCLAW_IMAGE" +} + # ─── Eval Daemon Lifecycle ──────────────────────────────────────────────────── start_eval_daemon() { @@ -728,6 +755,14 @@ run_all() { main() { check_prerequisites + build_local_image + + # Verify CLI binary exists (may have just been built by build_local_image). + if [[ ! -x "$NETCLAW_BIN" ]]; then + echo "ERROR: CLI binary not found at '$NETCLAW_BIN'" >&2 + exit 1 + fi + start_eval_daemon init_db