In the run, add a page to connect to the agent through the browser #11

Closed
opened 2026-11-03 15:48:46 +00:00 by SebastienDenooz · 6 comments

In the page of run's /runs/{id}, add a buton to a page to show the current screen of the running agent when the run use claude code, codex, etc...

We want to see the agent running live to follow what it doe

Functional analysis

Expected behaviour

The run detail page /runs/{id} gets a button that opens a separate page, /runs/{id}/live, in the same tab. That page shows a live, readable activity feed of what the agent in the run is doing, so a user can follow it as it works.

Decisions from the discussion:

Topic Decision
What the page shows Activity stream: a readable feed of the agent's messages, tool calls, files read or edited, and commands run, added as they happen. It is not a terminal mirror and not raw stdout/stderr.
Interaction Watch only. Users cannot type into the session, steer the agent or stop the run from this page.
Which runs All runs. This covers runs using a command-line agent (Claude Code, Codex, OpenCode, Copilot, Grok Build) and runs that call an LLM API directly. API runs show their model calls and tool calls.
Run lifecycle Live while running, then final. While the run is running, the feed updates live. After the run ends (succeeded, failed or cancelled), the page keeps the last content it captured and stops updating.
Access Same as the run page. Anyone who can see /runs/{id} can open the live page. Anyone who cannot see the run cannot open it.
Placement Dedicated page (/runs/{id}/live), reached from a button on the run page.
Runners Container and host runners are both supported.

Acceptance criteria

  1. Button. /runs/{id} shows a button that leads to /runs/{id}/live. This holds for every run, whatever the agent type (command-line agent or direct LLM API) and whatever the runner (container or host).
  2. Live feed while running. While the run is running, new entries appear on /runs/{id}/live without a manual page reload, in the order they happened.
  3. Feed content for command-line agents. For runs using Claude Code, Codex, OpenCode, Copilot or Grok Build, the feed shows the agent's messages, tool calls, files read or edited, and commands run. Each entry is readable and labelled by kind.
  4. Feed content for API runs. For runs that call an LLM API directly, the feed shows the model calls (the model's messages) and the tool calls with their results.
  5. Watch only. The page has no input, prompt box or control that sends anything to the agent or changes the run's course.
  6. Run not started. While the run is still pending, the page says the run has not started yet. It begins showing activity once the run starts, without a manual reload.
  7. Run ended. When the run ends while someone is watching, the page shows the final status (succeeded, failed or cancelled) and stops updating. The content captured up to that point stays visible.
  8. Opening after the end. Opening the page for a run that has already finished shows the last captured content and the final status. The page does not update.
  9. Access control. A user allowed to see /runs/{id} can open /runs/{id}/live. A user who is not allowed gets the same denial as on the run page, and no activity content leaks.
  10. Both runners. Criteria 2–8 hold for runs on the container runner and on the host runner.
  11. Unchanged final result. Adding the live feed does not change the run's existing final output, status or results.

Out of scope

  • A terminal-style mirror of the agent's interactive interface, and raw stdout/stderr streaming.
  • Interacting with the agent, or stopping or cancelling the run, from the live page.
  • A full replay or recording player beyond keeping the last captured content.
  • Opening the page in a new tab, or an inline panel on the run page.
In the page of run's `/runs/{id}`, add a buton to a page to show the current screen of the running agent when the run use `claude code`, `codex`, etc... We want to see the agent running live to follow what it doe <!-- agent:section:start --> ## Functional analysis ### Expected behaviour The run detail page `/runs/{id}` gets a button that opens a separate page, `/runs/{id}/live`, in the same tab. That page shows a live, readable activity feed of what the agent in the run is doing, so a user can follow it as it works. Decisions from the discussion: | Topic | Decision | |---|---| | What the page shows | **Activity stream:** a readable feed of the agent's messages, tool calls, files read or edited, and commands run, added as they happen. It is not a terminal mirror and not raw stdout/stderr. | | Interaction | **Watch only.** Users cannot type into the session, steer the agent or stop the run from this page. | | Which runs | **All runs.** This covers runs using a command-line agent (Claude Code, Codex, OpenCode, Copilot, Grok Build) and runs that call an LLM API directly. API runs show their model calls and tool calls. | | Run lifecycle | **Live while running, then final.** While the run is running, the feed updates live. After the run ends (succeeded, failed or cancelled), the page keeps the last content it captured and stops updating. | | Access | **Same as the run page.** Anyone who can see `/runs/{id}` can open the live page. Anyone who cannot see the run cannot open it. | | Placement | **Dedicated page** (`/runs/{id}/live`), reached from a button on the run page. | | Runners | **Container and host runners** are both supported. | ### Acceptance criteria 1. **Button.** `/runs/{id}` shows a button that leads to `/runs/{id}/live`. This holds for every run, whatever the agent type (command-line agent or direct LLM API) and whatever the runner (container or host). 2. **Live feed while running.** While the run is running, new entries appear on `/runs/{id}/live` without a manual page reload, in the order they happened. 3. **Feed content for command-line agents.** For runs using Claude Code, Codex, OpenCode, Copilot or Grok Build, the feed shows the agent's messages, tool calls, files read or edited, and commands run. Each entry is readable and labelled by kind. 4. **Feed content for API runs.** For runs that call an LLM API directly, the feed shows the model calls (the model's messages) and the tool calls with their results. 5. **Watch only.** The page has no input, prompt box or control that sends anything to the agent or changes the run's course. 6. **Run not started.** While the run is still pending, the page says the run has not started yet. It begins showing activity once the run starts, without a manual reload. 7. **Run ended.** When the run ends while someone is watching, the page shows the final status (succeeded, failed or cancelled) and stops updating. The content captured up to that point stays visible. 8. **Opening after the end.** Opening the page for a run that has already finished shows the last captured content and the final status. The page does not update. 9. **Access control.** A user allowed to see `/runs/{id}` can open `/runs/{id}/live`. A user who is not allowed gets the same denial as on the run page, and no activity content leaks. 10. **Both runners.** Criteria 2–8 hold for runs on the container runner and on the host runner. 11. **Unchanged final result.** Adding the live feed does not change the run's existing final output, status or results. ### Out of scope - A terminal-style mirror of the agent's interactive interface, and raw stdout/stderr streaming. - Interacting with the agent, or stopping or cancelling the run, from the live page. - A full replay or recording player beyond keeping the last captured content. - Opening the page in a new tab, or an inline panel on the run page. <!-- agent:key=695a5d3702be:kind=description --> <!-- agent:section:end -->
Owner

Thanks @SebastienDenooz, I am on it. I will post my analysis here.

Thanks @SebastienDenooz, I am on it. I will post my analysis here. <!-- agent:key=c973ec666703:kind=acknowledgement -->
Owner

I need a few answers before going further. Reply in a comment; without an answer by 2026-11-04 15:49 UTC, I will apply the defaults.

q1 — What should the "live screen" show? Right now each command-line agent (Claude Code, Codex, OpenCode, Copilot, Grok Build) runs in non-interactive mode. Its output is collected only when it finishes, so no terminal screen exists while it works.

  • activity-stream — A live, readable feed of what the agent is doing (its messages, tool calls, files read or edited, commands run), updated as they happen (default)
  • terminal-mirror — A real terminal view in the browser that looks exactly like the agent's interactive interface, as if you were watching its console
  • raw-output — The agent's raw output (stdout/stderr) streamed live as plain text

q2 — Should the browser view be watch-only, or can the user also act on the agent?

  • read-only — Watch only; the user cannot type into the session or change its course (default)
  • interactive — The user can type into the session (answer the agent, give instructions)
  • read-only-with-stop — Watch only, plus a button to stop the run from that page

q3 — Which runs show the button?

  • local-agents-only — Only runs that use a command-line agent (Claude Code, Codex, OpenCode, Copilot, Grok Build); runs that call an LLM API directly do not show it (default)
  • all-runs — Every run, including API runs, which would show their model calls and tool calls

q4 — What happens to the page when the run is not running (still pending, or already succeeded, failed or cancelled)?

  • running-only — The button shows only while the run is running; if the run ends while someone is watching, the page says it has finished
  • replay-after — The button also shows after the run ends, and the page shows the full recorded session as a replay
  • running-then-final — While running the view is live; after the run ends the page keeps the last captured content but no longer updates (default)

q5 — Who can open the live page? It may show code, prompts and command output from the run's sandbox.

  • same-as-run-page — Anyone who can see the run detail page /runs/{id} (default)
  • admins-only — Only organization owners/admins

q6 — Where does the live view open?

  • dedicated-page — A separate page (for example /runs/{id}/live) reached from a button on the run page (default)
  • new-tab — The same separate page, but the button opens it in a new browser tab
  • inline-panel — A panel that expands inside the run page itself

q7 — Does the agent have to run in a container, or must the live view also work when it runs directly on the worker host (the "host" runner)?

  • both-runners — Works for both container and host runners (default)
  • container-only — Works only for container runners; the button is hidden for host runs

You can also answer every question at once in a comment of your own.

I need a few answers before going further. Reply in a comment; without an answer by 2026-11-04 15:49 UTC, I will apply the defaults. <!-- agent:questions:block=12 --> **q1 — What should the "live screen" show? Right now each command-line agent (Claude Code, Codex, OpenCode, Copilot, Grok Build) runs in non-interactive mode. Its output is collected only when it finishes, so no terminal screen exists while it works.** <!-- agent:q=q1 --> - [x] activity-stream — A live, readable feed of what the agent is doing (its messages, tool calls, files read or edited, commands run), updated as they happen *(default)* - [ ] terminal-mirror — A real terminal view in the browser that looks exactly like the agent's interactive interface, as if you were watching its console - [ ] raw-output — The agent's raw output (stdout/stderr) streamed live as plain text **q2 — Should the browser view be watch-only, or can the user also act on the agent?** <!-- agent:q=q2 --> - [x] read-only — Watch only; the user cannot type into the session or change its course *(default)* - [ ] interactive — The user can type into the session (answer the agent, give instructions) - [ ] read-only-with-stop — Watch only, plus a button to stop the run from that page **q3 — Which runs show the button?** <!-- agent:q=q3 --> - [ ] local-agents-only — Only runs that use a command-line agent (Claude Code, Codex, OpenCode, Copilot, Grok Build); runs that call an LLM API directly do not show it *(default)* - [x] all-runs — Every run, including API runs, which would show their model calls and tool calls **q4 — What happens to the page when the run is not running (still pending, or already succeeded, failed or cancelled)?** <!-- agent:q=q4 --> - [ ] running-only — The button shows only while the run is running; if the run ends while someone is watching, the page says it has finished - [ ] replay-after — The button also shows after the run ends, and the page shows the full recorded session as a replay - [x] running-then-final — While running the view is live; after the run ends the page keeps the last captured content but no longer updates *(default)* **q5 — Who can open the live page? It may show code, prompts and command output from the run's sandbox.** <!-- agent:q=q5 --> - [x] same-as-run-page — Anyone who can see the run detail page `/runs/{id}` *(default)* - [ ] admins-only — Only organization owners/admins **q6 — Where does the live view open?** <!-- agent:q=q6 --> - [x] dedicated-page — A separate page (for example `/runs/{id}/live`) reached from a button on the run page *(default)* - [ ] new-tab — The same separate page, but the button opens it in a new browser tab - [ ] inline-panel — A panel that expands inside the run page itself **q7 — Does the agent have to run in a container, or must the live view also work when it runs directly on the worker host (the "host" runner)?** <!-- agent:q=q7 --> - [x] both-runners — Works for both container and host runners *(default)* - [ ] container-only — Works only for container runners; the button is hidden for host runs You can also answer every question at once in a comment of your own. <!-- agent:key=bbfae3f7fe7e:kind=questions -->
Author
Owner

Ok, i setup the answer on q3 and all others are ok for me. You can continue with that.

Ok, i setup the answer on q3 and all others are ok for me. You can continue with that.
Owner

Plan

Live activity page for runs (/runs/{id}/live)

What exists today

  • Routes and controller. routes/web.php:88-90 and app/Http/Controllers/RunController.php. GET /runs/{run}/live is already taken by the auto-refreshing fragment of the run page (runs.live → runs/partials/live.blade.php, polled by resources/js/live.js). The issue requires that URL for the new page, so the fragment moves (see step 6).
  • Command-line agents. app/Llm/Local/LocalAgentAdapter::invoke() makes one blocking ContainerRuntime::exec() call, and stdout is only read when it returns. Containers use Process::run() in app/Sandbox/CliContainerRuntime.php:89. The host runner uses Process::start() plus a polling loop in app/Sandbox/HostRuntime.php:190.
    • Codex (--json), OpenCode (--format json), Copilot (--output-format json, JSON lines) and Grok Build (streaming-json) already print one JSON event per line as they work.
    • Claude Code uses --output-format json, which prints a single object at the end.
  • API runs. app/Llm/Api/ApiLlmAdapter calls $agent->prompt() once. Each tool call goes through ToolContext::run(), which records a ToolCallRecord. These records are written to llm_tool_calls only after the run, in RunExecutor::recordResult().
  • Where the run executes. Runs execute in queue workers, and the page is served by the web process. So the database is the only channel between them, and the plan uses it.
  • Other building blocks. The retention purge is app/Jobs/PurgeRetention.php. Secret masking is app/Logging/Redactor.php. Browser tests use the Pest browser plugin (tests/Browser/..., Playwright under the hood).

Approach

1. Storage: a run_activities table

  • Migration create_run_activities_table with these columns:
    • id, organization_id, run_id (FK, cascade on delete), llm_session_id (nullable)
    • sequence (unsigned int, unique together with run_id)
    • kind (string)
    • title (string ≤ 255: the tool name, the file path, or the command)
    • body (text, nullable)
    • occurred_at
    • Index on (run_id, sequence).
  • Model App\Models\RunActivity using the BelongsToOrganization concern, plus a factory.
  • Enum App\Enums\ActivityKind with the values message, tool_call, tool_result, file_read, file_edit, command, error. Each value has a label() used for the badge, which covers criterion 3 ("labelled by kind").
  • Value object App\Llm\Data\ActivityEntry (readonly: kind, title, body).

2. Recording: App\Runs\Activity\ActivityRecorder

  • Built per run by RunExecutor (run id, session id, organization id) and passed to the adapter in a new optional LlmRequest::$activity (?ActivitySink, a small interface with record(ActivityEntry ...$entries)). SmokeTest passes nothing.
  • Write path:
    • Entries are masked with Redactor, and the run's credentials are registered as secrets too.
    • body is cut to alfred.llm.activity_excerpt_chars (default 4000).
    • Rows are batch-inserted when the buffer is flushed: at most every 500 ms, or every 50 entries.
    • The recorder stops after alfred.llm.activity_max_entries (default 2000) and writes one final error entry saying the feed was truncated.
  • Failures never affect the run (criterion 11). Every recorder error is caught and logged, and the run continues.
  • RunExecutor::invokeInRunner() flushes the recorder in a finally block.

3. Live output from the runners

  • Contract. Add ?Closure $onOutput = null (receives stdout chunks) as the last parameter of ContainerRuntime::exec(). Pass it through in RunnerRuntime and NoContainerRuntime.
  • CliContainerRuntime: $pending->run($argv, fn ($type, $chunk) => $type === 'out' && $onOutput?->__invoke($chunk)).
  • HostRuntime: inside the existing while ($process->running()) loop, forward $process->latestOutput(), then drain what remains after the loop.
  • FakeContainerRuntime: scripted results can declare chunks that are sent to the callback before the run returns.
  • ExecResult keeps holding the full stdout, so text, outcome, usage and exit code are worked out exactly as today.

4. Turning CLI events into entries

  • LocalAgentAdapter:
    • A line buffer (App\Llm\Local\JsonLineStream) splits chunks into complete lines and decodes each JSON event.
    • A new protected method activity(array $event): list<ActivityEntry> (default []) is called for each event, and its result goes to $request->activity.
    • Parsing stays inside each adapter, next to the existing extractText(). These adapters already depend on each CLI's event format.
  • What each adapter maps:
    • Claude Code: assistant text → message. tool_use → Read / Glob / Grep become file_read, Edit / Write / MultiEdit become file_edit, Bash becomes command. tool_result → result.
    • Codex: item.completed with agent_message / command_execution / file_change / reasoning.
    • OpenCode: text, tool_use parts.
    • Copilot: assistant.message, tool events.
    • Grok Build: text chunks merged into one message per run of text, plus tool_call / tool_call_update.
    • Unknown events are ignored.
  • Claude Code switches to --output-format stream-json --verbose.
    • extractText() and usage() read the final {"type":"result"} line, which has the same fields as today's single JSON object.
    • A new hook rawOutput(ExecResult) (default: stdout) returns only that result line for Claude Code. raw_output therefore keeps its current shape (criterion 11).
    • mapOutcome() and the auth/rate-limit patterns still read the full output.

5. API runs

  • ToolContext receives the activity sink.
    • Before running a tool, run() records a tool_call entry: tool name, a summary of the arguments (path, command…), mapped to file_read / file_edit / command by tool name.
    • After it, run() records a tool_result entry with the result excerpt and success.
  • Model messages: the assistant text of each step is recorded as soon as that step finishes. Before implementing, check the current laravel/ai docs (context7) for per-step events or the agent middleware / step callbacks.
    • If they are available, record from them.
    • Otherwise, record each step's text from $response->steps when prompt() returns. The tool calls stay live.
  • The llm_tool_calls write in recordResult() is unchanged.

6. Routes, controller, views

  • Routes:
    • The existing fragment moves to GET /runs/{run}/fragment (runs.fragment). Update show.blade.php:32 and its tests.
    • GET /runs/{run}/live → RunController@live (runs.live): the full page.
    • GET /runs/{run}/live/feed?after={sequence} → RunController@feed (runs.live.feed): an HTML fragment containing only the entries after the cursor, plus the run state.
  • Access: both routes call CurrentOrganization::abortUnlessOwns() and Gate::authorize('view', $run), the same checks as show (criterion 9: same 404 / redirect to login).
  • Views:
    • resources/views/runs/live.blade.php: back link to the run, the state badge, and an ordered list <ol data-feed>.
    • resources/views/runs/partials/feed.blade.php: entries as <li data-sequence data-kind>, each with a kind badge, a title, and the body in an escaped <pre> (never raw HTML).
    • Messages for a pending run ("The run has not started yet."), for no activity, for content removed by retention, and the final status once the run is finished. When finished, data-live-finished="true" is set.
    • No form or input on the page (criterion 5).
  • Button on show.blade.php: a "Watch live" link next to "Re-run", shown for every run (criterion 1).
  • JavaScript: extend resources/js/live.js with an append mode, so the existing replace mode is unaffected.
    • Elements with data-live-mode="append" request url?after=<last data-sequence> and append the returned <li>s.
    • They update the status header and stop when [data-live-finished="true"] appears.
  • Finished runs: the controller renders the full captured feed and no polling URL (criterion 8).

7. Retention and docs

  • PurgeRetention sets run_activities.body = null (and title for commands and files) for purged sessions, in the same transaction.
  • Update docs/specification.md §27.2, §27.5 and §27.6 (new routes, renamed fragment, activity feed, retention).

Tests

  • Unit:
    • Each *AdapterTest: activity() mapping from recorded JSON-line samples. Claude Code extractText() / usage() / rawOutput() on stream-json output, with the result unchanged compared to today.
    • JsonLineStream: partial lines across chunks.
    • ActivityRecorder: redaction, truncation, max entries, batched flush, errors swallowed.
    • CliContainerRuntimeTest / HostRuntimeProcessTest: the callback receives chunks before the process ends, and the ExecResult is unchanged.
    • FakeContainerRuntimeTest: scripted chunks.
    • ToolContext: live entries for calls and results.
  • Feature:
    • tests/Feature/Admin/Runs/RunLiveTest.php:
      • The button appears on the run page.
      • Page states: pending, running, finished (each finished state).
      • The after cursor returns only newer entries.
      • The data-live-finished flag.
      • Access: another organization gets 404, guests are redirected to login, and no entry text appears in a denied response.
      • Purged content.
    • RunScreensTest updated for /fragment.
    • RunExecutor tests with FakeContainerRuntime: a local run and an API run, each on both the container and the host runner, write activity rows and leave text_output / raw_output / outcome unchanged.
    • PurgeRetention test.
  • E2E (Pest browser / Playwright): tests/Browser/Runs/RunLiveTest.php.
    • Open the run, click "Watch live", and land on /runs/{id}/live.
    • Insert activity rows and see them appear without a reload, in order.
    • Mark the run succeeded and see the final status, with polling stopped.
    • Open a pending run and see the not-started message, then the feed once it starts.
  • CI: browser tests already run in both .github/workflows/ci.yml and .forgejo/workflows/ci.yml (and .gitlab-ci.yml). Check that the new test directory is picked up.
  • Manual check: with chrome-devtools, open the live page during a real local run.

Risks

  • CLI event formats change between versions. Parsing is best-effort: unknown events are ignored and never fail the run. The tests use real sample lines.
  • Claude Code stream-json needs --verbose with -p, and the new flag must work with the supported version range. Check this against claude --help for 2.x.
  • Database load: batching, the excerpt cap and the entry cap limit writes. The page polls every 2–3 s with an indexed cursor.
  • Sensitive content: the feed shows code and command output. It is limited to the run page's audience, masked with Redactor, and purged with the rest.
  • API model messages may only appear per step, or at the end, if laravel/ai exposes no step hook. Tool calls stay live either way.
  • Renaming the fragment route affects every link to the old fragment; only show.blade.php and the tests use it.

The implementation plan is ready for validation. Add the ai::validate_plan label to let me start.

## Plan # Live activity page for runs (`/runs/{id}/live`) ## What exists today - **Routes and controller.** `routes/web.php:88-90` and `app/Http/Controllers/RunController.php`. **`GET /runs/{run}/live` is already taken** by the auto-refreshing fragment of the run page (`runs.live` → `runs/partials/live.blade.php`, polled by `resources/js/live.js`). The issue requires that URL for the new page, so the fragment moves (see step 6). - **Command-line agents.** `app/Llm/Local/LocalAgentAdapter::invoke()` makes one blocking `ContainerRuntime::exec()` call, and stdout is only read when it returns. Containers use `Process::run()` in `app/Sandbox/CliContainerRuntime.php:89`. The host runner uses `Process::start()` plus a polling loop in `app/Sandbox/HostRuntime.php:190`. - Codex (`--json`), OpenCode (`--format json`), Copilot (`--output-format json`, JSON lines) and Grok Build (`streaming-json`) already print one JSON event per line as they work. - Claude Code uses `--output-format json`, which prints a single object at the end. - **API runs.** `app/Llm/Api/ApiLlmAdapter` calls `$agent->prompt()` once. Each tool call goes through `ToolContext::run()`, which records a `ToolCallRecord`. These records are written to `llm_tool_calls` only after the run, in `RunExecutor::recordResult()`. - **Where the run executes.** Runs execute in queue workers, and the page is served by the web process. So the database is the only channel between them, and the plan uses it. - **Other building blocks.** The retention purge is `app/Jobs/PurgeRetention.php`. Secret masking is `app/Logging/Redactor.php`. Browser tests use the Pest browser plugin (`tests/Browser/...`, Playwright under the hood). ## Approach ### 1. Storage: a `run_activities` table - **Migration** `create_run_activities_table` with these columns: - `id`, `organization_id`, `run_id` (FK, cascade on delete), `llm_session_id` (nullable) - `sequence` (unsigned int, unique together with `run_id`) - `kind` (string) - `title` (string ≤ 255: the tool name, the file path, or the command) - `body` (text, nullable) - `occurred_at` - Index on (`run_id`, `sequence`). - **Model** `App\Models\RunActivity` using the `BelongsToOrganization` concern, plus a factory. - **Enum** `App\Enums\ActivityKind` with the values `message`, `tool_call`, `tool_result`, `file_read`, `file_edit`, `command`, `error`. Each value has a `label()` used for the badge, which covers criterion 3 ("labelled by kind"). - **Value object** `App\Llm\Data\ActivityEntry` (readonly: kind, title, body). ### 2. Recording: `App\Runs\Activity\ActivityRecorder` - Built per run by `RunExecutor` (run id, session id, organization id) and passed to the adapter in a new optional `LlmRequest::$activity` (`?ActivitySink`, a small interface with `record(ActivityEntry ...$entries)`). `SmokeTest` passes nothing. - **Write path:** - Entries are masked with `Redactor`, and the run's credentials are registered as secrets too. - `body` is cut to `alfred.llm.activity_excerpt_chars` (default 4000). - Rows are batch-inserted when the buffer is flushed: at most every 500 ms, or every 50 entries. - The recorder stops after `alfred.llm.activity_max_entries` (default 2000) and writes one final `error` entry saying the feed was truncated. - **Failures never affect the run (criterion 11).** Every recorder error is caught and logged, and the run continues. - `RunExecutor::invokeInRunner()` flushes the recorder in a `finally` block. ### 3. Live output from the runners - **Contract.** Add `?Closure $onOutput = null` (receives stdout chunks) as the last parameter of `ContainerRuntime::exec()`. Pass it through in `RunnerRuntime` and `NoContainerRuntime`. - **`CliContainerRuntime`:** `$pending->run($argv, fn ($type, $chunk) => $type === 'out' && $onOutput?->__invoke($chunk))`. - **`HostRuntime`:** inside the existing `while ($process->running())` loop, forward `$process->latestOutput()`, then drain what remains after the loop. - **`FakeContainerRuntime`:** scripted results can declare chunks that are sent to the callback before the run returns. - `ExecResult` keeps holding the full stdout, so text, outcome, usage and exit code are worked out exactly as today. ### 4. Turning CLI events into entries - **`LocalAgentAdapter`:** - A line buffer (`App\Llm\Local\JsonLineStream`) splits chunks into complete lines and decodes each JSON event. - A new protected method `activity(array $event): list<ActivityEntry>` (default `[]`) is called for each event, and its result goes to `$request->activity`. - Parsing stays inside each adapter, next to the existing `extractText()`. These adapters already depend on each CLI's event format. - **What each adapter maps:** - **Claude Code:** `assistant` text → message. `tool_use` → Read / Glob / Grep become `file_read`, Edit / Write / MultiEdit become `file_edit`, Bash becomes `command`. `tool_result` → result. - **Codex:** `item.completed` with `agent_message` / `command_execution` / `file_change` / `reasoning`. - **OpenCode:** `text`, `tool_use` parts. - **Copilot:** `assistant.message`, tool events. - **Grok Build:** `text` chunks merged into one message per run of text, plus `tool_call` / `tool_call_update`. - Unknown events are ignored. - **Claude Code switches to `--output-format stream-json --verbose`.** - `extractText()` and `usage()` read the final `{"type":"result"}` line, which has the same fields as today's single JSON object. - A new hook `rawOutput(ExecResult)` (default: stdout) returns only that result line for Claude Code. `raw_output` therefore keeps its current shape (criterion 11). - `mapOutcome()` and the auth/rate-limit patterns still read the full output. ### 5. API runs - `ToolContext` receives the activity sink. - Before running a tool, `run()` records a `tool_call` entry: tool name, a summary of the arguments (path, command…), mapped to `file_read` / `file_edit` / `command` by tool name. - After it, `run()` records a `tool_result` entry with the result excerpt and success. - **Model messages:** the assistant text of each step is recorded as soon as that step finishes. Before implementing, check the current `laravel/ai` docs (context7) for per-step events or the agent middleware / step callbacks. - If they are available, record from them. - Otherwise, record each step's text from `$response->steps` when `prompt()` returns. The tool calls stay live. - The `llm_tool_calls` write in `recordResult()` is unchanged. ### 6. Routes, controller, views - **Routes:** - The existing fragment moves to `GET /runs/{run}/fragment` (`runs.fragment`). Update `show.blade.php:32` and its tests. - `GET /runs/{run}/live` → `RunController@live` (`runs.live`): the full page. - `GET /runs/{run}/live/feed?after={sequence}` → `RunController@feed` (`runs.live.feed`): an HTML fragment containing only the entries after the cursor, plus the run state. - **Access:** both routes call `CurrentOrganization::abortUnlessOwns()` and `Gate::authorize('view', $run)`, the same checks as `show` (criterion 9: same 404 / redirect to login). - **Views:** - `resources/views/runs/live.blade.php`: back link to the run, the state badge, and an ordered list `<ol data-feed>`. - `resources/views/runs/partials/feed.blade.php`: entries as `<li data-sequence data-kind>`, each with a kind badge, a title, and the body in an escaped `<pre>` (never raw HTML). - Messages for a pending run ("The run has not started yet."), for no activity, for content removed by retention, and the final status once the run is finished. When finished, `data-live-finished="true"` is set. - No form or input on the page (criterion 5). - **Button on `show.blade.php`:** a "Watch live" link next to "Re-run", shown for every run (criterion 1). - **JavaScript:** extend `resources/js/live.js` with an append mode, so the existing replace mode is unaffected. - Elements with `data-live-mode="append"` request `url?after=<last data-sequence>` and append the returned `<li>`s. - They update the status header and stop when `[data-live-finished="true"]` appears. - **Finished runs:** the controller renders the full captured feed and no polling URL (criterion 8). ### 7. Retention and docs - `PurgeRetention` sets `run_activities.body = null` (and `title` for commands and files) for purged sessions, in the same transaction. - Update `docs/specification.md` §27.2, §27.5 and §27.6 (new routes, renamed fragment, activity feed, retention). ## Tests - **Unit:** - Each `*AdapterTest`: `activity()` mapping from recorded JSON-line samples. Claude Code `extractText()` / `usage()` / `rawOutput()` on stream-json output, with the result unchanged compared to today. - `JsonLineStream`: partial lines across chunks. - `ActivityRecorder`: redaction, truncation, max entries, batched flush, errors swallowed. - `CliContainerRuntimeTest` / `HostRuntimeProcessTest`: the callback receives chunks before the process ends, and the `ExecResult` is unchanged. - `FakeContainerRuntimeTest`: scripted chunks. - `ToolContext`: live entries for calls and results. - **Feature:** - `tests/Feature/Admin/Runs/RunLiveTest.php`: - The button appears on the run page. - Page states: pending, running, finished (each finished state). - The `after` cursor returns only newer entries. - The `data-live-finished` flag. - Access: another organization gets 404, guests are redirected to login, and no entry text appears in a denied response. - Purged content. - `RunScreensTest` updated for `/fragment`. - `RunExecutor` tests with `FakeContainerRuntime`: a local run and an API run, each on both the container and the host runner, write activity rows and leave `text_output` / `raw_output` / `outcome` unchanged. - `PurgeRetention` test. - **E2E (Pest browser / Playwright):** `tests/Browser/Runs/RunLiveTest.php`. - Open the run, click "Watch live", and land on `/runs/{id}/live`. - Insert activity rows and see them appear without a reload, in order. - Mark the run succeeded and see the final status, with polling stopped. - Open a pending run and see the not-started message, then the feed once it starts. - **CI:** browser tests already run in both `.github/workflows/ci.yml` and `.forgejo/workflows/ci.yml` (and `.gitlab-ci.yml`). Check that the new test directory is picked up. - **Manual check:** with chrome-devtools, open the live page during a real local run. ## Risks - **CLI event formats change between versions.** Parsing is best-effort: unknown events are ignored and never fail the run. The tests use real sample lines. - **Claude Code `stream-json` needs `--verbose`** with `-p`, and the new flag must work with the supported version range. Check this against `claude --help` for 2.x. - **Database load:** batching, the excerpt cap and the entry cap limit writes. The page polls every 2–3 s with an indexed cursor. - **Sensitive content:** the feed shows code and command output. It is limited to the run page's audience, masked with `Redactor`, and purged with the rest. - **API model messages may only appear per step, or at the end**, if `laravel/ai` exposes no step hook. Tool calls stay live either way. - **Renaming the fragment route** affects every link to the old fragment; only `show.blade.php` and the tests use it. The implementation plan is ready for validation. Add the ai::validate_plan label to let me start. <!-- agent:key=37842f021bc9:kind=plan -->
Owner

Plan validated by @SebastienDenooz. Starting the development.

Plan validated by @SebastienDenooz. Starting the development. <!-- agent:key=7f08aa13b1c1:kind=plan_validated -->
Owner

The status label status::in_ci is managed by me: I restored it. Use the command labels to steer my work.

The status label status::in_ci is managed by me: I restored it. Use the command labels to steer my work. <!-- agent:key=655acb3da067:kind=status_label_restored -->
Alfred removed their assignment 2026-11-03 22:46:37 +00:00
Sign in to join this conversation.
No description provided.