You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Let the connector do the work itself, on the coding agent you choose - #1026
basecamp connect -P <profile> --orchestrator <agent> makes the connector do the work itself, with no session reading its output. Each trusted request goes to a headless session of the coding agent named. That session follows the basecamp-connect skill as it ships and hands the work to a worker, a session of its own, which replies in Basecamp as the agent.
Without the new flags the connector behaves as it does today: it prints request lines for the session that started it. connect stays hidden.
Why
The skill needs a Claude Code session left open, because it relies on two things that harness has: a monitor that wakes the session for each request line, and background subagents. The connector now supplies both from the CLI, so the same Agent Skill runs on other coding agents and keeps running with no session open.
How it is put together
internal/connector/runner (new). An adapter per coding agent CLI (the arguments of a headless turn and how its event stream reads), the orchestrator loop, the workers, the state file and a unix socket. There are three built-in adapters; a new one is a new adapter plus a stream fixture.
HandoffOptions.Deliver. The handoff gives the runner each request after writing its line and before closing its record, so a crash repeats a request rather than losing it, provided the connector starts again within the handoff's grace of a minute. Nil for every run without the new flags.
Commands the orchestrator runs, over the socket: connect dispatch, workers list|send|stop, say, stop, restart.
runner.json, beside connect.json: rules that choose a worker's coding agent, model and effort by repo, project, card table, to-do list or Ping, and what the connector was last started with (--run reuses it).
The skill.SKILL.md gains one section at the end for this path, and its opening no longer says the connector requires Claude Code. subagent.md is unchanged. The runner gives both files to its agents verbatim and adds only a table mapping the mechanisms they name onto its commands.
Worth a reviewer's attention
Permissions.--permissions full is the default: each CLI is started with its permission prompts off, because a headless turn has nobody to answer one. guarded starts each under its own unattended guardrails (Claude Code's auto mode; Codex's sandbox with automatic review) and refuses a CLI that has none.
The runner's commands inside a sandbox. An agent the connector starts is told which runner started it through two environment variables, the socket's path and the profile, so dispatch and the rest reach the socket without reading connect.json, whose ownership cannot be checked from inside a coding agent's sandbox. The check itself is unchanged. The socket is only used in a directory that is the person's own and closed to everyone else, which is what decides who may use it.
Follow-ups. A later request on the same card, to-do, message or chat goes to the worker that handled the first, in the same session. A new worker starts only when that session cannot take it.
Stops and crashes. A worker a stop interrupted is continued at the next start. A process a crash left running is ended by its recorded process group and confirmed gone before its session is continued, and a start that cannot confirm it does not start. While the connector's hold stands, no agent is started.
Verified
bin/ci on a clean checkout of this branch.
An opt-in test against the installed CLIs (go test -tags runner_live ./internal/connector/runner -run TestInstalled): a turn answers, a second turn continues the session, a session that no longer exists fails cleanly, and under guarded both Claude Code and Codex commit in a repo on a new and a resumed session.
Live, against a real Basecamp project on Linux, with Claude Code and Codex each as orchestrator and as worker, under full and guarded: a question answered in place, repo work with a worktree, commit and pushed branch, a follow-up continuing the same worker, a restart in the middle of a task, a rule choosing the worker by card table and by repo, and --run from kept settings.
The edited skill in a fresh Claude Code session still takes the original path: it started the plain connector, armed its monitor, and a background subagent replied.
Not verified
macOS.
Pings: every live run was narrowed to one project.
A pull request on a hosted remote: pushes went to a local one.
A run longer than a few minutes, and a hard kill of the connector outside the unit test.
The skill's model evals, which need an API key this was not run with.
The new skill section followed by a session that is not Claude Code.
The reason will be displayed to describe this comment to others. Learn more.
2 issues found across 26 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="internal/commands/connect_runner_test.go">
<violation number="1" location="internal/commands/connect_runner_test.go:170">
P3: Don't call `require` inside the `require.Eventually` condition. A failure there runs on the wrong goroutine and surfaces as a generic 20s timeout, and an unchecked type assertion panics the test binary. Use `require.EventuallyWithT` with `assert` calls, or poll in the test goroutine and assert the final state there.</violation>
</file>
<file name="internal/connector/runner/workers.go">
<violation number="1" location="internal/connector/runner/workers.go:293">
P2: `Send` can revive a stopped worker whose turn is still running, which starts a second turn on the same session. Reject stopped workers before appending the message.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Adds an optional headless runner so the connector can coordinate Basecamp work through Claude Code, Codex, or Pi without an open chat session. The existing print-only connector flow remains the default.
Changes:
Adds agent adapters, a persistent inbox, reusable worker sessions, and recovery handling.
Introduces runner control commands, routing rules, saved startup settings, and permission modes.
Embeds the connector skill and adds documentation, unit coverage, and opt-in live-agent tests.
[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
File
Description
skills/embed.go
Embeds the connector workflow and worker instructions.
skills/basecamp-connect/SKILL.md
Documents unattended operation and runner controls.
internal/connector/runner/workers.go
Implements worker dispatch, continuation, stopping, and reporting.
internal/connector/runner/turn.go
Runs agent processes and reads their output streams.
internal/connector/runner/store.go
Persists sessions, requests, inbox items, and workers.
internal/connector/runner/spec.go
Defines agent settings, permissions, and routing rules.
internal/connector/runner/spec_test.go
Tests configuration validation, precedence, and routing.
internal/connector/runner/socket.go
Provides the local runner control protocol.
internal/connector/runner/runner.go
Coordinates orchestration, delivery, recovery, and permissions.
internal/connector/runner/runner_test.go
Tests runner lifecycle, recovery, and session reuse.
internal/connector/runner/instructions.go
Maps skill mechanisms onto runner commands.
internal/connector/runner/installed_test.go
Adds opt-in tests against installed coding agents.
internal/connector/runner/fake_agent_test.go
Supplies scripted agents for runner tests.
internal/connector/runner/doc.go
Documents runner architecture and persistence.
internal/connector/runner/adapter.go
Implements Claude Code, Codex, and Pi adapters.
internal/connector/runner/adapter_test.go
Tests agent arguments and stream parsing.
internal/connector/handoff.go
Adds runner delivery before closing ledger records.
internal/connector/handoff_test.go
Tests delivery ordering and failure retries.
internal/commands/connect.go
Registers runner commands and expands connector help.
internal/commands/connect_runner.go
Adds runner flags, setup helpers, and control commands.
internal/commands/connect_runner_test.go
Tests runner settings and command integration.
internal/commands/connect_run.go
Integrates runner startup, logging, delivery, and shutdown.
internal/commands/connect_process_unix.go
Supports restarting through process replacement.
internal/commands/connect_process_other.go
Reports unsupported-platform restart behavior.
AGENTS.md
Documents runner development conventions and architecture.
.surface-skill-drift
Allows documented references to hidden runner commands.
The reason will be displayed to describe this comment to others. Learn more.
Not changed here, and the comments that overstated it are corrected in 20b40d0. The age guard is the connector's standing rule that a request older than a minute at a start is not run late, and it applies with a runner as it does without. What Deliver adds is narrower than the comments said: a request written but not yet kept when the connector crashed is handed off again by a start within that grace. Exempting undelivered records from the guard would run requests late after a long outage, which changes the handoff's contract and is better decided on its own. Leaving this thread open for a maintainer's call.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Unresolved critical defects can lose accepted requests after delivery failures, resume sessions alongside surviving processes, and allow symlink attacks to redirect connections to attacker-controlled sockets.
Restore worker state when saving a follow-up fails
internal/connector/runner/workers.go:219
[P2] If saving a follow-up fails, the reused worker keeps the appended event and queued state, but an idle worker never reaches r.start. Retrying then returns existing=true, leaving the request stranded; a running worker can instead consume a handoff reported as failed. Restore the worker's previous state before returning the save error.
Restore worker state when saving a message fails
internal/connector/runner/workers.go:319
[P2] A failed save leaves the new message and queued state in memory, but skips starting a finished worker. A retry sees it as live and also skips r.start, stranding both messages; a running worker can instead execute a send reported as failed. Restore the previous worker state and prompt slice before returning the error.
Treat execution failures as unknown before retrying
internal/connector/runner/workers.go:557
[P2] A timeout or execution error can happen after a worker posts a reply or pushes changes, so this report cannot claim that nobody replied. instructions.go:85 explicitly tells the orchestrator to redispatch these failures with --again, risking duplicate effects. Update both the report and that instruction to treat completion as unknown and require checking the thread and repository before retrying.
The reason will be displayed to describe this comment to others. Learn more.
1 issue found across 8 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="internal/connector/runner/runner.go">
<violation number="1" location="internal/connector/runner/runner.go:550">
P1: This checks the hold only before an orchestrator turn starts; a hold set during the turn does not stop or gate the running agent, which can continue dispatching or posting while the hold stands. Monitor hold changes during turns and stop or gate agent actions until release.</violation>
</file>
Reply with feedback, questions, or to request a fix.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Process recovery can resume sessions without confirming old commands have stopped, while unresolved workers remain reusable, risking overlapping execution and overwritten recovery records.
If saving a follow-up fails, the previous worker is left mutated in memory: it already contains this event and, if it was idle, is now queued, but r.start was never reached. Retrying the dispatch then finds the event in workerFor and returns success without starting anything, leaving the accepted request stuck. Restore the worker's previous state when the save fails, as the new-worker path already rolls back its insertion.
Roll back worker mutations when message save fails
internal/connector/runner/workers.go:319
A failed save leaves the appended message and queued state in memory even though Send reports failure. For a finished worker, no goroutine is started, and a retry now sees a live worker and skips r.start again, so it remains stuck until restart. For a running worker, the rejected message can still execute and be duplicated by a retry. Roll back the worker mutation before returning the save error.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Human assessment is needed because process-persistence and termination gaps can leave untracked agents running alongside retries, while save failures can also stall or duplicate work.
The handoff writes a trusted request to stdout and then closes its record,
which is all a reader of the lines needs. A run that does the work itself
needs the request kept somewhere of its own first: closed before it is kept,
a crash between the two loses a request that was already acknowledged.
HandoffOptions.Deliver is called after the line is written and before the
record is closed, and returns once the request is kept. A delivery that fails
leaves the record open, so the request is delivered again. Nil delivers
nowhere, which is every run today.
…gent's CLI
The basecamp-connect skill runs in a Claude Code session and leans on two
things only that harness has: a monitor that wakes the session for each
request line, and background subagents. The runner supplies both from the CLI,
so the same skill can run on any coding agent that takes a prompt without a
terminal, runs shell commands and continues an earlier session.
Each request the connector hands off is kept in the runner's state and given
to an orchestrator, one turn of a headless session at a time. The orchestrator
follows SKILL.md as it ships and hands the work to a worker, a session of its
own that follows subagent.md and reports back as another turn. The runner adds
only a table that maps the mechanisms the skill names onto its commands.
An adapter is one CLI: the arguments of a turn and how its event stream reads.
A turn is one process, started through driver.StartWorker as the leader of a
process group the runner can end whole.
- A later request on the same card, to-do, message or chat goes to the worker
that handled the first, continued in its session, and waits its turn if that
worker is busy. A new worker starts only when that session cannot take it.
- A worker a stop interrupted is continued at the next start and told so. A
process a crash left running is ended, by its recorded process group, before
its session is continued.
- runner.json, beside connect.json, chooses a worker's coding agent, model and
effort by repo, project, card table, to-do list, containing recording or
Ping, and keeps what the connector was last started with.
- Permissions are one switch. Full starts each CLI with its prompts off, since
a headless turn has nobody to answer one. Guarded starts each under its own
unattended guardrails, and refuses a CLI that has none.
The adapters are held to the installed CLIs by an opt-in test:
go test -tags runner_live ./internal/connector/runner -run TestInstalled
…ts use
basecamp connect -P <profile> --orchestrator <agent> does the work itself
instead of printing requests for a session to read. --worker names another
coding agent for the work, the model and effort flags choose those, and
--permissions chooses full or guarded. What a start names is kept for the
profile, and --run starts with it. Without any of these the connector is what
it was.
The orchestrator's tools are commands that reach the running connector over a
unix socket in its state directory: dispatch hands a request to a worker,
workers list, send and stop see and steer them, say talks to the orchestrator
from a terminal, and stop and restart end the connector or start it again.
An agent the connector starts is told which runner started it, so these
commands reach the socket from inside a coding agent's sandbox, where the
ownership of connect.json cannot be checked. The check is unchanged.
The skill said running the connector requires Claude Code. Watching it from a
session still does. A new section, after everything a session following the
original flow reads, says how to start the connector with an orchestrator
instead: which coding agent, that the choice is kept, that it has to be started
where it outlives the session, what full and guarded permissions mean, and the
commands for checking on it.
The per-request steps stay inside the compaction budget, and the new hidden
commands and flags are acknowledged in the drift baseline with the rest of
connect.
One request, one worker. Dispatch let go of its lock to read what a request
sits under and did not ask again, under the lock, whether the event had got a
worker meanwhile, so two dispatches arriving together could start two. A
worker also has one goroutine now, whatever it is told: a message sent just
after a stop, before the stopped turn had ended, started a second one on the
same session.
A leftover process is always ended. Starting with another orchestrator reset
the session and, with it, the record of a process the last run had left, which
then ran on beside the new one.
What never ran is not counted. A turn refused under guarded permissions
reached no agent, and no longer uses up one of a message's attempts. A `say`
that could not be saved is taken back out, so a retry is not answered twice.
Requests still waiting, or being worked on, are not pruned with the history. A
rule's relative repo is refused when the file is read, since it could never
match. What a request sits under is cached only when it was read, so a rule
added later still gets its answer, and a card on hold is followed to its
column and card table, which its On Hold section does not name.
The socket no longer gives up on the first failed accept, and a client that
sends half a request no longer holds a stop open. Standard input past its
limit is refused rather than cut. A dispatch waits as long as its lookups can
take. What a start names is kept once the start has its lock, not before.
A failed turn's stderr has the agent's environment taken out of it, and the
help's example and its claim about what is read again are corrected.
A hold means nothing is dispatched or posted until it is released. The
handoff honored it and the runner did not: started under a hold, it went on
giving its orchestrator the messages it already held and starting the workers
that were waiting. It now starts no agent while the hold stands, and takes up
where it was when the hold is released.
A process a crashed run left is ended before its session is continued. When it
could not be confirmed ended, the session was continued all the same, beside
whatever was still running. The recorded group now gets the rule's last step
as well, which ends what a vanished leader left behind, and a group that still
cannot be confirmed gone keeps its record: a worker is closed as failed, which
the orchestrator is told, and an orchestrator's stops the start.
The comments said a crash between writing a request and keeping it repeats the
request. That holds only for a start within the handoff's grace: an older
record is not run late, with a runner or without. They now say so.
A start now confirms each process a crash left is gone, after the signal
as well as when the leader had already gone, and one that cannot be
confirmed stops the start whichever session it was. A worker that was
only set aside could still be told more, or take a later request on its
thread, beside the process nobody ended.
The socket's directory is checked wherever it is listened on or called:
a real directory, this user's, closed to everyone else. The fallback for
a long path sits under XDG_RUNTIME_DIR when there is one, and an agent is
given the socket's path itself rather than the directory to derive it
from.
A failed lookup of a card's On Hold section is logged, and the accept
loop only reads the run's end as the reason for an error when there was
one.
A socket that cannot sit in the runner's directory, because the path is
too long for an address, went wherever the environment of the moment
said, so a command started with another environment looked somewhere
else and reported the running connector absent. The connector now keeps
where it listens beside its state, and a call reads that.
The runtime directory is used only when the whole path fits an address.
The directory check compares the effective user, as the connector's
other ownership checks do, and off Unix it refuses rather than passing
everything.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Critical gaps in process persistence, shutdown confirmation, and hold rechecking can leave agents untracked, allow overlapping processes, or start work while a hold remains active.
…saved
A start does not begin beside a process it cannot confirm ended. A turn
is now held to the same:
- The record of a turn's process goes only once nothing of its process
group is left. One that stays fails its worker, is asked after again
before that session's next turn, orchestrator or worker, and is the
next start's to end.
- An agent whose process could not be written to the state is ended
before it is told anything, and reported as one that could not run.
A worker continued after a stop is given what it was last on along with
the notice that it was interrupted: a stop just after its turn began may
have come before its session had the request.
A dispatch to a thread's earlier worker, or a message sent to a worker,
that could not be saved leaves the worker as it was, rather than queued
with nobody to run it.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Critical issues remain: pruning can erase recovery records for surviving processes, and agents can bypass an active hold after waiting for permission configuration to be repaired.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
basecamp connect -P <profile> --orchestrator <agent>makes the connector do the work itself, with no session reading its output. Each trusted request goes to a headless session of the coding agent named. That session follows the basecamp-connect skill as it ships and hands the work to a worker, a session of its own, which replies in Basecamp as the agent.Without the new flags the connector behaves as it does today: it prints request lines for the session that started it.
connectstays hidden.Why
The skill needs a Claude Code session left open, because it relies on two things that harness has: a monitor that wakes the session for each request line, and background subagents. The connector now supplies both from the CLI, so the same Agent Skill runs on other coding agents and keeps running with no session open.
How it is put together
internal/connector/runner(new). An adapter per coding agent CLI (the arguments of a headless turn and how its event stream reads), the orchestrator loop, the workers, the state file and a unix socket. There are three built-in adapters; a new one is a new adapter plus a stream fixture.HandoffOptions.Deliver. The handoff gives the runner each request after writing its line and before closing its record, so a crash repeats a request rather than losing it, provided the connector starts again within the handoff's grace of a minute. Nil for every run without the new flags.connect dispatch,workers list|send|stop,say,stop,restart.runner.json, besideconnect.json: rules that choose a worker's coding agent, model and effort by repo, project, card table, to-do list or Ping, and what the connector was last started with (--runreuses it).SKILL.mdgains one section at the end for this path, and its opening no longer says the connector requires Claude Code.subagent.mdis unchanged. The runner gives both files to its agents verbatim and adds only a table mapping the mechanisms they name onto its commands.Worth a reviewer's attention
--permissions fullis the default: each CLI is started with its permission prompts off, because a headless turn has nobody to answer one.guardedstarts each under its own unattended guardrails (Claude Code's auto mode; Codex's sandbox with automatic review) and refuses a CLI that has none.dispatchand the rest reach the socket without readingconnect.json, whose ownership cannot be checked from inside a coding agent's sandbox. The check itself is unchanged. The socket is only used in a directory that is the person's own and closed to everyone else, which is what decides who may use it.Verified
bin/cion a clean checkout of this branch.go test -tags runner_live ./internal/connector/runner -run TestInstalled): a turn answers, a second turn continues the session, a session that no longer exists fails cleanly, and underguardedboth Claude Code and Codex commit in a repo on a new and a resumed session.fullandguarded: a question answered in place, repo work with a worktree, commit and pushed branch, a follow-up continuing the same worker, a restart in the middle of a task, a rule choosing the worker by card table and by repo, and--runfrom kept settings.Not verified