Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ea12c35
feat(server): SSE run-trace stream + dashboard run-engine plan
EdmondDantes Jun 27, 2026
5f6e3b1
refactor(run): extract IssueRunner as the shared headless run engine
EdmondDantes Jun 27, 2026
bf2dee3
feat(server): POST /start and /answer — run an issue with a human gate
EdmondDantes Jun 27, 2026
d5f268b
fix(generate): stop solvers calling done in an early step and abortin…
EdmondDantes Jun 27, 2026
ab78b55
feat(server): push live trace over an in-process bus, no polling
EdmondDantes Jun 27, 2026
f9a91d7
feat(server): SSE board stream — live issue snapshots for the Kanban
EdmondDantes Jun 27, 2026
f687637
* remove old files
EdmondDantes Jun 27, 2026
219e306
docs(architecture): rewrite ARCHITECTURE.md for the current system
EdmondDantes Jun 27, 2026
387b31c
refactor(server,run): address review — interfaces over closures, fact…
EdmondDantes Jun 27, 2026
3236d2c
feat(workflow): back() to an earlier step, and keep model context on …
EdmondDantes Jun 27, 2026
ae0b546
refactor(run): move IssueRunner/RunContext to Claw\\Run; repair catch…
EdmondDantes Jun 27, 2026
127ca23
feat(generate): review the drafted solver via a critic, not an inline…
EdmondDantes Jun 27, 2026
22dc451
refactor: readonly classes where all state is readonly; TraceBus keys…
EdmondDantes Jun 27, 2026
c12a998
refactor(trace): LiveTraceSink composes TraceStore; the bus carries t…
EdmondDantes Jun 27, 2026
695c570
style: enforce a blank line after a control-structure block (cs-fixer)
EdmondDantes Jun 27, 2026
80d6689
refactor(server): thin Server — read via ProjectStore/TraceReader, re…
EdmondDantes Jun 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
'single_quote' => true,
'trailing_comma_in_multiline' => true,
'declare_strict_types' => true,
// a blank line before these statements => a blank line after a control-structure's closing `}`
'blank_line_before_statement' => ['statements' => [
'break', 'continue', 'declare', 'do', 'for', 'foreach', 'if',
'return', 'switch', 'throw', 'try', 'while', 'yield', 'yield_from',
]],
'phpdoc_align' => false,
'phpdoc_separation' => false,
'no_superfluous_phpdoc_tags' => false,
Expand Down
524 changes: 262 additions & 262 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"psr/log": "^3.0",
"phpstan/phpstan": "^2.2",
"friendsofphp/php-cs-fixer": "^3.95",
"true-async/ide-helper": "^0.7.2"
"true-async/ide-helper": "^0.7.3"
},
"autoload": {
"psr-4": {
Expand Down
14 changes: 7 additions & 7 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

297 changes: 297 additions & 0 deletions docs/dashboard-server-plan.md

Large diffs are not rendered by default.

174 changes: 0 additions & 174 deletions docs/workflow-gaps.md

This file was deleted.

2 changes: 2 additions & 0 deletions src/Agent/AbstractAgent.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ final public function send(AgentRequest $request): AgentResponse
return $this->attempt($request);
} catch (AgentException $e) {
$delay = $this->retryPolicy->delayBeforeRetry($e, $attempt);

if ($delay === null) {
throw $e;
}
Expand Down Expand Up @@ -56,6 +57,7 @@ protected function postJson(string $url, string $body, array $headers): array
{
try {
$response = $this->http->post($url, $body, $headers);

if (!$response->isOk()) {
throw AgentErrors::fromResponse($response);
}
Expand Down
1 change: 1 addition & 0 deletions src/Agent/AgentErrors.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ public static function classify(int $status, ?string $errorType, string $message
private static function isContextOverflow(string $message): bool
{
$message = strtolower($message);

foreach (['context length', 'context window', 'maximum context', 'prompt is too long', 'too many tokens', 'reduce the length'] as $needle) {
if (str_contains($message, $needle)) {
return true;
Expand Down
29 changes: 29 additions & 0 deletions src/Agent/AgentFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Claw\Agent;

use Claw\Config;
use Claw\Http\HttpClientInterface;

/**
* Builds the agent named by the config, or null if that agent is not wired yet. Agents retry internally
* (cause-aware), so callers pass a plain transport. Lives here, not in the CLI layer, so every entry
* point (CLI run, dashboard server) composes its agent the same way.
*/
final class AgentFactory
{
public static function make(Config $config, HttpClientInterface $http): ?AgentInterface
{
return match ($config->agent) {
'claude' => new ClaudeAgent($http, $config->apiKey),
'openai-compatible' => new OpenAiCompatibleAgent(
$http,
$config->apiKey,
$config->baseUrl ?? 'https://api.deepseek.com',
),
default => null,
};
}
}
2 changes: 2 additions & 0 deletions src/Agent/Budget.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ public function isExhausted(): bool
if ($this->tokenLimit > 0 && $this->tokens >= $this->tokenLimit) {
return true;
}

if ($this->secondsLimit > 0.0 && $this->elapsed() >= $this->secondsLimit) {
return true;
}
Expand All @@ -83,6 +84,7 @@ public function reason(): string
if ($this->tokenLimit > 0 && $this->tokens >= $this->tokenLimit) {
return "token budget exhausted ({$this->tokens}/{$this->tokenLimit})";
}

if ($this->secondsLimit > 0.0 && $this->elapsed() >= $this->secondsLimit) {
return 'time budget exhausted (' . round($this->elapsed(), 1) . "s/{$this->secondsLimit}s)";
}
Expand Down
2 changes: 2 additions & 0 deletions src/Agent/ClaudeAgent.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ public static function decodeResponse(array $data): AgentResponse
$textBlock = new TextBlock((string) ($block['text'] ?? ''));
$content[] = $textBlock;
$texts[] = $textBlock->text;

break;

case 'tool_use':
Expand All @@ -101,6 +102,7 @@ public static function decodeResponse(array $data): AgentResponse
);
$content[] = $useBlock;
$toolCalls[] = $useBlock;

break;
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/Agent/DefaultTurnLoop.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ public function run(array $history): TurnResult
// is spent, stop the exchange here and return what we have, not another round-trip.
if ($this->turnBudget !== null) {
$this->turnBudget->spend($response->usage->inputTokens + $response->usage->outputTokens);

if ($this->turnBudget->isExhausted()) {
$this->tracer?->exit($turn);

Expand All @@ -143,8 +144,10 @@ public function run(array $history): TurnResult
// the answer as the next user turn, and continue the same loop (context stays whole).
if ($this->ask !== null) {
$question = $this->extractQuestion($response->text ?? '');

if ($question !== null) {
$answer = $this->ask->reply($question);

if ($answer !== null) { // null = the chain passed up, no one answered
$history[] = Message::userText($answer);

Expand All @@ -157,6 +160,7 @@ public function run(array $history): TurnResult
}

$results = [];

foreach ($response->toolCalls as $call) {
$this->tracer?->toolCall($call->name, $call->input);
$result = $this->executor->call(new ToolCall($call->id, $call->name, $call->input));
Expand Down
1 change: 1 addition & 0 deletions src/Agent/EscalatingSpeaker.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public function reply(string $incoming): ?string
{
foreach ($this->tiers as $tier) {
$answer = $tier->reply($incoming);

if ($answer !== null) {
return $answer; // this tier handled it
}
Expand Down
5 changes: 5 additions & 0 deletions src/Agent/OpenAiCompatibleAgent.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ protected function attempt(AgentRequest $request): AgentResponse
public static function encodeRequest(AgentRequest $request): array
{
$messages = [];

if ($request->system !== '') {
$messages[] = ['role' => 'system', 'content' => $request->system];
}
Expand Down Expand Up @@ -98,6 +99,7 @@ public static function decodeResponse(array $data): AgentResponse
$text = null;

$contentText = $message['content'] ?? null;

if (is_string($contentText) && $contentText !== '') {
$content[] = new TextBlock($contentText);
$text = $contentText;
Expand Down Expand Up @@ -151,6 +153,7 @@ private static function encodeMessage(Message $message): array
if ($message->role === Role::Assistant) {
$text = '';
$toolCalls = [];

foreach ($message->content as $block) {
if ($block instanceof TextBlock) {
$text .= $block->text;
Expand All @@ -170,6 +173,7 @@ private static function encodeMessage(Message $message): array
}

$encoded = ['role' => 'assistant', 'content' => $text === '' ? null : $text];

if ($toolCalls !== []) {
$encoded['tool_calls'] = $toolCalls;
}
Expand All @@ -178,6 +182,7 @@ private static function encodeMessage(Message $message): array
}

$text = '';

foreach ($message->content as $block) {
if ($block instanceof TextBlock) {
$text .= $block->text;
Expand Down
9 changes: 9 additions & 0 deletions src/Chat/AsyncConsoleConversation.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ private function readLoop(): void

if ($line === false) {
$this->eof = true; // EOF (Ctrl-D / closed stdin)

return;
}

Expand Down Expand Up @@ -231,6 +232,7 @@ public function updateStatus(?Status $status): void
if ($status === null) {
$this->statusLabel = '';
$this->writeStatus('');

return;
}

Expand Down Expand Up @@ -410,9 +412,11 @@ private function renderHistory(): void
$height = $this->chatRows - $this->chatStart + 1;

$lines = $this->history;

foreach ($this->deferred as $line) {
$lines[] = self::C_DIM . 'User: ' . $line . self::C_RESET . "\n";
}

if ($this->warning !== null) {
$lines[] = $this->warning . "\n"; // its own block, below the dim deferred area
}
Expand Down Expand Up @@ -450,6 +454,7 @@ private function watchResize(): void
private function syncSize(): void
{
[$rows, $cols] = self::detectSize();

if ($rows !== $this->rows || $cols !== $this->cols) {
$this->rows = $rows;
$this->cols = $cols;
Expand Down Expand Up @@ -489,6 +494,7 @@ private static function detectSize(): array
// readline_info (static here), this refreshes on every resize.
if (PHP_OS_FAMILY === 'Windows') {
$size = self::winConsoleSize();

if ($size !== null) {
return $size;
}
Expand All @@ -501,6 +507,7 @@ private static function detectSize(): array
// Linux/macOS: `stty size` prints "rows cols" for the tty — live on resize.
if (($rows < 4 || $cols < 20) && PHP_OS_FAMILY !== 'Windows') {
$out = @shell_exec('stty size 2>/dev/null');

if (is_string($out) && preg_match('/^(\d+)\s+(\d+)/', trim($out), $m)) {
$rows = (int) $m[1];
$cols = (int) $m[2];
Expand Down Expand Up @@ -548,11 +555,13 @@ private static function winConsoleSize(): ?array
);
}
$info = $k->new('CONSOLE_SCREEN_BUFFER_INFO');

if (!$k->GetConsoleScreenBufferInfo($k->GetStdHandle(0xFFFFFFF5), \FFI::addr($info))) {
return null; // STD_OUTPUT_HANDLE = (DWORD)-11
}
$cols = $info->w->Right - $info->w->Left + 1;
$rows = $info->w->Bottom - $info->w->Top + 1;

return ($cols < 20 || $rows < 4) ? null : [$rows, $cols];
} catch (\Throwable) {
return null;
Expand Down
1 change: 1 addition & 0 deletions src/Chat/ConsoleConversation.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public function receive(): ?string
}

$line = trim($line);

if ($line !== '') {
return $line;
}
Expand Down
9 changes: 9 additions & 0 deletions src/Chat/TelegramChat.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,24 +71,28 @@ public function accept(): ConversationInterface
public function ingest(array $update): void
{
$callback = $update['callback_query'] ?? null;

if (\is_array($callback)) {
$this->ingestCallback($callback);

return;
}

$message = $update['message'] ?? null;

if (!\is_array($message)) {
return;
}

$chat = \is_array($message['chat'] ?? null) ? $message['chat'] : [];

if (($chat['type'] ?? null) !== 'private') {
return; // DMs only for now
}

$from = \is_array($message['from'] ?? null) ? $message['from'] : [];
$userId = (int) ($from['id'] ?? 0);

if (!($this->isAllowed)($userId)) {
// Silent drop. Log the id so the owner can find it for the allowlist.
fwrite(STDERR, "telegram: dropped message from unauthorized id {$userId}\n");
Expand All @@ -97,12 +101,14 @@ public function ingest(array $update): void
}

$text = $message['text'] ?? null;

if (!\is_string($text) || trim($text) === '') {
return;
}

$chatId = (int) ($chat['id'] ?? $userId);
$conversation = $this->conversations[$chatId] ?? null;

if ($conversation === null) {
$conversation = new TelegramConversation($chatId, $this->client);
$this->conversations[$chatId] = $conversation;
Expand All @@ -121,11 +127,13 @@ public function ingest(array $update): void
private function ingestCallback(array $callback): void
{
$from = \is_array($callback['from'] ?? null) ? $callback['from'] : [];

if (!($this->isAllowed)((int) ($from['id'] ?? 0))) {
return;
}

$id = (string) ($callback['id'] ?? '');

if ($id !== '') {
$this->client->answerCallbackQuery($id);
}
Expand All @@ -136,6 +144,7 @@ private function ingestCallback(array $callback): void
$data = $callback['data'] ?? null;

$conversation = $this->conversations[$chatId] ?? null;

if ($conversation !== null && \is_string($data)) {
$conversation->deliver($data);
}
Expand Down
5 changes: 5 additions & 0 deletions src/Chat/TelegramClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,19 @@ public function getUpdates(int $offset, int $timeoutSeconds = 25): array
]);

$data = $this->http->get($url)->json();

if (($data['ok'] ?? false) !== true) {
throw new HttpException('Telegram getUpdates failed: ' . json_encode($data['description'] ?? $data));
}

$result = $data['result'] ?? [];

if (!\is_array($result)) {
return [];
}

$updates = [];

foreach ($result as $item) {
if (\is_array($item)) {
/** @var array<string, mixed> $item */
Expand All @@ -65,6 +68,7 @@ public function getUpdates(int $offset, int $timeoutSeconds = 25): array
public function sendMessage(int $chatId, string $text, ?array $replyMarkup = null): void
{
$payload = ['chat_id' => $chatId, 'text' => $text];

if ($replyMarkup !== null) {
$payload['reply_markup'] = $replyMarkup;
}
Expand Down Expand Up @@ -95,6 +99,7 @@ private function call(string $method, array $payload): void
// (message too long, chat blocked, bad markup). Check ok like getUpdates so a failed write does
// not silently look like success.
$data = $this->http->post($this->base . $method, $body, ['Content-Type: application/json'])->json();

if (($data['ok'] ?? false) !== true) {
throw new HttpException("Telegram {$method} failed: " . json_encode($data['description'] ?? $data));
}
Expand Down
Loading
Loading