Skip to content

Make the kcap agent commands flow-participant aware - #408

Merged
alexeyzimarev merged 15 commits into
mainfrom
ai1557-flow-participant-aware
Jul 30, 2026
Merged

alexeyzimarev merged 15 commits into
mainfrom
ai1557-flow-participant-aware

Conversation

@alexeyzimarev

Copy link
Copy Markdown
Member

Closes #379
AI-1557

kcap agent ls|attach|stop treated every daemon-hosted agent identically, so a review-flow participant — a reviewer mid-round — was indistinguishable from an agent you started. attach handed you raw PTY stdin on it, and stop --all killed it silently. This teaches the commands the difference.

What changed

kcap agent ls gains a KIND column:

AGENT                              STATUS     KIND         REPO
3f9acd34ef56ab78cd90ef12ab34cd56   Running    agent        ~/dev/api
ab99887766554433221100aabbccddee   Running    review-flow  ~/dev/api   [reviewer]
ff00112233445566778899aabbccddee   Running    review       ~/dev/web

For review and review-flow agents:

  • attach is read-only. Output streams so you can watch a reviewer work; your keystrokes are not delivered, and your terminal size is not applied to it — a read-only viewer never enters ClientDims, so it cannot shrink the participants terminal through the min-clamp.
  • stop is refused unless --force, with a message naming the flow.
  • stop --all skips them and reports how many, rather than silently omitting them. --force includes them and lists them under their own heading in the confirmation, so the destructive variant shows more information than the safe one.

Enforcement is daemon-side for both, so a current client cannot bypass it.

Wire changes

FrameType is append-only. StopV2 = 10 carries a force flag; AttachedReadOnly = 71 carries id + reason + snapshot. AgentList rows grow to id⇥status⇥repo⇥kind⇥flowRunId⇥flowRole; StopAck gains a skipped status.

AttachedReadOnly is a separate frame rather than a flag on Attached because Attacheds payload ends with an unbounded snapshot — a trailing flag would be painted onto the users terminal instead of parsed. It also fails closed: an older CLI cannot decode frame 71, so it errors rather than pumping stdin at a participant.

Version skew — please read

  • Older daemon: it reports no kind, so ls/attach degrade silently to unprotected. stop does not degrade — it hard-fails, because the CLI sends StopV2 which that daemon cannot decode, and tells you to restart it.
  • Older CLI: it sends the legacy Stop frame, which has no force concept, so the daemon treats it as --force. An old client can therefore silently force-stop a protected agent. Frame 8 postdates v0.11.8, so no released client is affected — but it is a real hole and it is documented in the README rather than glossed.

Known limitations

  • --force still leaves the flow unaware its participant was stopped. Attributing that needs server-side work.
  • Plain web-UI-launched agents (LaunchKind.Default) are deliberately not protected — they are your own work by another route.

Verification

  • Unit 4237 passed / 42 failed on this branch; those 42 are the pre-existing CodexHookCommandTests/uninstall-config.toml baseline confirmed at the merge base. Zero new.
  • Integration 160/160 on the branch.
  • Trial-merged onto current main (13 commits ahead) and tested there: plain main fails 45 unit tests, the merged result fails the same 45 with 21 more tests; integration 162/162 solo. So the merge is semantically clean, not merely conflict-free — a check worth doing explicitly after Group the agent commands under kcap agent #383 auto-merged cleanly into a guard that shadowed it.
  • Both AOT publishes clean of IL3050/IL2026.

Design and plan

  • docs/superpowers/specs/2026-07-29-ai1557-flow-participant-aware-agent-commands-design.md
  • docs/superpowers/plans/2026-07-29-ai1557-flow-participant-aware-agent-commands.md

🤖 Generated with Claude Code

alexeyzimarev and others added 14 commits July 29, 2026 16:30
Protects review and review-flow agents from accidental attach-injection and
stop, enforced daemon-side. Read-only attach, stop refuses without --force,
stop --all skips and says so.

Refs AI-1557, #379.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Also corrects the spec's Attached mechanism: its payload ends with an
unbounded snapshot, so a trailing flag would be painted onto the terminal
rather than parsed. Uses a separate AttachedReadOnly frame instead, which
also fails closed against an older client.

Refs AI-1557, #379.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…delivery

Attaching_to_a_flow_participant_is_read_only previously only asserted the
frame type and empty ClientDims — neither observes the Stdin arm, so
NoopPtyProcess silently swallowed writes and the test stayed green even
with the readOnly guard deleted. Seed the agent with a recording PTY
double instead and assert zero writes; add the mirror assertion to the
plain-agent case so the pair brackets the behaviour.
Except hashed AgentInstance's mutable teardown fields (Status, LastOutputAt,
...) rather than identity, and was enumerated after the concurrent stops had
already started mutating them — a stopped agent could be misreported as
skipped too, with a duplicate row in the ack. Also corrects the refusal
message for a plain (non-flow) review agent, which previously claimed a
flow round it doesn't have.
Switches the client onto the StopV2 frame the daemon has enforced protection
against since #378: `stop --all` now partitions review/review-flow agents out
of the confirmation prompt and reports them as skipped (exit 0) rather than
stopping everything unconditionally, and `--force` opts back in. Also pins
LocalControlServer's StopV2 decode-and-dispatch over a real socket, the one
hop the codec round-trip and handler tests don't individually cover.
The claim that daemon-side enforcement "holds regardless of client version"
was wrong: an old kcap sends the legacy Stop request (no --force concept),
and the daemon treats that as --force, so a stale client can silently
force-stop a review/review-flow agent against an up-to-date daemon. The
old-daemon sentence also conflated stop with ls/attach: those degrade
silently, but stop always sends the newer request format an old daemon
can't decode, so it hard-fails and tells the user to restart the daemon
instead of running unprotected. Also filled in two gaps in help-agent.txt's
parallel text: attach's read-only view also ignores terminal resize, and
--force lifts the single-id refusal, not just the --all skip.
…ommands

- stop --all --force now groups protected agents under their own labelled
  heading, matching the spec (the plan's snippet had it backwards; fixed
  the plan too so re-execution won't replay the defect)
- the PID-record stop path (a prior-incarnation survivor) now honours
  Kind-based protection instead of reaping unconditionally; the decision
  reads the record via a new FindPidRecord accessor, keeping
  TryStopByPidRecordAsync itself policy-free for its server-origin caller
- KindText/IsProtectedKind/ProtectionReason fail safe on an unrecognised
  LaunchKind instead of defaulting to unprotected
- FrameCodec.StopV2 guards against a zero-length payload
- ParseAgentRow and the ls fetch path restore the 3-column floor a stray
  tab in a repo path could otherwise defeat
- doc/help/test fixes: help-agent.txt synopsis gets [--force], stale
  ls/AgentList column docs updated, and the tautological --force
  dispatch test is supplemented with one that can actually fail

Closes #379, AI-1557
…evel help

The FindPidRecord extraction left the original XML summary attached to the new
method, so it claimed to "reap it by identity and delete the record on confirmed
death" — which it does not; it only reads. Moved back onto
TryStopByPidRecordAsync, which does do that and was left undocumented.

help-usage.txt still described `agent ls` as (id, status, repo); it grew a KIND
column. This was the fourth instance of that stale text, the other three having
been corrected already.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Make kcap agent commands aware of review and review-flow participants

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Add agent KIND/flow identity to kcap agent ls via extended AgentList payload.
• Enforce daemon-side protections: read-only attach, stop refusal unless --force.
• Introduce StopV2/AttachedReadOnly frames and expand tests/docs for version-skew behavior.
Diagram

graph TD
  U["User terminal"] --> CLI["kcap CLI"] --> LCS["LocalControlServer"] --> ORCH["AgentOrchestrator"] --> RT["Agent runtime / PTY"]
  ORCH --> AL["AgentList (kind/flow)"]
  ORCH --> RO["AttachedReadOnly (viewer)"]
  ORCH --> SA["StopAck (stopped/skipped)"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Client-side protection only (filter kinds in CLI)
  • ➕ No wire protocol changes (no StopV2 / AttachedReadOnly).
  • ➕ Simpler daemon implementation and fewer compatibility concerns.
  • ➖ Not enforceable: older/stale/hand-rolled clients could still inject stdin or stop participants.
  • ➖ Doesn’t solve the core safety issue when multiple clients interact with the daemon.
2. Refuse attach entirely for protected agents
  • ➕ Eliminates any chance of user confusion about interactivity.
  • ➕ Even simpler daemon attach loop (no read-only mode).
  • ➖ Removes a legitimate debugging/observability workflow (watching a reviewer/flow participant).
  • ➖ Doesn’t address 'stop --all' safety on its own; still needs stop protection.
3. Introduce explicit daemon/client version negotiation
  • ➕ Could avoid silent degrade when talking to an older daemon (kinds missing).
  • ➕ Enables more nuanced compatibility logic long-term.
  • ➖ Adds more protocol surface and upgrade complexity than needed for this feature.
  • ➖ Still requires new message types; handshake becomes yet another skew point.

Recommendation: Keep the PR’s daemon-enforced approach: it correctly treats review/review-flow agents as protocol-owned and prevents stdin/stop hazards even if the CLI is modified. The chosen wire changes (StopV2 + AttachedReadOnly) are appropriately minimal and designed to fail-closed for attach, while preserving legacy Stop behavior for older clients.

Files changed (18) +1855 / -79

Enhancement (7) +263 / -58
FrameCodec.csEncode/decode StopV2 and AttachedReadOnly structured payloads +34/-2

Encode/decode StopV2 and AttachedReadOnly structured payloads

• Extends codec pre-encoded frame handling and adds helpers for StopV2 (force flag + lp agentId) and AttachedReadOnly (lp id + lp reason + snapshot tail). Ensures StopV2 validates minimum payload size.

src/Capacitor.Cli.Core/LocalIpc/FrameCodec.cs

FrameType.csAdd StopV2 and AttachedReadOnly frame types and update comments +4/-2

Add StopV2 and AttachedReadOnly frame types and update comments

• Introduces new frame IDs (StopV2=10, AttachedReadOnly=71) while keeping FrameType append-only. Updates AgentList and StopAck comments to reflect new columns/statuses.

src/Capacitor.Cli.Core/LocalIpc/FrameType.cs

LocalFrame.csExpose LocalFrame.StopV2 factory +1/-0

Expose LocalFrame.StopV2 factory

• Adds a StopV2 factory method delegating to FrameCodec to simplify callers. Leaves legacy Stop/StopAck unchanged for backward compatibility paths.

src/Capacitor.Cli.Core/LocalIpc/LocalFrame.cs

AgentOrchestrator.LocalIpc.csDaemon enforces protected attach and stop behavior; emit kind/flow fields +107/-16

Daemon enforces protected attach and stop behavior; emit kind/flow fields

• Extends local agent listing to include kind, flowRunId, and flowRole. Adds HandleLocalStopV2Async enforcing refusal/skipping of non-default agents unless forced, including for PID-record survivors. Makes protected attaches read-only by sending AttachedReadOnly and dropping stdin/resize while avoiding ClientDims participation.

src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.LocalIpc.cs

LocalControlServer.csRoute StopV2 frames to orchestrator +7/-1

Route StopV2 frames to orchestrator

• Adds a FrameType.StopV2 switch arm that decodes (force, id) and calls HandleLocalStopV2Async. Updates the default error message to include StopV2 among expected frames.

src/Capacitor.Cli.Daemon/Services/LocalControlServer.cs

AgentCommand.csCLI: parse/show KIND, implement 'stop --force', and report skipped +93/-35

CLI: parse/show KIND, implement 'stop --force', and report skipped

• Expands AgentRow to include kind/flow fields and adds a tolerant parser for older daemons. 'ls' renders a KIND column and flow role, and 'stop' now supports '--force', sends StopV2, groups the confirmation prompt, and reports 'skipped' separately from failures.

src/Capacitor.Cli/Commands/AgentCommand.cs

LocalAgentClient.csHandle AttachedReadOnly and disable stdin/resize when read-only +17/-2

Handle AttachedReadOnly and disable stdin/resize when read-only

• Adds handling for AttachedReadOnly: prints a read-only banner, replays the snapshot, and avoids sending the SizeFrame repaint nudge. Disables stdin forwarding and resize frames while keeping the detach sequence functional.

src/Capacitor.Cli/Local/LocalAgentClient.cs

Refactor (1) +13 / -5
AgentOrchestrator.csExtract PID record lookup for stop protection and reuse +13/-5

Extract PID record lookup for stop protection and reuse

• Adds FindPidRecord helper to look up persisted PID records without policy. Refactors TryStopByPidRecordAsync to use this helper and makes PID record deletion null-safe.

src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

Tests (5) +368 / -3
AgentVerbDispatchTests.csAdd integration coverage for 'stop --force' argument parsing +22/-0

Add integration coverage for 'stop --force' argument parsing

• Adds tests ensuring '--force' is treated as a flag (not a positional agent id) under '--all', and that '--force' alone still triggers usage. This guards against CLI parsing regressions independent of daemon behavior.

test/Capacitor.Cli.Tests.Integration/AgentVerbDispatchTests.cs

AgentCommandRoutingTests.csUnit test for partitioning protected agents during stop confirmation +14/-0

Unit test for partitioning protected agents during stop confirmation

• Adds a test validating that review/review-flow agents are separated into the protected group for 'stop --all' prompt rendering. Exercises the new PartitionByProtection helper behavior.

test/Capacitor.Cli.Tests.Unit/AgentCommandRoutingTests.cs

AgentIdResolutionTests.csExtend ID resolution tests for kind/flow row parsing and protection +32/-3

Extend ID resolution tests for kind/flow row parsing and protection

• Updates static agent fixtures for the widened AgentRow. Adds tests verifying parsing of 6-column rows, defaulting behavior for 3-column (old daemon) rows, and protection classification for review kinds.

test/Capacitor.Cli.Tests.Unit/AgentIdResolutionTests.cs

AgentOrchestratorLocalAttachTests.csAdd daemon-side attach/stop protection tests (including real socket StopV2) +268/-0

Add daemon-side attach/stop protection tests (including real socket StopV2)

• Introduces extensive coverage for: AgentList emitting kind/flow columns; read-only attach dropping stdin/resize and not entering ClientDims; StopV2 refusal/skipping behavior; and end-to-end StopV2 routing through LocalControlServer over a real Unix socket. Adds parallelism guards for shared socket-path overrides.

test/Capacitor.Cli.Tests.Unit/AgentOrchestratorLocalAttachTests.cs

FrameCodecTests.csAdd StopV2 and AttachedReadOnly round-trip codec tests +32/-0

Add StopV2 and AttachedReadOnly round-trip codec tests

• Adds round-trip tests validating StopV2 force/id encoding and AttachedReadOnly id/reason/snapshot encoding (including empty snapshot). Ensures codec behavior is pinned for the new wire payload shapes.

test/Capacitor.Cli.Tests.Unit/FrameCodecTests.cs

Documentation (5) +1211 / -13
README.mdDocument KIND column and protected review/review-flow behavior +9/-2

Document KIND column and protected review/review-flow behavior

• Updates 'kcap agent' docs to explain the new KIND column and daemon-enforced protections. Adds explicit version-skew notes (older CLI vs older daemon) and clarifies stop/attach semantics for review agents.

README.md

2026-07-29-ai1557-flow-participant-aware-agent-commands.mdAdd detailed implementation plan for flow-participant-aware agent commands +1035/-0

Add detailed implementation plan for flow-participant-aware agent commands

• Introduces a step-by-step plan covering protocol, daemon, CLI, tests, docs, and AOT verification. Captures constraints (append-only FrameType, read-only attach rules, baseline test failures) and acceptance checks.

docs/superpowers/plans/2026-07-29-ai1557-flow-participant-aware-agent-commands.md

2026-07-29-ai1557-flow-participant-aware-agent-commands-design.mdAdd design spec describing protections, protocol changes, and skew +147/-0

Add design spec describing protections, protocol changes, and skew

• Defines motivation, decisions, and wire protocol additions (StopV2, AttachedReadOnly, AgentList/StopAck extensions). Documents daemon-side enforcement, client behavior, and explicit version-skew tradeoffs.

docs/superpowers/specs/2026-07-29-ai1557-flow-participant-aware-agent-commands-design.md

help-agent.txtUpdate 'agent' help for KIND, read-only attach, and 'stop --force' +18/-9

Update 'agent' help for KIND, read-only attach, and 'stop --force'

• Refreshes usage text to include KIND in 'ls', adds '--force' option description, and documents behavior for review/review-flow agents (read-only attach; stop refusal without force). Removes the old #379 warning block in favor of real behavior.

src/Capacitor.Cli.Core/Resources/help-agent.txt

help-usage.txtUpdate top-level usage lines for KIND and '--force' +2/-2

Update top-level usage lines for KIND and '--force'

• Adjusts summary usage strings to reflect the new KIND column and 'agent stop ... --force' option. Keeps the rest of the usage structure intact.

src/Capacitor.Cli.Core/Resources/help-usage.txt

@qodo-code-review

qodo-code-review Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. AgentList delimiter injection ✓ Resolved 🐞 Bug ≡ Correctness
Description
The daemon emits RepoPath/flow fields verbatim inside a tab+newline delimited AgentList table,
while the CLI parses rows via naive Split('\t') and uses the parsed Kind to decide which agents
are "protected" for the stop --all confirmation. If any emitted field contains a tab/newline, the
CLI can mis-parse columns/rows and misclassify agents, making the confirmation prompt and
skip/include grouping inaccurate relative to daemon-side behavior.
Code

src/Capacitor.Cli/Commands/AgentCommand.cs[R360-365]

+            // A row needs at least id/status/repo — fewer columns is unparseable, not a short row
+            // to default-fill (a repo path containing a stray tab could otherwise shift the kind
+            // column and mislabel an agent).
            return [.. resp.Text.Split('\n')
-                .Select(l => l.Split('\t'))
-                .Where(p => p.Length == 3)
-                .Select(p => new AgentRow(p[0], p[1], p[2]))];
+                .Where(l => l.Length > 0 && l.Split('\t').Length >= 3)
+                .Select(ParseAgentRow)];
Evidence
The daemon constructs AgentList rows by interpolating a.RepoPath, a.FlowRunId, and a.FlowRole
directly into a tab-separated line and joins rows with \n, so any embedded \t/\n in those
fields will corrupt the table framing. The CLI then filters/accepts rows based on
Split('\t').Length >= 3 and parses fixed indices into Kind, which is then used to partition
agents for the stop --all confirmation prompt—making the prompt/grouping potentially incorrect if
framing is corrupted.

src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.LocalIpc.cs[8-13]
src/Capacitor.Cli/Commands/AgentCommand.cs[360-365]
src/Capacitor.Cli/Commands/AgentCommand.cs[456-478]
src/Capacitor.Cli/Commands/AgentCommand.cs[171-194]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`AgentList` is a tab/newline delimited text table, but the daemon interpolates `RepoPath`, `FlowRunId`, and `FlowRole` without escaping. The CLI then parses rows with `Split('\t')` and uses the parsed `Kind` to partition agents for `stop --all` confirmation (`IsProtectedKind`/`PartitionByProtection`). Any tab/newline in those emitted fields can shift columns or create extra rows, causing misleading listings and (more importantly) an inaccurate confirmation prompt/blast radius.

### Issue Context
- Daemon produces `AgentList` as: `id\tstatus\trepo\tkind\tflowRunId\tflowRole` joined with `\n`.
- CLI accepts rows with `Split('\t').Length >= 3` and then indexes fixed positions for `Kind` and flow columns.
- `stop --all` uses parsed `Kind` to decide what’s "protected" and thus what appears in the confirmation prompt.

### Fix Focus Areas
- **Preferable (robust):** replace the AgentList text table with a structured/binary payload (e.g., length-prefixed fields) so paths/roles cannot corrupt framing.
- **Minimal mitigation (still recommended):**
 - Daemon-side: escape/sanitize `\t` and `\n` (and potentially `\r`) in `RepoPath`, `FlowRunId`, `FlowRole` before emitting.
 - CLI-side: make parsing strict (e.g., accept *only* 3-column legacy rows or exactly 6-column current rows). If a row has an unexpected column count, fail the operation (especially for `stop --all`) rather than mis-parsing.
 - Add a unit test covering a repo path with a tab/newline to ensure the CLI fails closed (or the daemon escapes it).

- src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.LocalIpc.cs[8-13]
- src/Capacitor.Cli/Commands/AgentCommand.cs[360-365]
- src/Capacitor.Cli/Commands/AgentCommand.cs[456-478]
- src/Capacitor.Cli/Commands/AgentCommand.cs[171-194]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread src/Capacitor.Cli/Commands/AgentCommand.cs Outdated
`AgentList` is a tab/newline-delimited table, and RepoPath, FlowRunId and
FlowRole are free-form — a repo path may legally contain a tab or newline. Emitted
raw, one shifts the reader's columns or splits the row.

That is not merely cosmetic. The CLI keys `stop --all`'s confirmation off the kind
column, so a shifted row makes a plain agent read as protected: the prompt says
"Skipping 1 review agent", the user confirms N, and the daemon — which computes
eligibility itself and is authoritative — stops N+1. The confirmation understates
the blast radius, which is the property the prompt exists to convey.

Closed at both layers. The daemon replaces tab/CR/LF with a space in the three
free-form cells. The CLI accepts only exactly 3 columns (older daemon) or exactly
6 (current) and refuses the whole table otherwise, rather than acting on a guess.

Found by Qodo on #408. Two earlier reviews rated this class Minor on the grounds
that the daemon stays authoritative for the stop — true, but it is the prompt that
the user consents to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alexeyzimarev

Copy link
Copy Markdown
Member Author

Fixed in 0501bf0. @qodo-code-review is right, and the finding is slightly sharper than my own reviews had it.

Two earlier review passes rated this class Minor because the daemon computes eligibility itself and is authoritative for the stop — so a mis-parsed row cannot cause a wrong stop. That reasoning holds for the decision but not for the confirmation prompt, which is what the user actually consents to.

Traced concretely: with a tab in a repo path, p[3] becomes a path fragment, and since IsProtectedKind is deliberately fail-safe (kind is not "agent"), a plain agent reads as protected. The CLI drops it from targets, prints "Skipping 1 review agent(s)", prompts Stop N?, and sends StopV2(force: false, ""). The daemon then sees Kind == Default and stops it. The user confirms N and N+1 die — the prompt understates the blast radius, which is exactly the property it exists to convey. (The newline case is milder: a row fragment makes a protected agent read as stoppable, and the daemon correctly skips it.)

Closed at both layers, per the "preferable + minimal mitigation" split in the suggestion:

  • Daemon (AgentOrchestrator.LocalIpc.cs): a Cell() helper replaces tab/CR/LF with a space in the three free-form fields (RepoPath, FlowRunId, FlowRole). Id, Status and Kind are all constrained values and need no escaping.
  • CLI (AgentCommand.FetchAgentsAsync): accepts only exactly 3 columns (older daemon) or exactly 6 (current), and refuses the whole table with a clear error otherwise rather than acting on a guess. This replaces the previous >= 3 floor, which admitted shifted rows.

I did not take the full structured/length-prefixed payload option. The daemon is the only producer, sanitisation makes every reachable case safe, and the strict column check fails closed on anything else — so the remaining benefit is bounded while the cost is a wire-format change to a frame in the same unreleased PR.

New test: Local_list_neutralises_delimiters_inside_a_free_form_field seeds a review-flow agent whose flowRunId and flowRole carry a tab and a newline, and asserts the emitted table is one row of exactly six columns with the kind column intact. I did not add a unit test for the CLI-side strictness — FetchAgentsAsync is socket-bound with no seam, and I would rather say so than add a test that asserts nothing.

Verification: unit 4238 passed / 42 failed (the pre-existing CodexHookCommandTests/uninstall baseline, zero new), integration 160/160, both AOT publishes clean.

@qodo-code-review

Copy link
Copy Markdown

Fixed in 0501bf0. @qodo-code-review is right, and the finding is slightly sharper than my own reviews had it.

Two earlier review passes rated this class Minor because the daemon computes eligibility itself and is authoritative for the stop — so a mis-parsed row cannot cause a wrong stop. That reasoning holds for the decision but not for the confirmation prompt, which is what the user actually consents to.

Traced concretely: with a tab in a repo path, p[3] becomes a path fragment, and since IsProtectedKind is deliberately fail-safe (kind is not "agent"), a plain agent reads as protected. The CLI drops it from targets, prints "Skipping 1 review agent(s)", prompts Stop N?, and sends StopV2(force: false, ""). The daemon then sees Kind == Default and stops it. The user confirms N and N+1 die — the prompt understates the blast radius, which is exactly the property it exists to convey. (The newline case is milder: a row fragment makes a protected agent read as stoppable, and the daemon correctly skips it.)

Closed at both layers, per the "preferable + minimal mitigation" split in the suggestion:

  • Daemon (AgentOrchestrator.LocalIpc.cs): a Cell() helper replaces tab/CR/LF with a space in the three free-form fields (RepoPath, FlowRunId, FlowRole). Id, Status and Kind are all constrained values and need no escaping.
  • CLI (AgentCommand.FetchAgentsAsync): accepts only exactly 3 columns (older daemon) or exactly 6 (current), and refuses the whole table with a clear error otherwise rather than acting on a guess. This replaces the previous >= 3 floor, which admitted shifted rows.

I did not take the full structured/length-prefixed payload option. The daemon is the only producer, sanitisation makes every reachable case safe, and the strict column check fails closed on anything else — so the remaining benefit is bounded while the cost is a wire-format change to a frame in the same unreleased PR.

New test: Local_list_neutralises_delimiters_inside_a_free_form_field seeds a review-flow agent whose flowRunId and flowRole carry a tab and a newline, and asserts the emitted table is one row of exactly six columns with the kind column intact. I did not add a unit test for the CLI-side strictness — FetchAgentsAsync is socket-bound with no seam, and I would rather say so than add a test that asserts nothing.

Verification: unit 4238 passed / 42 failed (the pre-existing CodexHookCommandTests/uninstall baseline, zero new), integration 160/160, both AOT publishes clean.

Confirmed — 0501bf0 addresses finding 1 at both layers:

  • The daemon sanitizes free-form fields before emitting agent-list rows.
  • The CLI now accepts only valid 3- or 6-column rows and fails closed on malformed data.
  • The regression test covers tab/newline delimiter injection and preserves the protected-kind column.

The verification results also look consistent with the fix: integration and AOT checks pass, with only the documented pre-existing unit-test failures remaining.

@alexeyzimarev
alexeyzimarev merged commit 2106c0e into main Jul 30, 2026
6 checks passed
@alexeyzimarev
alexeyzimarev deleted the ai1557-flow-participant-aware branch July 30, 2026 12:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

kcap agent commands should be aware of flow participants

1 participant