Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
23042d2
Freeze the dispatcher's interfaces: driver, tasks and attempts, hooks
jorgemanrubia Sep 17, 2026
58ed8a9
Run the connector: tests, the run command, and the worker seam
jorgemanrubia Sep 17, 2026
13d0010
Terminate the leader by pid too; pin --setting-sources in the args test
jorgemanrubia Sep 17, 2026
1fecaf8
Launch on #736's createTask; one live task per event is retired_at's
jorgemanrubia Sep 17, 2026
4904303
Bound the wait on a worker's pipes, so a stray descendant cannot hang…
jorgemanrubia Sep 17, 2026
68fdb10
Fail, not hang, when a per-task workspace session never starts
jorgemanrubia Sep 17, 2026
71a773e
Answer the first review: starvation, stop reasons, recovery, containment
jorgemanrubia Sep 17, 2026
7521b7e
End an attempt through #736's supersedeTask, which returns unexposed …
jorgemanrubia Sep 17, 2026
10fdc10
Answer the second review: scope, authorization, and what a stop means
jorgemanrubia Sep 17, 2026
c7ec041
Preallocate the stranded query's arguments
jorgemanrubia Sep 17, 2026
0b77076
Answer the third review: groups, locations, slots, truncation, the skill
jorgemanrubia Sep 17, 2026
2c44b06
Name the one-owner rule and hold everything to it
jorgemanrubia Sep 17, 2026
547d150
One release point, and nothing may reach around it
jorgemanrubia Sep 17, 2026
06bf1c1
Write the driver contract down, and make the code keep it
jorgemanrubia Sep 17, 2026
125c118
On #736's 67aac1d: settlement cannot meet a moved handed record; desc…
jorgemanrubia Sep 17, 2026
462cac6
The task token's carriage: a one-use socket and the worker-mcp bridge
jorgemanrubia Sep 17, 2026
6eb4666
Withdraw through #736's withdrawExposure, after the supersession it r…
jorgemanrubia Sep 17, 2026
bcaa010
A worker's MCP server may be its descendant in a group of its own: Co…
jorgemanrubia Sep 17, 2026
4a8e52f
The prompt's worst case fits the budget: a URL over 120 characters is…
jorgemanrubia Sep 17, 2026
d58536a
A process group whose members are all zombies is gone
jorgemanrubia Sep 17, 2026
60196d4
drivertest: a secret scan never opens a SQLite database or its journals
jorgemanrubia Sep 17, 2026
5b59bca
Tests start their helper processes with a context
jorgemanrubia Sep 17, 2026
82ee3ec
The redaction rule: one function every text leaving a worker passes t…
jorgemanrubia Sep 17, 2026
58587b6
The refusal rule: a refusal is recorded in the ledger as it happens, …
jorgemanrubia Sep 17, 2026
a00b414
Take no descriptor's range on trust at the syscall boundary
jorgemanrubia Sep 17, 2026
df6ff26
Copilot: a stub that matches its Unix twin, a turn that keeps its ref…
jorgemanrubia Sep 17, 2026
57bfbf3
The token's window is the worker's MCP server's, and starts when the …
jorgemanrubia Sep 17, 2026
9963e3d
The release point ends the MCP server the agent started outside the w…
jorgemanrubia Sep 17, 2026
efeb109
A restart ends the MCP server that took the token, and a clean finish…
jorgemanrubia Sep 17, 2026
8483da8
A token socket always has a path a unix socket can carry
jorgemanrubia Sep 17, 2026
af7e411
The moved token socket is the connector's own: swept, checked, named …
jorgemanrubia Sep 17, 2026
7e49e63
A restarted MCP server takes the token again, and four paths that ans…
jorgemanrubia Sep 17, 2026
d17eb08
The socket arms again only when the server holding the token is gone,…
jorgemanrubia Sep 17, 2026
e061398
A spent handoff budget is said out loud
jorgemanrubia Sep 17, 2026
59017dc
Write down what counts as one refusal, and why the handoff budget is …
jorgemanrubia Sep 17, 2026
0fc79b0
Say what became of every handoff, and count a nameless refusal every …
jorgemanrubia Sep 17, 2026
56042e6
Add the acp driver: the connector as an ACP v1 client
jorgemanrubia Sep 17, 2026
6f22d7d
acp: keep foreign MCP servers out, and no hang on a stuck adapter
jorgemanrubia Sep 17, 2026
51e8143
acp: bound the wait on a held output, and three review fixes
jorgemanrubia Sep 17, 2026
dbde762
acp: answer the second review round
jorgemanrubia Sep 17, 2026
a36e1d5
acp: a cancel ends one turn, and the preflight reads spaced keys
jorgemanrubia Sep 17, 2026
17e965c
acp: answer the third review round
jorgemanrubia Sep 17, 2026
c250d83
acp: bound the requests in flight, and steady two tests
jorgemanrubia Sep 17, 2026
f8bb120
acp: every refusal on its turn, and what a tool call may cost
jorgemanrubia Sep 17, 2026
b4f74f3
acp: ask the compatibility check's write twice before judging it
jorgemanrubia Sep 17, 2026
535901b
acp: confirm a failed handshake's group gone, and close the last refu…
jorgemanrubia Sep 17, 2026
701ab99
acp: a settled turn asks nothing more, and requests keep the turn the…
jorgemanrubia Sep 17, 2026
005afb6
acp: adopt the written driver contract, and settle a turn as its answ…
jorgemanrubia Sep 17, 2026
b03b140
acp: prove the token bridge against both pinned adapters
jorgemanrubia Sep 17, 2026
628a024
acp: a probe that proves something on any machine, and a strict install
jorgemanrubia Sep 17, 2026
f778ae5
acp: no session goes on without the MCP servers it was given
jorgemanrubia Sep 17, 2026
08f8847
acp: an agent that outruns even its refusals ends its session
jorgemanrubia Sep 17, 2026
5d78402
acp: adopt the shared redaction and refusal record, and answer the ni…
jorgemanrubia Sep 17, 2026
76e4d8d
acp: wrap the shared unverified sentinel, and prove the shared rules
jorgemanrubia Sep 17, 2026
124e5a7
acp: a server nobody gave it, an init for another session, and two lo…
jorgemanrubia Sep 17, 2026
cc6404c
acp: the eleventh review's fixes, and the three rules said once
jorgemanrubia Sep 17, 2026
7482ce6
acp: say exactly what is bounded, trusted and inherited
jorgemanrubia Sep 17, 2026
28c21f2
acp: bound what the agent holds, and gate evidence where decisions ar…
jorgemanrubia Sep 17, 2026
8191970
acp: read the whole of an adapter's stderr, and the socket's directory
jorgemanrubia Sep 17, 2026
d225e07
acp: ask twice before judging what an adapter does with a dead server
jorgemanrubia Sep 17, 2026
05ed104
acp: a name the session never gave is not kept as a name
jorgemanrubia Sep 17, 2026
439311a
acp: hold the nameless-refusal count with a test
jorgemanrubia Sep 17, 2026
af15cd8
acp: a call this driver cannot place whole is refused, not judged in …
jorgemanrubia Sep 17, 2026
3064b45
acp: a session runs only the MCP servers the adapter says it got
jorgemanrubia Sep 18, 2026
211cae6
Merge connect-dispatcher, which is now on main after #736 squashed
jorgemanrubia Sep 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .naming-allowlist
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@ keyring(bcq
legacy bcq
# RELEASING.md — actual GitHub App name
bcq-release-bot
# npm integrity hashes are base64 and can contain any letters
./internal/connector/driver/acp/adapters/package-lock.json
1 change: 1 addition & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -5345,6 +5345,7 @@ FLAG basecamp config untrust --styled type=bool
FLAG basecamp config untrust --todolist type=string
FLAG basecamp config untrust --verbose type=count
FLAG basecamp connect --account type=string
FLAG basecamp connect --acp-adapters type=string
FLAG basecamp connect --agent type=bool
FLAG basecamp connect --cache-dir type=string
FLAG basecamp connect --count type=bool
Expand Down
30 changes: 30 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,33 @@ qa-report:
echo ""; \
fi

# The connector's acp driver runs pinned ACP adapters, installed here once by
# an operator and never downloaded at dispatch time.
# Where basecamp connect looks by default: an absolute $XDG_DATA_HOME, else
# ~/.local/share (a relative XDG_DATA_HOME is ignored there too).
ACP_ADAPTERS_DIR ?= $(if $(filter /%,$(XDG_DATA_HOME)),$(XDG_DATA_HOME),$(HOME)/.local/share)/basecamp/acp-adapters

# Install the pinned ACP adapters (internal/connector/driver/acp/adapters).
# --engine-strict: an adapter whose Node version requirement this machine does
# not meet fails the install, not the first dispatch.
.PHONY: acp-adapters
acp-adapters:
@mkdir -p "$(ACP_ADAPTERS_DIR)"
cp internal/connector/driver/acp/adapters/package.json internal/connector/driver/acp/adapters/package-lock.json "$(ACP_ADAPTERS_DIR)/"
npm ci --prefix "$(ACP_ADAPTERS_DIR)" --ignore-scripts --no-audit --no-fund --engine-strict

# The ACP adapter-compatibility test: eight checks through the acp driver
# against each installed adapter (the spike's four, the worker shell's
# environment, a decoy MCP server in the working directory, the task
# token's bridge, and what an adapter does when a session's MCP server dies
# mid-session). Sends real prompts (model quota); skipped
# for an adapter that is not installed. ACP_TRANSCRIPTS=<dir> keeps redacted
# JSON-RPC transcripts.
.PHONY: test-acp-compat
test-acp-compat: check-toolchain
BASECAMP_ACP_ADAPTERS_DIR="$(ACP_ADAPTERS_DIR)" BASECAMP_ACP_TRANSCRIPTS="$(ACP_TRANSCRIPTS)" \
$(GOTEST) -tags acpcompat -run TestAdapterCompat -count=1 -timeout 30m -v ./internal/connector/driver/acp/

# Run tests with race detector
.PHONY: race-test
race-test: check-toolchain
Expand Down Expand Up @@ -294,6 +321,9 @@ provenance-check:
.PHONY: vet
vet: check-toolchain
$(GOVET) $(BUILD_TAGS) ./...
@# The adapter-compatibility test builds only with its own tag, so
@# nothing else would notice it rotting.
$(GOVET) -tags acpcompat ./internal/connector/driver/acp/

# Format code
.PHONY: fmt
Expand Down
28 changes: 24 additions & 4 deletions internal/commands/connect_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/basecamp/basecamp-cli/internal/connector"
"github.com/basecamp/basecamp-cli/internal/connector/admission"
"github.com/basecamp/basecamp-cli/internal/connector/driver"
"github.com/basecamp/basecamp-cli/internal/connector/driver/acp"
"github.com/basecamp/basecamp-cli/internal/connector/driver/spawn"
"github.com/basecamp/basecamp-cli/internal/connector/ndjson"
"github.com/basecamp/basecamp-cli/internal/connector/setup"
Expand All @@ -38,6 +39,7 @@ type connectRunFlags struct {
shadow bool
since int64
driver string
adapters string
}

func addConnectRunFlags(cmd *cobra.Command, f *connectRunFlags) {
Expand All @@ -47,7 +49,8 @@ func addConnectRunFlags(cmd *cobra.Command, f *connectRunFlags) {
fl.Var((*repeatedString)(&f.projects), "project", "Only hear events in this project id (repeatable; default every project the agent can see)")
fl.BoolVar(&f.shadow, "shadow", false, "Admit and log in an isolated state directory; dispatch and post nothing")
fl.Int64Var(&f.since, "since", 0, "Enter the feed just after this event id, whatever the ledger holds")
fl.StringVar(&f.driver, "driver", "", "Override connect.json's driver (spawn)")
fl.StringVar(&f.driver, "driver", "", "Override connect.json's driver (spawn or acp)")
fl.StringVar(&f.adapters, "acp-adapters", "", "Where the pinned ACP adapters are installed, for --driver acp (default $XDG_DATA_HOME/basecamp/acp-adapters)")
}

// connectStateHome is the directory holding the connector's state root, from
Expand Down Expand Up @@ -123,6 +126,23 @@ func connectSessionsPath(file setup.File) string {
return filepath.Join(base, "bcc-"+connector.StateDirName(file.AccountID, file.Agent.PersonID))
}

// connectDriver is the driver connect.json (or --driver) names for its
// worker. The acp driver runs the worker's pinned ACP adapter, found where it
// was installed; nothing is downloaded here.
func connectDriver(name, worker, adaptersDir string) (driver.Driver, error) {
if name != setup.DriverACP {
return spawn.New(worker, spawn.Options{})
}
if adaptersDir != "" && !filepath.IsAbs(adaptersDir) {
abs, err := filepath.Abs(adaptersDir)
if err != nil {
return nil, err
}
adaptersDir = abs
}
return acp.ForWorker(worker, adaptersDir, nil)
}

func runConnect(cmd *cobra.Command, f *connectRunFlags) error {
if !connectSupportedOS(runtime.GOOS) {
return output.ErrUsage("basecamp connect runs on macOS and Linux only: it ends a crashed connector's workers by process group and start time, which only those two can read")
Expand Down Expand Up @@ -165,8 +185,8 @@ func runConnect(cmd *cobra.Command, f *connectRunFlags) error {
if f.driver != "" {
driverName = f.driver
}
if !f.shadow && driverName != setup.DriverSpawn {
return output.ErrUsage(fmt.Sprintf("driver %q is not available yet; use %q", driverName, setup.DriverSpawn))
if !f.shadow && driverName != setup.DriverSpawn && driverName != setup.DriverACP {
return output.ErrUsage(fmt.Sprintf("driver %q is not %q or %q", driverName, setup.DriverSpawn, setup.DriverACP))
}

account, err := connectAccount(app, name)
Expand Down Expand Up @@ -270,7 +290,7 @@ func runConnect(cmd *cobra.Command, f *connectRunFlags) error {
return err
}
routes := newConnectRoutes(path, file, logger)
worker, err := spawn.New(file.WorkerName(), spawn.Options{})
worker, err := connectDriver(driverName, file.WorkerName(), f.adapters)
if err != nil {
return output.ErrUsage(err.Error())
}
Expand Down
31 changes: 31 additions & 0 deletions internal/commands/connect_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/basecamp/basecamp-cli/internal/config"
"github.com/basecamp/basecamp-cli/internal/connector"
"github.com/basecamp/basecamp-cli/internal/connector/admission"
"github.com/basecamp/basecamp-cli/internal/connector/driver/acp"
"github.com/basecamp/basecamp-cli/internal/connector/setup"
)

Expand Down Expand Up @@ -193,3 +194,33 @@ func TestTheDoctorCheckReadsTheProfilesConnectorLayout(t *testing.T) {
assert.Equal(t, "warn", check.Status)
assert.Contains(t, check.Hint, "XDG_RUNTIME_DIR", "and says what to do about it")
}

func TestConnectDriverRunsTheWorkersPinnedACPAdapterFromWhereItWasInstalled(t *testing.T) {
d, err := connectDriver(setup.DriverSpawn, setup.WorkerClaude, "")
require.NoError(t, err)
assert.Equal(t, setup.WorkerClaude, d.Name())

dir := t.TempDir()
_, err = connectDriver(setup.DriverACP, setup.WorkerClaude, dir)
require.ErrorIs(t, err, acp.ErrAdapterMissing, "an adapter that is not installed is never fetched")

pkg := filepath.Join(dir, "node_modules", filepath.FromSlash(acp.ClaudeAgentACP.Package))
require.NoError(t, os.MkdirAll(pkg, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(pkg, "package.json"),
[]byte(`{"name":"`+acp.ClaudeAgentACP.Package+`","version":"`+acp.ClaudeAgentACP.Version+`"}`), 0o600))
bin := filepath.Join(dir, "node_modules", ".bin")
require.NoError(t, os.MkdirAll(bin, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(bin, acp.ClaudeAgentACP.Name), []byte("#!/bin/sh\n"), 0o700))
d, err = connectDriver(setup.DriverACP, setup.WorkerClaude, dir)
require.NoError(t, err)
assert.Equal(t, acp.Name, d.Name())

// A relative directory is the operator's, from where they run the command.
t.Chdir(filepath.Dir(dir))
d, err = connectDriver(setup.DriverACP, setup.WorkerClaude, filepath.Base(dir))
require.NoError(t, err)
assert.Equal(t, acp.Name, d.Name())

_, err = connectDriver(setup.DriverACP, "nobody", dir)
assert.Error(t, err)
}
Loading
Loading