diff --git a/src/Cli/WorkflowMode.php b/src/Cli/WorkflowMode.php index 52a1524..d370466 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; + $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,71 @@ 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 { + // 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; + 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"); + + while (true) { + fwrite(STDOUT, '› '); + $line = fgets(STDIN); + + if ($line === false) { + fwrite(STDOUT, "\n (input closed — detaching; the run continues, answer it in the dashboard.)\n"); + + return false; + } + + $text = trim($line); + + 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; + } + } + /** * 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..3f831c8 100644 --- a/src/ServerClient.php +++ b/src/ServerClient.php @@ -93,6 +93,42 @@ 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), + ); + + $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; + } + + 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); + } }