From 84cbca47a5e848a9d6ed882023bfc6c4db5d11e7 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:50:58 +0000 Subject: [PATCH 1/2] feat(cli): answer a run's human gate from the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a tailed run parks at a question, the CLI prompts on the terminal and POSTs the reply to …/answer; the run wakes and carries on. A non-tty (piped) run keeps the read-only hint instead of blocking on input, and a closed stream detaches. Turns the read-only tail interactive. answer() status mapping is unit-tested; the interactive path is not live-verified (a real gate fires non-deterministically). --- src/Cli/WorkflowMode.php | 72 +++++++++++++++++++++++++++++++------- src/ServerClient.php | 30 ++++++++++++++++ tests/ServerClientTest.php | 27 ++++++++++++++ 3 files changed, 116 insertions(+), 13 deletions(-) diff --git a/src/Cli/WorkflowMode.php b/src/Cli/WorkflowMode.php index 52a1524..72bf795 100644 --- a/src/Cli/WorkflowMode.php +++ b/src/Cli/WorkflowMode.php @@ -303,30 +303,31 @@ private function tailRun(ServerClient $client, array $server, string $key, strin { $threshold = ($verbosity ?? Level::Info)->value; $color = stream_isatty(STDOUT) && getenv('NO_COLOR') === false; + $interactive = stream_isatty(STDIN); $since = 0; - $flaggedGate = false; + $openPrompt = null; // the prompt of a question not yet answered, or null + $hinted = false; while (true) { foreach ($client->trace($server, $key, $runId, $since) as $row) { $since = max($since, \is_int($row['seq'] ?? null) ? $row['seq'] : $since); + $type = (string) ($row['type'] ?? ''); + $data = \is_array($row['data'] ?? null) ? $row['data'] : []; + + if ($type === 'question') { + $openPrompt = (string) ($data['prompt'] ?? 'the run is waiting for a human'); + $hinted = false; + } elseif ($type === 'answer') { + $openPrompt = null; + } if ((\is_int($row['level'] ?? null) ? $row['level'] : 0) < $threshold) { continue; } - $data = \is_array($row['data'] ?? null) ? $row['data'] : []; - $head = TraceFormat::line((string) ($row['phase'] ?? ''), (string) ($row['type'] ?? ''), $data); + $head = TraceFormat::line((string) ($row['phase'] ?? ''), $type, $data); $depth = \is_int($row['depth'] ?? null) ? $row['depth'] : 0; - fwrite(STDOUT, str_repeat(' ', $depth) . TraceFormat::paint((string) ($row['type'] ?? ''), $head, $color) . "\n"); - - if (($row['type'] ?? '') === 'question' && !$flaggedGate) { - $flaggedGate = true; - fwrite(STDOUT, " ⏸ waiting for a human — answer it in the dashboard; Ctrl-C detaches (the run continues).\n"); - } - - if (($row['type'] ?? '') === 'answer') { - $flaggedGate = false; - } + fwrite(STDOUT, str_repeat(' ', $depth) . TraceFormat::paint($type, $head, $color) . "\n"); } $status = $this->runStatus($client->runs($server, $key, $issueId), $runId); @@ -343,10 +344,55 @@ private function tailRun(ServerClient $client, array $server, string $key, strin return 1; } + // A gate is open. On a terminal, answer it here and the run carries on; otherwise say where + // it can be answered and keep following, so a piped `claw run` never blocks on input. + if ($openPrompt !== null) { + if ($interactive) { + if ($this->answerGate($client, $server, $key, $issueId, $openPrompt)) { + $openPrompt = null; + } else { + $interactive = false; // input closed — stop asking, fall to the hint next round + } + } elseif (!$hinted) { + $hinted = true; + fwrite(STDOUT, " ⏸ waiting for a human — answer it in the dashboard; Ctrl-C detaches (the run continues).\n"); + } + } + usleep(500_000); } } + /** + * Ask the person the run's open question and send their reply. + * + * Returns false ONLY when the input stream is closed (Ctrl-D / a pipe ran dry) — the one case the + * caller must stop asking on, or it would spin on an EOF that never carries text. An answer that + * the server rejects returns true: the gate is handled (it was answered elsewhere, or the run moved + * on), so the caller drops it rather than re-prompting in a tight loop. + * + * @param array{host: string, port: int} $server + */ + private function answerGate(ServerClient $client, array $server, string $key, string $issueId, string $prompt): bool + { + fwrite(STDOUT, "\n⏸ {$prompt}\n› "); + $line = fgets(STDIN); + + if ($line === false) { + fwrite(STDOUT, "\n (input closed — detaching; the run continues, answer it in the dashboard.)\n"); + + return false; + } + + try { + $client->answer($server, $key, $issueId, trim($line)); + } catch (ClawException $e) { + fwrite(STDERR, ' ' . $e->getMessage() . "\n"); + } + + return true; + } + /** * A run's current status from a runs listing, or '' when it is not there. * diff --git a/src/ServerClient.php b/src/ServerClient.php index d961b3e..44a9fa8 100644 --- a/src/ServerClient.php +++ b/src/ServerClient.php @@ -93,6 +93,36 @@ public function startRun(array $server, string $key, string $issueId): void throw new ClawException("the server refused to start the run (HTTP {$response->status})"); } + /** + * Send a person's reply to a run's open gate. + * + * @param array{host: string, port: int} $server + * + * @throws ClawException when the run is not waiting (409) or the server refuses the answer + */ + public function answer(array $server, string $key, string $issueId, string $text): void + { + $url = sprintf( + 'http://%s:%d/api/projects/%s/issues/%s/answer', + $server['host'], + $server['port'], + rawurlencode($key), + rawurlencode($issueId), + ); + + $response = $this->http->post($url, (string) json_encode(['text' => $text]), ['Content-Type: application/json']); + + if ($response->status === 202) { + return; + } + + if ($response->status === 409) { + throw new ClawException('the run is not waiting for an answer right now'); + } + + throw new ClawException("the server refused the answer (HTTP {$response->status})"); + } + /** * The runs recorded for an issue, oldest first — how the CLI finds the run a start produced, since * the id is minted inside the run and not returned by the start. diff --git a/tests/ServerClientTest.php b/tests/ServerClientTest.php index 2164b15..353621b 100644 --- a/tests/ServerClientTest.php +++ b/tests/ServerClientTest.php @@ -157,4 +157,31 @@ public function traceIsEmptyOnAnErrorResponse(): void Assert::same($client->trace(['host' => '127.0.0.1', 'port' => 8787], 'proj', '9', 0), []); } + + #[Test] + public function anAnswerAcceptedReturnsQuietlyAndCarriesTheText(): void + { + $http = new FakeHttpClient(new HttpResponse(202, '')); + new ServerClient($http, $this->workspace, '/opt/claw') + ->answer(['host' => '127.0.0.1', 'port' => 8787], 'proj', '7', 'use the merge sort'); + + Assert::same(str_ends_with((string) $http->lastUrl, '/issues/7/answer'), true); + Assert::same(str_contains((string) $http->lastBody, 'use the merge sort'), true); + } + + #[Test] + public function answeringAGateThatIsNotWaitingIsReported(): void + { + $threw = false; + + try { + $this->client(new HttpResponse(409, '{"error":"the run is not waiting for an answer right now"}')) + ->answer(['host' => '127.0.0.1', 'port' => 8787], 'proj', '7', 'hi'); + } catch (ClawException $e) { + $threw = true; + Assert::same(str_contains($e->getMessage(), 'not waiting'), true); + } + + Assert::same($threw, true); + } } From 125427804cb40be776429269547e0bb4746e2ffd Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:56:12 +0000 Subject: [PATCH 2/2] fix(cli): harden gate answering (empty reply, EOF, encoding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Fable's review: re-prompt on a bare Enter rather than spending the gate on an empty answer; suppress the doubled hint after an EOF detach; reject an answer that will not JSON-encode instead of sending an empty body. The stale-answer-to-a-second-question window (a dashboard answer racing the terminal user's typing) is left for a server-side fix — /answer should take a question id and 409 on mismatch. --- src/Cli/WorkflowMode.php | 44 +++++++++++++++++++++++++++------------- src/ServerClient.php | 8 +++++++- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/Cli/WorkflowMode.php b/src/Cli/WorkflowMode.php index 72bf795..d370466 100644 --- a/src/Cli/WorkflowMode.php +++ b/src/Cli/WorkflowMode.php @@ -305,7 +305,7 @@ private function tailRun(ServerClient $client, array $server, string $key, strin $color = stream_isatty(STDOUT) && getenv('NO_COLOR') === false; $interactive = stream_isatty(STDIN); $since = 0; - $openPrompt = null; // the prompt of a question not yet answered, or null + $openPrompt = null; $hinted = false; while (true) { @@ -351,7 +351,10 @@ private function tailRun(ServerClient $client, array $server, string $key, strin if ($this->answerGate($client, $server, $key, $issueId, $openPrompt)) { $openPrompt = null; } else { - $interactive = false; // input closed — stop asking, fall to the hint next round + // input closed — stop asking; suppress the hint this round so it is not doubled + // with the "detaching" line answerGate just printed, and let the next gate re-hint + $interactive = false; + $hinted = true; } } elseif (!$hinted) { $hinted = true; @@ -375,22 +378,35 @@ private function tailRun(ServerClient $client, array $server, string $key, strin */ private function answerGate(ServerClient $client, array $server, string $key, string $issueId, string $prompt): bool { - fwrite(STDOUT, "\n⏸ {$prompt}\n› "); - $line = fgets(STDIN); + fwrite(STDOUT, "\n⏸ {$prompt}\n"); - if ($line === false) { - fwrite(STDOUT, "\n (input closed — detaching; the run continues, answer it in the dashboard.)\n"); + while (true) { + fwrite(STDOUT, '› '); + $line = fgets(STDIN); - return false; - } + if ($line === false) { + fwrite(STDOUT, "\n (input closed — detaching; the run continues, answer it in the dashboard.)\n"); - try { - $client->answer($server, $key, $issueId, trim($line)); - } catch (ClawException $e) { - fwrite(STDERR, ' ' . $e->getMessage() . "\n"); - } + return false; + } + + $text = trim($line); - return true; + if ($text === '') { + // An empty reply wakes the run with nothing to act on — ask again rather than spend the gate. + fwrite(STDOUT, " (type a reply — an empty answer would spend the gate for nothing.)\n"); + + continue; + } + + try { + $client->answer($server, $key, $issueId, $text); + } catch (ClawException $e) { + fwrite(STDERR, ' ' . $e->getMessage() . "\n"); + } + + return true; + } } /** diff --git a/src/ServerClient.php b/src/ServerClient.php index 44a9fa8..3f831c8 100644 --- a/src/ServerClient.php +++ b/src/ServerClient.php @@ -110,7 +110,13 @@ public function answer(array $server, string $key, string $issueId, string $text rawurlencode($issueId), ); - $response = $this->http->post($url, (string) json_encode(['text' => $text]), ['Content-Type: application/json']); + $body = json_encode(['text' => $text]); + + if ($body === false) { + throw new ClawException('cannot send the answer: it is not valid text'); + } + + $response = $this->http->post($url, $body, ['Content-Type: application/json']); if ($response->status === 202) { return;