From 27658202139e7c7531bb941cc940ba2f177f74a0 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 13 Oct 2026 17:44:30 -0400 Subject: [PATCH 1/4] Say a connect adds a connection, and a refusal means disconnected Copy for multiple connections per agent (step 4 of the plan). Once Basecamp lets a personal agent hold several connections, connecting on another computer no longer ends this one, so: - The connector's and guided setup's refusal says this computer's connection was disconnected in Basecamp, without "or connected on another computer". - auth agent connect help says each approval adds a connection, that a personal agent's others keep working, that a shared agent keeps one, and that disconnecting this connection leaves the others. - connect help says a personal agent can run on several computers, and connect setup help that two connections serving one project both answer. - A lost handover says to disconnect the new connection, not the agent, so nobody reaches for Disconnect all. - README and the basecamp-connect skill drop "every connection rotates the agent's secret". Merges with bc3 step 6; before it, connecting elsewhere still replaces. --- README.md | 11 ++++++++--- internal/auth/agent_connect.go | 4 ++-- internal/auth/agent_connect_test.go | 12 ++++++------ internal/commands/auth_agent.go | 15 ++++++++++++--- internal/commands/connect.go | 9 +++++++++ internal/commands/connect_run.go | 4 ++-- .../commands/connect_run_disconnect_test.go | 8 ++++---- internal/commands/connect_setup_guided.go | 2 +- .../commands/connect_setup_guided_test.go | 2 +- skills/basecamp-connect/SKILL.md | 19 ++++++++++++------- 10 files changed, 57 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 02a39f6ca..c11a0a18b 100644 --- a/README.md +++ b/README.md @@ -267,9 +267,14 @@ basecamp connect setup -P my-agent --serve 12345 # who may give it work, basecamp connect -P my-agent # the watcher ``` -Connect each agent once. Every connection rotates the agent's secret, so a -second one, from this CLI or from the HTTP handshake on Basecamp's instructions -page, disconnects the first and asks you to approve again. +Each approval adds a connection with its own secret, named after the computer +(rename it on My > Agent). A personal agent can have several, on one computer +or several, and connecting another, from this CLI or from the HTTP handshake on +Basecamp's instructions page, leaves the others working. Disconnecting one in +Basecamp stops only that one. Each connection serves the projects its own setup +names; two that serve the same project both answer it, so serve each project +from one. A shared agent keeps a single connection: connecting it again +disconnects the first and asks you to approve again. In Claude Code, the `/basecamp-connect` skill runs the watcher and hands each request to a subagent. It ships in this CLI's plugin from 0.12.0 on, and an diff --git a/internal/auth/agent_connect.go b/internal/auth/agent_connect.go index fafca6a99..b575be75e 100644 --- a/internal/auth/agent_connect.go +++ b/internal/auth/agent_connect.go @@ -344,14 +344,14 @@ func reconnectRemedy(err error) error { return err } hinted := *e - hinted.Hint = "Disconnect the agent in Basecamp and run the connect command again; the secret this connection was handed is gone." + hinted.Hint = "Disconnect the new connection in Basecamp and run the connect command again; the secret it was handed is gone." return &hinted } // warnHandoverLost says what a failure after the handover costs, in the // one line an operator has to act on. func warnHandoverLost(log func(string)) { - log("warning: the connection was approved and the agent's client secret was handed over once, but keeping it failed. Disconnect the agent in Basecamp and connect again.") + log("warning: the connection was approved and its client secret was handed over once, but keeping it failed. Disconnect the new connection in Basecamp and connect again.") } // spentHandoverError marks an error raised once the poll had already been diff --git a/internal/auth/agent_connect_test.go b/internal/auth/agent_connect_test.go index e4da47f27..8dc9e76bf 100644 --- a/internal/auth/agent_connect_test.go +++ b/internal/auth/agent_connect_test.go @@ -387,7 +387,7 @@ func TestConnectAgentRefusesATokenWiderThanTheApproval(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "wider than the scope") assertNoAgentCredential(t, m) - assert.Contains(t, cl.joined(), "Disconnect the agent in Basecamp and connect again") + assert.Contains(t, cl.joined(), "Disconnect the new connection in Basecamp and connect again") assert.NotContains(t, cl.joined(), "minted", "no token from a refused mint is kept") } @@ -535,7 +535,7 @@ func TestConnectAgentRefusesAScopeItCannotStore(t *testing.T) { assertNoAgentCredential(t, m) // The poll was answered, so the code is burned and the secret minted // whatever this process made of the answer. - assert.Contains(t, cl.joined(), "Disconnect the agent in Basecamp and connect again") + assert.Contains(t, cl.joined(), "Disconnect the new connection in Basecamp and connect again") var e *output.Error assert.ErrorAs(t, err, &e, "the refusal keeps its own class through the spent-handover marker") } @@ -701,7 +701,7 @@ func TestConnectAgentStoresNothingWhenTheProfileCannotBeCommitted(t *testing.T) // failure leaves the agent connected to nothing. Saying "nothing was // stored" alone would send the operator looking for a credential that // no longer exists anywhere. - assert.Contains(t, cl.joined(), "Disconnect the agent in Basecamp and connect again") + assert.Contains(t, cl.joined(), "Disconnect the new connection in Basecamp and connect again") } // TestConnectAgentRefusesTheLaunchpadFallback: Launchpad has no agent @@ -842,7 +842,7 @@ func TestConnectAgentRefusesAHandoverWiderThanTheRequest(t *testing.T) { assert.Contains(t, err.Error(), "read-only") assert.Empty(t, as.calls(&as.tokenForms), "a handover wider than the request is never minted with") assertNoAgentCredential(t, m) - assert.Contains(t, cl.joined(), "Disconnect the agent in Basecamp and connect again") + assert.Contains(t, cl.joined(), "Disconnect the new connection in Basecamp and connect again") } // TestConnectAgentHonorsASlowRetryAfter: a Retry-After longer than a @@ -880,7 +880,7 @@ func TestConnectAgentHandsBackARemedyTheOperatorCanFollow(t *testing.T) { require.Error(t, err) var e *output.Error require.ErrorAs(t, err, &e) - assert.Contains(t, e.Hint, "Disconnect the agent in Basecamp") + assert.Contains(t, e.Hint, "Disconnect the new connection in Basecamp") assert.NotContains(t, e.Hint, "--with-client-credentials") } @@ -939,7 +939,7 @@ func TestConnectAgentTreatsAnInterruptedHandoverAsSpent(t *testing.T) { cl := &collectLogger{} _, err := m.ConnectAgent(context.Background(), connectOptions(cl, newTestClock())) require.Error(t, err) - assert.Contains(t, cl.joined(), "Disconnect the agent in Basecamp and connect again") + assert.Contains(t, cl.joined(), "Disconnect the new connection in Basecamp and connect again") assertNoAgentCredential(t, m) assert.Empty(t, as.calls(&as.tokenForms), "nothing was minted with a credential that never arrived") } diff --git a/internal/commands/auth_agent.go b/internal/commands/auth_agent.go index a70239f35..07a3a6976 100644 --- a/internal/commands/auth_agent.go +++ b/internal/commands/auth_agent.go @@ -52,6 +52,14 @@ the agent's own OAuth client: this CLI mints an access token from it whenever it needs one, so there is no token to paste and none to renew by hand. +Each approval adds a connection with a secret of its own, named after this +computer unless --device-name names it. A personal agent's other +connections, on this computer or another, keep working. A shared agent +keeps a single connection: connecting it replaces the one it had, wherever +that was. Connecting a profile again stores the new connection in place of +the one it held; for a personal agent, the old one stays in Basecamp until +you disconnect it there. + The account comes from the agent you approve — pass --account only to insist on one, and the connection is refused if the agent belongs to another. @@ -65,8 +73,9 @@ Over SSH, in CI, or on a host with no display the browser is skipped and the link is yours to open on any device; --local forces a launch anyway, --no-browser skips it. Ctrl-C cancels the wait and stores nothing. -Disconnecting is done in Basecamp: disconnecting the agent there kills the -client secret this holds. Drop the local copy with ` + "`basecamp auth logout -P `" + `.`, +Disconnecting is done in Basecamp: disconnecting this connection there kills +the secret this holds, and the agent's other connections keep working. Drop +the local copy with ` + "`basecamp auth logout -P `" + `.`, Annotations: map[string]string{AnnotationProfileMayCreate: "true"}, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -94,7 +103,7 @@ client secret this holds. Drop the local copy with ` + "`basecamp auth logout -P }, } - cmd.Flags().StringVar(&deviceName, "device-name", "", "Name this computer on the approval page (default: this host's name)") + cmd.Flags().StringVar(&deviceName, "device-name", "", "Name this connection on the approval page and in Basecamp (default: this host's name)") cmd.Flags().StringVar(&softwareName, "software-name", "", "Name the software this CLI runs inside, shown as \"Connected to\"") cmd.Flags().StringVar(&scope, "scope", "", "OAuth scope to ask for: 'read' or 'full' (default full; the approval page can downgrade it)") cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Print the link instead of opening a browser") diff --git a/internal/commands/connect.go b/internal/commands/connect.go index c59318449..c7eaffebb 100644 --- a/internal/commands/connect.go +++ b/internal/commands/connect.go @@ -61,6 +61,11 @@ off nothing. --hold sets a durable hold: intake and admission run, nothing is handed off, and earlier records wait for review, until basecamp connect release. Linux and macOS only. +Each basecamp auth agent connect adds a connection with its own secret, so a +personal agent can run a connector on several computers at once. Each one +serves the projects its own setup names, and two that serve the same project +both answer it. + basecamp connect status what it heard and holds basecamp connect doctor what it needs to run basecamp connect discard close a record without running it @@ -358,6 +363,10 @@ does not serve is handed off, and nobody is told. --watch-completions '` (add `--no-browser` if the person is on another device). Show the link and code. They open the link, check the code, choose the agent (or create one) and approve. -- `oauth_type` `agent`: already connected. Don't connect again: that rotates - the agent's secret and disconnects any other computer using it. +- `oauth_type` `agent`: already connected. Don't connect again: for a + personal agent that adds another connection and leaves the one this + profile held unused in Basecamp; for a shared agent it disconnects any + other computer using it. - Anything else: a person's login is stored. Ask; never connect an agent over it. @@ -527,7 +530,9 @@ Failed checks worth knowing: - **Stream ticket refused.** The account event feed isn't enabled for this account or agent. That's a Basecamp setting the person has to ask for. - **Scope: not full access.** The agent couldn't reply. Connect again with full - access, with the person's consent, since it rotates the secret. + access, with the person's consent: for a personal agent that adds a + connection, and the read-only one stays in Basecamp until they disconnect it + there; for a shared agent it replaces the connection. **Never edit connect.json by hand**, and never read it directly: use `basecamp connect show -P '' --json`, which checks the file is safe @@ -538,7 +543,7 @@ first. Every change goes through setup, which keeps whatever you don't pass. The background task notifies you when the connector exits. Read the end of its output and tell the person what it means: -- **"was disconnected in Basecamp, or connected on another computer"**: run +- **"connection to … was disconnected in Basecamp"**: run `basecamp connect setup -P ''` (it reconnects with one approval), then start it again. - **Already running**: another connector for this agent holds its lock, on From 6aa3faee74c19bf288fe7ba93da0ccd5f340a97b Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 13 Oct 2026 17:52:08 -0400 Subject: [PATCH 2/4] Say one connector per agent on this computer, and that overlap answers what both admit Pi's review: the connector's lock is local, so a personal agent's other computers can run one; and two connections serving one project each answer only what their own trust admits. --- README.md | 7 ++++--- internal/commands/connect.go | 8 ++++---- skills/basecamp-connect/SKILL.md | 6 +++--- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c11a0a18b..e4828d80d 100644 --- a/README.md +++ b/README.md @@ -272,9 +272,10 @@ Each approval adds a connection with its own secret, named after the computer or several, and connecting another, from this CLI or from the HTTP handshake on Basecamp's instructions page, leaves the others working. Disconnecting one in Basecamp stops only that one. Each connection serves the projects its own setup -names; two that serve the same project both answer it, so serve each project -from one. A shared agent keeps a single connection: connecting it again -disconnects the first and asks you to approve again. +names; when two serve the same project, a request both admit is answered by +each, so serve each project from one. A shared agent keeps a single +connection: connecting it again disconnects the first and asks you to approve +again. In Claude Code, the `/basecamp-connect` skill runs the watcher and hands each request to a subagent. It ships in this CLI's plugin from 0.12.0 on, and an diff --git a/internal/commands/connect.go b/internal/commands/connect.go index c7eaffebb..80a470e9d 100644 --- a/internal/commands/connect.go +++ b/internal/commands/connect.go @@ -63,8 +63,8 @@ release. Linux and macOS only. Each basecamp auth agent connect adds a connection with its own secret, so a personal agent can run a connector on several computers at once. Each one -serves the projects its own setup names, and two that serve the same project -both answer it. +serves the projects its own setup names; when two serve the same project, a +request both admit is answered by each. basecamp connect status what it heard and holds basecamp connect doctor what it needs to run @@ -364,8 +364,8 @@ makes the agent hear every trusted completion in that project without being assigned. The list is this profile's own. When two connections of one agent serve the -same project, both answer every request there, so serve each project from -one of them. +same project, a request both admit there is answered by each, so serve each +project from one of them. No directory is associated with a project here: the session handling the requests chooses the repo for each one. diff --git a/skills/basecamp-connect/SKILL.md b/skills/basecamp-connect/SKILL.md index 78a5bdcbf..1455f55bc 100644 --- a/skills/basecamp-connect/SKILL.md +++ b/skills/basecamp-connect/SKILL.md @@ -58,9 +58,9 @@ Claude Code only. The connector runs on Linux and macOS. ### 1. Start it, and watch its output -Only one connector per agent can run at a time. If it's already running -elsewhere, it refuses to start and says so: tell the person, and don't start a -second. +Only one connector per agent can run at a time on this computer. If one is +already running here, it refuses to start and says so: tell the person, and +don't start a second. Start it in the background with the Bash tool (`run_in_background: true`), and note the output file the harness reports: From f178c3d754c24d7cac072d9b3f1269ebd6d159fd Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 13 Oct 2026 17:56:27 -0400 Subject: [PATCH 3/4] Say an overlapping request can be answered twice, not that it is Pi's second pass: a held or shadow connector admits without handing off, so admission is not a guarantee of an answer. --- README.md | 4 ++-- internal/commands/connect.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e4828d80d..f0f60e77c 100644 --- a/README.md +++ b/README.md @@ -272,8 +272,8 @@ Each approval adds a connection with its own secret, named after the computer or several, and connecting another, from this CLI or from the HTTP handshake on Basecamp's instructions page, leaves the others working. Disconnecting one in Basecamp stops only that one. Each connection serves the projects its own setup -names; when two serve the same project, a request both admit is answered by -each, so serve each project from one. A shared agent keeps a single +names; when two serve the same project, a request both admit can be answered +twice, so serve each project from one. A shared agent keeps a single connection: connecting it again disconnects the first and asks you to approve again. diff --git a/internal/commands/connect.go b/internal/commands/connect.go index 80a470e9d..d62595b30 100644 --- a/internal/commands/connect.go +++ b/internal/commands/connect.go @@ -64,7 +64,7 @@ release. Linux and macOS only. Each basecamp auth agent connect adds a connection with its own secret, so a personal agent can run a connector on several computers at once. Each one serves the projects its own setup names; when two serve the same project, a -request both admit is answered by each. +request both admit can be answered twice. basecamp connect status what it heard and holds basecamp connect doctor what it needs to run @@ -364,8 +364,8 @@ makes the agent hear every trusted completion in that project without being assigned. The list is this profile's own. When two connections of one agent serve the -same project, a request both admit there is answered by each, so serve each -project from one of them. +same project, a request both admit there can be answered twice, so serve +each project from one of them. No directory is associated with a project here: the session handling the requests chooses the repo for each one. From 7ba7e9628e4662857f21f724556f1aaec8858414 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 13 Oct 2026 18:08:13 -0400 Subject: [PATCH 4/4] Keep the connect skill's per-request steps inside the compaction cut The lock sentence grew the skill past the 14,000-character budget that TestTheConnectSkillKeepsEachRequestsStepsInsideTheCompactionCut holds; say it in fewer words. --- skills/basecamp-connect/SKILL.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/skills/basecamp-connect/SKILL.md b/skills/basecamp-connect/SKILL.md index 1455f55bc..6a31a78e3 100644 --- a/skills/basecamp-connect/SKILL.md +++ b/skills/basecamp-connect/SKILL.md @@ -58,9 +58,9 @@ Claude Code only. The connector runs on Linux and macOS. ### 1. Start it, and watch its output -Only one connector per agent can run at a time on this computer. If one is -already running here, it refuses to start and says so: tell the person, and -don't start a second. +One connector per agent can run on this computer. If one is already +running, it refuses to start and says so: tell the person, and don't start a +second. Start it in the background with the Bash tool (`run_in_background: true`), and note the output file the harness reports: