diff --git a/internal/commands/connect.go b/internal/commands/connect.go index 5b7e15017..625d76dd4 100644 --- a/internal/commands/connect.go +++ b/internal/commands/connect.go @@ -41,9 +41,11 @@ func NewConnectCmd() *cobra.Command { Long: `Run a local agent connector: it listens to the account event feed as a Basecamp agent, admits what a trusted person asks of that agent, and prints each trusted request for the session that started it to handle. It starts no -workers and posts nothing itself: the basecamp-connect skill, run in your own -Claude Code session, acknowledges each request, picks the repo and hands it to -a subagent that replies as the agent. +workers and posts only the acknowledgement boost, unless setup turns it off +(--no-ack-boost): a request someone made of the agent is boosted as the agent +before it is printed. The basecamp-connect skill, run in your own Claude Code +session, picks the repo and hands each request to a subagent that replies as +the agent. Connect the agent to a profile first (basecamp auth agent connect -P ), then run setup on that profile: it records who may drive the agent, maps @@ -95,7 +97,8 @@ func newConnectShowCmd() *cobra.Command { Use: "show", Short: "Show a profile's connector setup without changing it", Long: `Show the connect.json a profile's setup wrote: the agent, the operator and -trust mode, each served project, and who is let into a Ping. +trust mode, each served project, who is let into a Ping, and the boost the +connector acknowledges requests with (ack_boost, the default included). The file is read through the same checks setup and the connector apply: it is refused, and nothing of it shown, when it is a symlink, is not a @@ -156,7 +159,7 @@ func runConnectShow(app *appctx.App) error { // agent, the trust and the served projects live, so a person's formats get them // flattened to one line each; JSON keeps the file's own shape. markdown := app.Output.EffectiveFormat() == output.FormatMarkdown - return app.OK(connectShowResult{Path: path, File: f}, + return app.OK(connectShowResult{Path: path, File: f, AckBoost: f.Ack()}, output.WithDisplayData(connectShowDisplay(path, f, markdown)), output.WithSummary(fmt.Sprintf("Connector setup for profile %q: trust %s, %d served project(s)", name, f.Trust.Mode, len(f.Projects)))) } @@ -207,6 +210,7 @@ func connectShowDisplay(path string, f setup.File, markdown bool) map[string]any "trust": trust, "agents": agents, "projects": strconv.Itoa(len(f.Projects)) + " served", + "ack": connectAckDisplay(f.Ack()), } for id, entry := range f.Pings { ids := make([]string, len(entry.Participants)) @@ -228,6 +232,14 @@ func connectShowDisplay(path string, f setup.File, markdown bool) map[string]any return d } +// connectAckDisplay says how the connector acknowledges a request. +func connectAckDisplay(ack setup.AckBoost) string { + if ack.Off { + return "off: the session boosts each request" + } + return "boosts " + strconv.Quote(ack.Content) + " before handing each request off" +} + // escapeControls writes every control or non-printable rune in s, the // backslash and "<" (which the renderer would read as HTML) as a Go escape // (\x1b, \u009b, \\, \x3c), so a path can reach a @@ -267,9 +279,12 @@ func markdownCode(s string) string { } // connectShowResult is show's data: where the file is, and what it holds. +// AckBoost is the acknowledgement in effect, the default included, which +// the file leaves out. type connectShowResult struct { Path string `json:"path"` setup.File + AckBoost setup.AckBoost `json:"ack_boost"` } // connectSetupFlags are setup's flags, as typed. @@ -297,6 +312,11 @@ type connectSetupFlags struct { allowInPing []string disallowInPing []string + // ackBoost is --ack-boost, the boost the connector acknowledges a + // request with; noAckBoost turns it off. + ackBoost optionalString + noAckBoost bool + serve []string classes []string watch []string @@ -385,6 +405,14 @@ person's in basecamp pings show --json. Anyone else's line in a Ping is discarded, as is any agent's. Restart the connector after changing who is let into a Ping. +Acknowledgement. When someone asks the agent for something (a mention, an +assignment, a ping), the connector boosts that recording as the agent before +printing the request, and the request line carries the boost's id. Followed +threads, completions and boosts are context and are not acknowledged. +--ack-boost sets the boost (default 👀, at most 16 characters); +--no-ack-boost turns it off, and the session acknowledges instead. Restart +the connector after changing it. + No directory is associated with a project here: the session handling the requests chooses the repo for each one. @@ -423,7 +451,9 @@ Examples: basecamp connect setup -P agent --class 12345=internal basecamp connect setup -P agent --allow-agent 333 basecamp connect setup -P agent --allow-in-ping 2085958513:111 - basecamp connect setup -P agent --agent-thread-cap 5 --agent-daily-cap 30`, + basecamp connect setup -P agent --agent-thread-cap 5 --agent-daily-cap 30 + basecamp connect setup -P agent --ack-boost 'On it' + basecamp connect setup -P agent --no-ack-boost`, Annotations: map[string]string{AnnotationProfileMayCreate: "true"}, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -457,7 +487,10 @@ Examples: fl.StringArrayVar(&f.classes, "class", nil, "Classify a served project: =, or = to clear it (repeatable)") fl.StringArrayVar(&f.watch, "watch-completions", nil, "Admit every trusted completion in a served project (repeatable)") fl.StringArrayVar(&f.unwatch, "no-watch-completions", nil, "Stop watching a project's completions (repeatable)") + fl.Var(&f.ackBoost, "ack-boost", fmt.Sprintf("Boost the connector acknowledges each request with, as the agent, before handing it off (default %s; at most %d characters)", setup.DefaultAckContent, setup.AckContentMax)) + fl.BoolVar(&f.noAckBoost, "no-ack-boost", false, "Don't acknowledge requests in the connector; the session reading them boosts instead") cmd.MarkFlagsMutuallyExclusive("operator", "operator-profile") + cmd.MarkFlagsMutuallyExclusive("ack-boost", "no-ack-boost") return cmd } @@ -980,6 +1013,12 @@ func (f *connectSetupFlags) changes() (setup.Changes, error) { } ch.Remove = append(ch.Remove, id) } + switch { + case f.ackBoost.set: + ch.AckBoost = &setup.AckBoost{Content: f.ackBoost.value} + case f.noAckBoost: + ch.AckBoost = &setup.AckBoost{Off: true} + } return ch, nil } @@ -1441,3 +1480,18 @@ func (n *optionalInt) Set(s string) error { n.set, n.value = true, v return nil } + +// optionalString is a string flag that remembers whether it was passed, so +// an empty value given is told apart from none. +type optionalString struct { + set bool + value string +} + +func (o *optionalString) String() string { return o.value } +func (o *optionalString) Type() string { return "string" } + +func (o *optionalString) Set(s string) error { + o.set, o.value = true, s + return nil +} diff --git a/internal/commands/connect_ack_test.go b/internal/commands/connect_ack_test.go new file mode 100644 index 000000000..780be83f6 --- /dev/null +++ b/internal/commands/connect_ack_test.go @@ -0,0 +1,102 @@ +//go:build unix + +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/connector/setup" + "github.com/basecamp/basecamp-cli/internal/output" +) + +func loadAckBoost(t *testing.T) setup.AckBoost { + t.Helper() + f, err := setup.Load(connectSetupPath(t, "agent")) + require.NoError(t, err) + return f.Ack() +} + +// --ack-boost sets the boost the connector acknowledges with, --no-ack-boost +// turns it off, and a run that passes neither keeps it. +func TestConnectSetupSetsTheAckBoost(t *testing.T) { + s := startConnectSetupServer(t) + firstSetup(t, s) + assert.Equal(t, setup.AckBoost{Content: "👀"}, loadAckBoost(t), "on by default") + + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--ack-boost", "On it") + require.NoError(t, err, out) + assert.Equal(t, setup.AckBoost{Content: "On it"}, loadAckBoost(t)) + + out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), serveArg()) + require.NoError(t, err, out) + assert.Equal(t, setup.AckBoost{Content: "On it"}, loadAckBoost(t), "kept by a run that does not pass it") + + out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--no-ack-boost") + require.NoError(t, err, out) + assert.Equal(t, setup.AckBoost{Off: true}, loadAckBoost(t)) +} + +// A boost Basecamp would refuse, and both flags at once, are refused before +// anything is written. +func TestConnectSetupRefusesABadAckBoost(t *testing.T) { + for name, args := range map[string][]string{ + "empty": {"--ack-boost", ""}, + "seventeen runes": {"--ack-boost", strings.Repeat("👀", 17)}, + "both flags": {"--ack-boost", "👍", "--no-ack-boost"}, + } { + t.Run(name, func(t *testing.T) { + s := startConnectSetupServer(t) + args = append([]string{"--operator", fmt.Sprint(setupOperatorPerson), serveArg()}, args...) + out, err := runConnectSetupCmd(t, connectSetupApp(t, s, "agent"), args...) + require.Error(t, err, out) + assertNotWritten(t, "agent") + }) + } +} + +// Show says how requests are acknowledged, the default included, which the +// file itself leaves out. +func TestConnectShowSaysHowRequestsAreAcknowledged(t *testing.T) { + s := startConnectSetupServer(t) + firstSetup(t, s) + + show := func(t *testing.T, format output.Format) string { + t.Helper() + app := newConnectSetupApp(t, s, "agent") + var buf bytes.Buffer + app.Output = output.New(output.Options{Format: format, Writer: &buf}) + out, err := runConnectShowCmd(t, app) + require.NoError(t, err, out) + return buf.String() + } + ackOf := func(t *testing.T) setup.AckBoost { + t.Helper() + var envelope struct { + Data struct { + AckBoost setup.AckBoost `json:"ack_boost"` + } `json:"data"` + } + raw := show(t, output.FormatJSON) + require.NoError(t, json.Unmarshal([]byte(raw), &envelope), raw) + return envelope.Data.AckBoost + } + + data, err := os.ReadFile(connectSetupPath(t, "agent")) + require.NoError(t, err) + require.NotContains(t, string(data), "ack_boost", "the default is not in the file") + assert.Equal(t, setup.AckBoost{Content: "👀"}, ackOf(t)) + assert.Contains(t, show(t, output.FormatStyled), `boosts "👀"`) + + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--no-ack-boost") + require.NoError(t, err, out) + assert.Equal(t, setup.AckBoost{Off: true}, ackOf(t)) + assert.Contains(t, show(t, output.FormatStyled), "off: the session boosts each request") +} diff --git a/internal/commands/connect_run.go b/internal/commands/connect_run.go index c3f9894fb..d5aed7195 100644 --- a/internal/commands/connect_run.go +++ b/internal/commands/connect_run.go @@ -352,13 +352,17 @@ func runConnect(cmd *cobra.Command, f *connectRunFlags) error { return connector.RunAdmission(ctx, connector.AdmissionOptions{Ledger: ledger, Queue: queue, Admitter: admitter, Lines: lines, Logger: logger}) }) if !f.shadow { - // Each trusted request goes to stdout for the session that started - // the connector, and the connector is done with it. + // Each trusted request someone made of the agent is acknowledged + // with a boost, unless connect.json turns that off, then goes to + // stdout for the session that started the connector, and the + // connector is done with it. A shadow runs no handoff, so it posts + // nothing; a hold hands nothing off, so it boosts nothing. started := time.Now() runPart("handoff", func(ctx context.Context) error { return connector.RunHandoff(ctx, connector.HandoffOptions{ Ledger: ledger, Served: served.Current, Buckets: buckets, AgentID: agentID, Lines: lines, Logger: logger, Started: started, + AckContent: file.Ack().Content, Booster: connector.SDKBooster{Client: accountClient}, }) }) } diff --git a/internal/commands/connect_setup_guided.go b/internal/commands/connect_setup_guided.go index 81025dab5..dc85993ba 100644 --- a/internal/commands/connect_setup_guided.go +++ b/internal/commands/connect_setup_guided.go @@ -49,7 +49,7 @@ var connectSetupPolicyFlags = []string{ "serve", "unserve", "class", "watch-completions", "no-watch-completions", "allow-assignments-from-authorized", "allow-agent", "disallow-agent", "agent-thread-cap", "agent-daily-cap", - "allow-in-ping", "disallow-in-ping", + "allow-in-ping", "disallow-in-ping", "ack-boost", "no-ack-boost", } // connectGOOS is the platform guided setup checks before connecting. A diff --git a/internal/connector/handoff.go b/internal/connector/handoff.go index de09d13a3..999467450 100644 --- a/internal/connector/handoff.go +++ b/internal/connector/handoff.go @@ -16,10 +16,12 @@ import ( "github.com/basecamp/basecamp-cli/internal/richtext" ) -// The handoff is what the connector does with a request it trusts: it writes -// it to stdout as an event line and is done with it. Whoever reads the lines, +// The handoff is what the connector does with a request it trusts: it +// acknowledges it with a boost when someone asked for something, writes it to +// stdout as an event line, and is done with it. Whoever reads the lines, // usually the person's own Claude session through the basecamp-connect skill, -// acknowledges it, does the work and replies. The connector starts no worker. +// does the work and replies. The connector starts no worker, and the boost is +// the one thing it posts. // HandoffLine is one trusted request, as the reader gets it. It is the // instruction a worker used to pull, written out: the agent's own mention @@ -60,8 +62,9 @@ type HandoffLine struct { // comparing ids itself. Admission settles it (Snapshot.Owner); a record // admitted before it did says false. Owner bool `json:"owner"` - // Acknowledge says a person asked for something. A comment on a thread - // the agent follows, or a completion, is context and is not acknowledged. + // Acknowledge says an acknowledgement is still owed: a person asked for + // something and the connector did not boost it. A comment on a thread the + // agent follows, or a completion, is context and is not acknowledged. Acknowledge bool `json:"acknowledge"` // Go says this boosted request is the operator's go: their boost on a // post the agent declared an ask (`basecamp connect ask`), and the first @@ -74,6 +77,11 @@ type HandoffLine struct { // approves. The worker acts on these words, never on a fresh read of // Recording, which may have been edited since. Empty on every other line. Ask HandoffAsk `json:"ask"` + // Ack is the boost the connector acknowledged the request with, as the + // agent, before writing this line. Absent when it gave none: the request + // is not one to acknowledge, the acknowledgement is turned off, or the + // boost failed, and then the reader acknowledges it as before. + Ack *HandoffAck `json:"ack,omitempty"` Content string `json:"content"` ContentUpdatedAt time.Time `json:"content_updated_at"` @@ -86,6 +94,13 @@ type HandoffAsk struct { Content string `json:"content"` } +// HandoffAck is the boost that acknowledged a request: its id, which the +// agent can update to words of its own, and what it says. +type HandoffAck struct { + BoostID int64 `json:"boost_id"` + Content string `json:"content"` +} + // HandoffRecording points at the recording the request is about. type HandoffRecording struct { BucketID int64 `json:"bucket_id"` @@ -129,8 +144,22 @@ type HandoffOptions struct { // Interval is how often admitted records are looked for. Zero means // DefaultHandoffInterval. Interval time.Duration + // AckContent is the boost a request someone made of the agent is + // acknowledged with, through Booster, before its line is written. Empty + // turns the acknowledgement off. + AckContent string + Booster Booster } +// Booster boosts a recording as the agent and returns the boost's id. +type Booster interface { + Boost(ctx context.Context, recordingID int64, content string) (int64, error) +} + +// AckTimeout bounds the acknowledgement boost, so a Basecamp that does not +// answer delays a request's line by this much and no more. +const AckTimeout = 10 * time.Second + func (o HandoffOptions) log() *slog.Logger { if o.Logger == nil { return slog.New(slog.DiscardHandler) @@ -226,12 +255,68 @@ func handOff(ctx context.Context, opts HandoffOptions, record Record) error { opts.log().Warn("connector: a request could not be handed off", "event_id", record.ID, "error", err) return opts.Ledger.SetState(ctx, record.ID, StateDiscarded, ReasonUnreadable) } + if line.Ack, err = acknowledge(ctx, opts, record); err != nil { + return err + } + // A hold can land while the boost is in flight. A record it moved is not + // handed off; its ack stays recorded for a later redispatch to reuse. + current, ok, err := opts.Ledger.Get(ctx, record.ID) + if err != nil { + return err + } + if !ok || current.State != record.State { + opts.log().Info("connector: a request that changed state while it was acknowledged is not handed off", "event_id", record.ID, "state", current.State) + return nil + } + // A reader that predates ack boosts whenever acknowledge says so, which + // would put a second boost beside the connector's. + line.Acknowledge = line.Acknowledge && line.Ack == nil if err := opts.Lines.WriteLine(line); err != nil { return fmt.Errorf("connector: hand off event %d: %w", record.ID, err) } return opts.Ledger.SetState(ctx, record.ID, StateDiscarded, ReasonHandedOff) } +// acknowledge boosts the recording of a request someone made of the agent, +// once, and returns the boost for its line. A request handed off again after +// a crash gets the boost the ledger recorded rather than another. +// +// A boost that failed is no acknowledgement and stops nothing: the line goes +// out without one, and its reader acknowledges instead. It is never retried: +// a request that failed may still have landed, and a second would post the +// boost twice. Only the ledger failing is an error. +func acknowledge(ctx context.Context, opts HandoffOptions, record Record) (*HandoffAck, error) { + if !record.Decision.Acknowledge { + return nil, nil + } + // A boost already given is the acknowledgement even if boosting has since + // been turned off: without it on the line, the reader would boost again. + if ack, ok, err := opts.Ledger.ack(ctx, record.ID); err != nil || ok { + return &ack, err + } + if opts.AckContent == "" || opts.Booster == nil { + return nil, nil + } + // A shutdown does not cut a boost off midway, nor its recording: one cut + // off may have landed unrecorded, and the next run would boost again. The + // recording has a deadline of its own, so a boost that used most of its + // budget still gets written. + boostCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), AckTimeout) + defer cancel() + id, err := opts.Booster.Boost(boostCtx, record.RecordingID, opts.AckContent) + if err != nil { + opts.log().Warn("connector: the acknowledgement boost failed; the request is handed off without it", "event_id", record.ID, "error", err) + return nil, nil + } + ack := HandoffAck{BoostID: id, Content: richtext.SanitizeTerminal(opts.AckContent)} + recordCtx, cancelRecord := context.WithTimeout(context.WithoutCancel(ctx), AckTimeout) + defer cancelRecord() + if err := opts.Ledger.recordAck(recordCtx, record.ID, ack); err != nil { + return nil, err + } + return &ack, nil +} + // handoffLine is a record as the reader gets it. func handoffLine(record Record, agentID int64, goes bool) (HandoffLine, error) { var snapshot struct { diff --git a/internal/connector/handoff_ack_test.go b/internal/connector/handoff_ack_test.go new file mode 100644 index 000000000..afdf04607 --- /dev/null +++ b/internal/connector/handoff_ack_test.go @@ -0,0 +1,237 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log/slog" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/connector/admission" + "github.com/basecamp/basecamp-cli/internal/connector/ndjson" +) + +// fakeBooster is Basecamp's boost endpoint: it records each boost, and what +// had been written to stdout when it was asked. +type fakeBooster struct { + out *bytes.Buffer + err error + boosts []fakeBoost + // during runs while a boost is in flight. + during func() +} + +type fakeBoost struct { + RecordingID int64 + Content string + // Written is what stdout held when the boost was asked for. + Written string +} + +func (b *fakeBooster) Boost(_ context.Context, recordingID int64, content string) (int64, error) { + b.boosts = append(b.boosts, fakeBoost{RecordingID: recordingID, Content: content, Written: b.out.String()}) + if b.during != nil { + b.during() + } + if b.err != nil { + return 0, b.err + } + return 9000 + int64(len(b.boosts)), nil +} + +func ackOptions(ledger *Ledger, out *bytes.Buffer) (HandoffOptions, *fakeBooster) { + booster := &fakeBooster{out: out} + opts := handoffOptions(ledger, out) + opts.AckContent = "👀" + opts.Booster = booster + return opts, booster +} + +// A request someone made of the agent is boosted on its recording, as the +// agent, before its line is written, and the line says which boost. +func TestARequestIsAcknowledgedBeforeItsLineIsWritten(t *testing.T) { + ledger := newTestLedger(t) + admitOn(t, ledger, 1, "recording:1") + var out bytes.Buffer + opts, booster := ackOptions(ledger, &out) + + require.NoError(t, handOffReady(context.Background(), opts)) + + require.Len(t, booster.boosts, 1) + assert.Equal(t, fakeBoost{RecordingID: 10304028972, Content: "👀"}, booster.boosts[0], "the request's own recording, before anything was written") + lines := handedOffLines(t, &out) + require.Len(t, lines, 1) + assert.Equal(t, &HandoffAck{BoostID: 9001, Content: "👀"}, lines[0].Ack) + assert.False(t, lines[0].Acknowledge, "no acknowledgement is owed, so a reader that predates ack does not boost again") + assert.Equal(t, ReasonHandedOff, getRecord(t, ledger, 1).Reason) +} + +// With the acknowledgement off, nothing is posted and the line has no ack. +func TestNoAcknowledgementWhenItIsTurnedOff(t *testing.T) { + ledger := newTestLedger(t) + admitOn(t, ledger, 1, "recording:1") + var out bytes.Buffer + opts, booster := ackOptions(ledger, &out) + opts.AckContent = "" + + require.NoError(t, handOffReady(context.Background(), opts)) + + assert.Empty(t, booster.boosts) + lines := handedOffLines(t, &out) + require.Len(t, lines, 1) + assert.True(t, lines[0].Acknowledge, "the reader still owes the acknowledgement") + assert.NotContains(t, out.String(), `"ack"`) +} + +// A boost that fails stops nothing: the request is handed off without an +// ack, for its reader to acknowledge, and the failure is logged. It is not +// tried again. +func TestAFailedAcknowledgementStillHandsTheRequestOff(t *testing.T) { + ledger := newTestLedger(t) + admitOn(t, ledger, 1, "recording:1") + var out, logs bytes.Buffer + opts, booster := ackOptions(ledger, &out) + booster.err = errors.New("archived Pings refuse agents") + opts.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + + require.NoError(t, handOffReady(context.Background(), opts)) + + assert.Len(t, booster.boosts, 1, "a failed boost may have landed, so it is never retried") + lines := handedOffLines(t, &out) + require.Len(t, lines, 1) + assert.Nil(t, lines[0].Ack) + assert.True(t, lines[0].Acknowledge, "the reader still owes the acknowledgement") + assert.NotContains(t, out.String(), `"ack"`) + assert.Contains(t, logs.String(), "level=WARN") + assert.Contains(t, logs.String(), "archived Pings refuse agents") + assert.Equal(t, ReasonHandedOff, getRecord(t, ledger, 1).Reason) +} + +// A request handed off again, after a run stopped between its boost and +// closing its record, carries the boost it already got rather than another. +// Stdout failing is that stop: the boost is given, and the record is left +// open as a crash leaves it. +func TestARequestHandedOffAgainIsNotBoostedTwice(t *testing.T) { + ledger := newTestLedger(t) + admitOn(t, ledger, 1, "recording:1") + var out bytes.Buffer + opts, booster := ackOptions(ledger, &out) + + crashed := opts + crashed.Lines = ndjson.NewWriter(failingWriter{}) + require.Error(t, handOffReady(context.Background(), crashed)) + require.Len(t, booster.boosts, 1) + assert.Equal(t, StateAdmitted, getRecord(t, ledger, 1).State, "left to be handed off again") + + require.NoError(t, handOffReady(context.Background(), opts)) + + assert.Len(t, booster.boosts, 1, "the boost already given is reused") + lines := handedOffLines(t, &out) + require.Len(t, lines, 1) + assert.Equal(t, &HandoffAck{BoostID: 9001, Content: "👀"}, lines[0].Ack) +} + +// Context is not a request: a comment on a followed thread, a completion and +// a boost are handed off unacknowledged. +func TestContextIsNotAcknowledged(t *testing.T) { + for _, trigger := range []admission.Trigger{admission.TriggerSubscribed, admission.TriggerCompleted, admission.TriggerBoosted} { + t.Run(string(trigger), func(t *testing.T) { + ledger := newTestLedger(t) + seenRecord(t, ledger, 1) + v := admittedVerdict(1, 0, "recording:1") + v.Trigger, v.Acknowledge = trigger, false + _, err := ledger.Admission().Commit(context.Background(), v) + require.NoError(t, err) + var out bytes.Buffer + opts, booster := ackOptions(ledger, &out) + + require.NoError(t, handOffReady(context.Background(), opts)) + + assert.Empty(t, booster.boosts) + lines := handedOffLines(t, &out) + require.Len(t, lines, 1) + assert.Nil(t, lines[0].Ack) + }) + } +} + +// A hold hands nothing off, so it boosts nothing either. +func TestAHoldAcknowledgesNothing(t *testing.T) { + ledger := newTestLedger(t) + _, err := ledger.SetHold(context.Background(), "operator", HoldByOperator) + require.NoError(t, err) + admitOn(t, ledger, 1, "recording:1") + var out bytes.Buffer + opts, booster := ackOptions(ledger, &out) + + require.NoError(t, handOffReady(context.Background(), opts)) + + assert.Empty(t, booster.boosts) + assert.Empty(t, out.String()) +} + +// The line's ack is the shape the skill reads. +func TestTheAckOnTheLineIsItsBoostIDAndContent(t *testing.T) { + ledger := newTestLedger(t) + admitOn(t, ledger, 1, "recording:1") + var out bytes.Buffer + opts, _ := ackOptions(ledger, &out) + + require.NoError(t, handOffReady(context.Background(), opts)) + + var line map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out.String())), &line)) + assert.JSONEq(t, `{"boost_id":9001,"content":"👀"}`, string(line["ack"])) +} + +// A hold that lands while the boost is in flight keeps the request from +// being handed off, and the connector running; the boost stays recorded for +// a later redispatch rather than being given again. +func TestAHoldDuringTheAcknowledgementStopsTheHandoff(t *testing.T) { + ledger := newTestLedger(t) + admitOn(t, ledger, 1, "recording:1") + var out bytes.Buffer + opts, booster := ackOptions(ledger, &out) + booster.during = func() { + _, err := ledger.SetHold(context.Background(), "operator", HoldByOperator) + require.NoError(t, err) + } + + require.NoError(t, handOffReady(context.Background(), opts)) + + assert.Len(t, booster.boosts, 1) + assert.Empty(t, strings.TrimSpace(out.String()), "nothing is handed off once held") + assert.Equal(t, StateHeld, getRecord(t, ledger, 1).State) + ack, ok, err := ledger.ack(context.Background(), 1) + require.NoError(t, err) + require.True(t, ok, "the boost given is recorded") + assert.Equal(t, int64(9001), ack.BoostID) +} + +// A request handed off again after a crash carries the boost it got even when +// the restart turns boosting off, so its reader does not boost a second time. +func TestABoostAlreadyGivenSurvivesTurningAcknowledgementOff(t *testing.T) { + ledger := newTestLedger(t) + admitOn(t, ledger, 1, "recording:1") + var out bytes.Buffer + opts, booster := ackOptions(ledger, &out) + + crashed := opts + crashed.Lines = ndjson.NewWriter(failingWriter{}) + require.Error(t, handOffReady(context.Background(), crashed)) + require.Len(t, booster.boosts, 1) + + opts.AckContent = "" + require.NoError(t, handOffReady(context.Background(), opts)) + + assert.Len(t, booster.boosts, 1, "no second boost") + lines := handedOffLines(t, &out) + require.Len(t, lines, 1) + assert.Equal(t, &HandoffAck{BoostID: 9001, Content: "👀"}, lines[0].Ack) + assert.False(t, lines[0].Acknowledge, "nothing is owed") +} diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go index 7710e2fad..28d7f4b7b 100644 --- a/internal/connector/ledger.go +++ b/internal/connector/ledger.go @@ -951,6 +951,11 @@ END; // operator for a go, and which event's boost took each one's go. See // ledger_asks.go. migrationAsks, + + // Migration 16. Acks: the boost the connector acknowledged each request + // with, so a request handed off again gets the same one. See + // ledger_acks.go. + migrationAcks, } // migrationBlockedRetrySchedule is migration 14. diff --git a/internal/connector/ledger_acks.go b/internal/connector/ledger_acks.go new file mode 100644 index 000000000..2e7480645 --- /dev/null +++ b/internal/connector/ledger_acks.go @@ -0,0 +1,54 @@ +package connector + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +// An ack is the boost the connector gave a request's recording, as the agent, +// to acknowledge it before handing it off. It is written once the boost +// lands and before the request's line is, so a line printed again after a +// crash (see handOff) carries the boost already given rather than giving a +// second one. +// +// What it cannot cover is a crash between Basecamp creating the boost and +// this row being written: the next run boosts again. That window is one +// local write wide, and it is the same double a boost that timed out but +// landed makes, which the session's fallback already risks. +const migrationAcks = ` +CREATE TABLE acks ( + event_id INTEGER PRIMARY KEY REFERENCES events (id), + boost_id INTEGER NOT NULL CHECK (boost_id > 0), + content TEXT NOT NULL CHECK (content <> ''), + boosted_at TEXT NOT NULL +); +` + +// recordAck records the boost that acknowledged eventID. A second record +// for one event keeps the first. +func (l *Ledger) recordAck(ctx context.Context, eventID int64, ack HandoffAck) error { + return retryBusy(func() error { + _, err := l.db.ExecContext(ctx, + `INSERT INTO acks (event_id, boost_id, content, boosted_at) VALUES (?, ?, ?, ?) ON CONFLICT (event_id) DO NOTHING`, + eventID, ack.BoostID, ack.Content, l.timestamp()) + if err != nil { + return fmt.Errorf("connector: record the acknowledgement of %d: %w", eventID, err) + } + return nil + }) +} + +// ack is the boost that acknowledged eventID, if one did. +func (l *Ledger) ack(ctx context.Context, eventID int64) (HandoffAck, bool, error) { + var ack HandoffAck + err := l.db.QueryRowContext(ctx, `SELECT boost_id, content FROM acks WHERE event_id = ?`, eventID).Scan(&ack.BoostID, &ack.Content) + if errors.Is(err, sql.ErrNoRows) { + return HandoffAck{}, false, nil + } + if err != nil { + return HandoffAck{}, false, fmt.Errorf("connector: read the acknowledgement of %d: %w", eventID, err) + } + return ack, true, nil +} diff --git a/internal/connector/request_contract_test.go b/internal/connector/request_contract_test.go index 43c095054..f4a68ae3e 100644 --- a/internal/connector/request_contract_test.go +++ b/internal/connector/request_contract_test.go @@ -20,6 +20,8 @@ import ( // dropping one breaks a reader that nothing here runs, so it is a breaking // change made on purpose, here, or not at all. Adding a key is not. var requestLineContract = []string{ + "ack.boost_id", + "ack.content", "acknowledge", "ask.content", "ask.title", @@ -47,7 +49,7 @@ var requestLineContract = []string{ // optionalKeys are the contract's keys a line may leave out: the ones whose // value is not always known. -var optionalKeys = []string{"recording.project_name", "requester_name"} +var optionalKeys = []string{"ack.boost_id", "ack.content", "recording.project_name", "requester_name"} // The line the connector writes carries exactly the keys the contract names, // under those names: a key dropped or renamed breaks a reader, and a key @@ -100,6 +102,9 @@ func fill(t *testing.T, v reflect.Value) { return } switch v.Kind() { + case reflect.Pointer: + v.Set(reflect.New(v.Type().Elem())) + fill(t, v.Elem()) case reflect.Struct: for i := range v.NumField() { if v.Type().Field(i).IsExported() { diff --git a/internal/connector/sdk_booster.go b/internal/connector/sdk_booster.go new file mode 100644 index 000000000..f13a25f41 --- /dev/null +++ b/internal/connector/sdk_booster.go @@ -0,0 +1,29 @@ +package connector + +import ( + "context" + "errors" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" +) + +// SDKBooster boosts a recording as the agent the client authenticates as. +// The SDK sends a boost once and never retries it, since a retried POST can +// post twice. +type SDKBooster struct { + Client *basecamp.AccountClient +} + +var _ Booster = SDKBooster{} + +// Boost implements Booster. +func (b SDKBooster) Boost(ctx context.Context, recordingID int64, content string) (int64, error) { + boost, err := b.Client.Boosts().CreateRecording(ctx, recordingID, content) + if err != nil { + return 0, err + } + if boost.ID <= 0 { + return 0, errors.New("basecamp answered the boost without its id") + } + return boost.ID, nil +} diff --git a/internal/connector/setup/ack_test.go b/internal/connector/setup/ack_test.go new file mode 100644 index 000000000..7fb7b0580 --- /dev/null +++ b/internal/connector/setup/ack_test.go @@ -0,0 +1,87 @@ +//go:build unix + +package setup + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A connect.json that never set the acknowledgement boosts the default: an +// existing setup gets it on upgrade. +func TestAFileWithNoAckBoostAcknowledgesWithTheDefault(t *testing.T) { + assert.Equal(t, AckBoost{Content: "👀"}, validFile(t).Ack()) +} + +// The setting survives a save and a load, and the default is written as no +// key at all, which a CLI from before the key still opens. +func TestTheAckBoostRoundTripsAndTheDefaultIsNotWritten(t *testing.T) { + for _, tc := range []struct { + name string + change AckBoost + key bool + }{ + {"custom", AckBoost{Content: "On it"}, true}, + {"off", AckBoost{Off: true}, true}, + {"default", AckBoost{Content: DefaultAckContent}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + path, err := Path(configDir(t), "agent") + require.NoError(t, err) + f, err := Apply(validFile(t), Changes{AckBoost: &tc.change}) + require.NoError(t, err) + require.NoError(t, save(path, f)) + + data, err := os.ReadFile(path) + require.NoError(t, err) + var raw map[string]json.RawMessage + require.NoError(t, json.Unmarshal(data, &raw)) + assert.Equal(t, tc.key, raw["ack_boost"] != nil) + + loaded, err := Load(path) + require.NoError(t, err) + assert.Equal(t, tc.change, loaded.Ack()) + }) + } +} + +// Setting the default again takes a custom setting's key back out. +func TestSettingTheDefaultAgainRemovesTheKey(t *testing.T) { + f, err := Apply(validFile(t), Changes{AckBoost: &AckBoost{Off: true}}) + require.NoError(t, err) + f, err = Apply(f, Changes{AckBoost: &AckBoost{Content: DefaultAckContent}}) + require.NoError(t, err) + assert.Nil(t, f.AckBoost) +} + +// Basecamp takes a boost of at most 16 characters, counted as runes, and an +// empty one is no acknowledgement; neither is set, and neither is read. +func TestAnAckBoostBasecampWouldRefuseIsRefused(t *testing.T) { + sixteen := strings.Repeat("👀", 16) + _, err := Apply(validFile(t), Changes{AckBoost: &AckBoost{Content: sixteen}}) + require.NoError(t, err, "16 runes is 64 bytes, and still fits") + + for name, ack := range map[string]AckBoost{ + "seventeen runes": {Content: sixteen + "!"}, + "empty": {Content: ""}, + "blank": {Content: " "}, + "off with content": {Off: true, Content: "👀"}, + } { + t.Run(name, func(t *testing.T) { + _, err := Apply(validFile(t), Changes{AckBoost: &ack}) + require.Error(t, err) + + f := validFile(t) + f.AckBoost = &ack + data, err := json.Marshal(f) + require.NoError(t, err) + _, err = Parse(data) + require.Error(t, err, "a hand-edited file is refused too") + }) + } +} diff --git a/internal/connector/setup/apply.go b/internal/connector/setup/apply.go index 091a2b3ec..844bb60ab 100644 --- a/internal/connector/setup/apply.go +++ b/internal/connector/setup/apply.go @@ -51,6 +51,10 @@ type Changes struct { WatchCompletions map[int64]bool // Remove stops serving projects. Remove []int64 + + // AckBoost sets how the connector acknowledges a request; nil keeps the + // file's. + AckBoost *AckBoost } // PingPerson is a person in one Ping. @@ -83,6 +87,18 @@ func Apply(f File, ch Changes) (File, error) { if out.Pings, err = applyPings(f.Pings, ch); err != nil { return File{}, err } + if ch.AckBoost != nil { + if err := ch.AckBoost.Validate(); err != nil { + return File{}, fmt.Errorf("--ack-boost: %w", err) + } + // The default is written as no key at all, which a CLI that + // predates the key still reads. + out.AckBoost = nil + if *ch.AckBoost != (AckBoost{Content: DefaultAckContent}) { + ack := *ch.AckBoost + out.AckBoost = &ack + } + } for _, id := range ch.Remove { if slices.Contains(ch.Serve, id) { diff --git a/internal/connector/setup/checks.go b/internal/connector/setup/checks.go index ea9c294aa..dab980495 100644 --- a/internal/connector/setup/checks.go +++ b/internal/connector/setup/checks.go @@ -389,8 +389,8 @@ func verifyPerson(ctx context.Context, r Reader, name string, p Person, agentID // anchor by hand — so it is written, and warned about. func ProjectChecks(ctx context.Context, r Reader, f File, firstSetup bool) []Check { if len(f.Projects) == 0 { - // Nothing in an unserved project is handed off, and the connector - // posts nothing itself, so nobody is told. + // Nothing in an unserved project is handed off or acknowledged, so + // nobody is told. c := Check{ Name: "Projects", Status: StatusWarn, diff --git a/internal/connector/setup/file.go b/internal/connector/setup/file.go index 6b5a05434..63bee6c38 100644 --- a/internal/connector/setup/file.go +++ b/internal/connector/setup/file.go @@ -41,7 +41,9 @@ import ( "regexp" "slices" "strconv" + "strings" "time" + "unicode/utf8" "github.com/basecamp/basecamp-cli/internal/auth" "github.com/basecamp/basecamp-cli/internal/connector/admission" @@ -84,6 +86,12 @@ type File struct { // nobody in, so a file that uses none still opens in a CLI that predates // the key. Pings map[int64]admission.PingTrust `json:"pings,omitempty"` + // AckBoost is how the connector acknowledges a request someone made of + // the agent (--ack-boost, --no-ack-boost). Absent means the default, a + // DefaultAckContent boost, and setup writes it only when it is not, so a + // file that keeps the default still opens in a CLI that predates the + // key. + AckBoost *AckBoost `json:"ack_boost,omitempty"` // The Legacy fields are the settings of a connector that ran its own // workers: which driver and coding agent, how many at once, for how @@ -124,6 +132,49 @@ type Agent struct { IdentityID int64 `json:"identity_id,omitempty"` } +// AckBoost is the connector's acknowledgement of a request: a boost on the +// request's recording with Content, given before the request is handed off, +// or none at all when Off, which leaves acknowledging to the session. +type AckBoost struct { + Off bool `json:"off,omitempty"` + Content string `json:"content,omitempty"` +} + +// DefaultAckContent is the boost the connector acknowledges with unless +// connect.json says otherwise. +const DefaultAckContent = "👀" + +// AckContentMax is the longest boost Basecamp takes, counted in runes as +// `basecamp boost create` counts it. +const AckContentMax = 16 + +// Ack is the acknowledgement this file asks for, the default when it names +// none. +func (f File) Ack() AckBoost { + if f.AckBoost == nil { + return AckBoost{Content: DefaultAckContent} + } + return *f.AckBoost +} + +// Validate refuses an acknowledgement the connector could not give: one +// both off and with content, or a boost Basecamp would refuse. +func (a AckBoost) Validate() error { + if a.Off { + if a.Content != "" { + return errors.New("the acknowledgement boost is off and has content") + } + return nil + } + if strings.TrimSpace(a.Content) == "" { + return errors.New("the acknowledgement boost is empty; turn it off with --no-ack-boost instead") + } + if n := utf8.RuneCountInString(a.Content); n > AckContentMax { + return fmt.Errorf("the acknowledgement boost is %d characters, and a boost holds at most %d", n, AckContentMax) + } + return nil +} + // Duration is a time.Duration written as Go's duration string ("45m0s"). type Duration time.Duration @@ -230,6 +281,9 @@ func (f File) Validate() error { return fmt.Errorf("served project %d: class %q must be lowercase letters, digits, - or _, at most 40", bucket, project.Class) } } + if err := f.Ack().Validate(); err != nil { + return fmt.Errorf("connect.json ack_boost: %w", err) + } return nil } diff --git a/skill-evals/cases/basecamp-connect/project-agents-repo.yml b/skill-evals/cases/basecamp-connect/project-agents-repo.yml index 9eab6908a..33cd6fdaa 100644 --- a/skill-evals/cases/basecamp-connect/project-agents-repo.yml +++ b/skill-evals/cases/basecamp-connect/project-agents-repo.yml @@ -30,7 +30,7 @@ expect_sequence: - match: '^recordings documents(?=.*\s--all(?:\s|$))(?=.*\s--project[ =][''"]?456[''"]?(?:\s|$))(?=.*\s(?:-P|--profile)[ =][''"]?helper[''"]?(?:\s|$))' - match: '^docs show [''"]?901[''"]?(?=\s|$)(?=.*\s--project[ =][''"]?456[''"]?(?:\s|$))(?=.*\s(?:-P|--profile)[ =][''"]?helper[''"]?(?:\s|$))' reject: - - '^boost create\b' + - '^boost (create|update)\b' - '^connect setup\b' - '^(cards move|comments create|chat post)\b' accept_response: diff --git a/skills/basecamp-connect/SKILL.md b/skills/basecamp-connect/SKILL.md index a6e87b7fa..f8b3c030d 100644 --- a/skills/basecamp-connect/SKILL.md +++ b/skills/basecamp-connect/SKILL.md @@ -5,9 +5,10 @@ description: | (basecamp connect), which listens to the account's event feed as a Basecamp agent and prints one line per trusted request: a mention, an assignment, a ping from its operator, a comment on a thread the agent follows, a boost on - something it wrote. This - session acknowledges each one that asks for something within seconds (a - boost in its own words), picks the repo for the project, and hands the + something it wrote. The + connector boosts each one that asks for something the moment it arrives; + this session puts that boost in its own words, picks the repo for the + project, and hands the request to a background subagent that reads the context, does the work with your settings and tools, and replies as the agent. The session stays free to take the next request, and you can keep using it. @@ -49,10 +50,11 @@ Three pieces: - **The connector**: `basecamp connect -P ''`. It listens to the account's event feed as the agent, checks each event against who may give the agent work and which projects it serves, and prints one line per trusted - request. It does nothing else. -- **This session**: the orchestrator. It watches those lines, acknowledges each - request, chooses the repo, and dispatches one background subagent per - request. It never does the work itself. + request. It boosts each one that asks for something first, and does + nothing else. +- **This session**: the orchestrator. It watches those lines, words each + acknowledgement, chooses the repo, and dispatches one background subagent + per request. It never does the work itself. Claude Code only. The connector runs on Linux and macOS. @@ -91,10 +93,6 @@ Single-quote the path, as with every other value. Each notification is one request: handle it with *For each request*. A request that arrives while you're waiting on the person is not their reply. -The connector starts from the present. Anything asked more than a minute -before it started is not picked up. If someone says the agent ignored them, that's the first -thing to check. - ### 2. The request line ```json @@ -102,8 +100,9 @@ thing to check. "recording":{"bucket_id":456,"project_name":"BC5 Calendar","recording_id":789,"type":"Comment", "title":"Fix the date picker","url":"https://3.basecamp.com/999/buckets/456/recordings/789"}, "reply_to":{"kind":"comment","recording_id":700}, - "requester_id":1001,"requester_name":"Jorge Manrubia","role":"operator","owner":true,"ping":false,"acknowledge":true, - "go":false,"ask":{"title":"","content":""},"content":"

fix the date picker

","content_updated_at":"..."} + "requester_id":1001,"requester_name":"Jorge Manrubia","role":"operator","owner":true,"ping":false,"acknowledge":false, + "go":false,"ask":{"title":"","content":""},"ack":{"boost_id":555,"content":"👀"}, + "content":"

fix the date picker

","content_updated_at":"..."} ``` - **`trigger`**: why it reached you. @@ -115,8 +114,10 @@ thing to check. - `completed`: something completed in a project it watches. - `boosted`: a boost on the agent's own post. - `pinged`: an operator wrote to it in a direct Ping. -- **`acknowledge`**: true when a person asked for something. False for - `subscribed`, `completed` and `boosted`, context unless a `go`. +- **`acknowledge`**: true when a person asked for something and it isn't yet + acknowledged. False for `subscribed`, `completed` and `boosted`, context + unless a `go`, and when `ack` is on the line. +- **`ack`**: the connector's boost on the recording, when it gave one. - **`recording`**: what the event is about. `bucket_id` is the project, `project_name` its name. `url` works with every command below. - **`ping`**: true for a line in a Ping, a private chat in no project; @@ -187,14 +188,17 @@ refusal above, the holding reply below) post as step 5 of `subagent.md` does. ### a. Acknowledge, within seconds -Only when `acknowledge` is true. Boost the recording as the agent, with a -short phrase or emoji that fits the request (16 characters at most), never a -fixed string: +With `ack` on the line, the connector boosted `ack.content`: reword it to a +short phrase that fits the request (16 characters at most), never a fixed +string. Otherwise, only when `acknowledge` is true, boost: this loop with +`boost create ''`. A reply due within moments (a quick question) +is the acknowledgement: leave the boost, or give none, and say so in the +handoff; not for card work, where the boost shows the request landed. ```bash landed=false for i in 1 2 3; do - if out=$(basecamp boost create '' '' --profile '' 2>&1); then + if out=$(basecamp boost update '' --profile '' 2>&1); then landed=true; break fi echo "$out" | grep -qE 'Not authenticated for|token refresh failed' || break # anything else: no retry @@ -203,16 +207,11 @@ done [ "$landed" = true ] || { echo "boost did not verifiably land: $out" >&2; false; } ``` -Values go in single quotes, the acknowledgement included, with a single quote -inside one written as `'\''`: `'I'\''m on it'`. Numeric ids go in bare. -Retry only those two failures, which happen before any request is sent: any -other may have landed the boost, and a retry would post it twice. If it didn't -verifiably land, say so in the handoff; the subagent boosts as a fallback. - -When the reply will come within moments (a quick question in Campfire), the -reply itself can be the acknowledgement: skip the boost, and say so in the -handoff. Not for card work: there the boost is how the requester sees the -request landed. +Values go in single quotes, a single quote inside one written `'\''`: +`'I'\''m on it'`; numeric ids go in bare. Retry only those two failures, which +come before any request is sent; any other may have landed. If the update +fails, `ack.content` still acknowledges; if your own boost didn't verifiably +land, one is still owed. Say which in the handoff. ### b. Choose the repo @@ -285,8 +284,8 @@ to finish without this session: profile name is yours, and need not be the name the operator mentions; - the agent's profile name, and the repo path (or "no repo"). A subagent doesn't start in that repo by itself: say plainly that it must work there; -- whether an acknowledgement is still owed (the boost failed), or not owed - (it landed, or the reply is the acknowledgement); +- whether an acknowledgement is still owed (no boost landed), or not owed + (one landed, the connector's included); - the full content of the project's AGENTS.md document and any repo mapping used, or the explicit result "AGENTS.md checked: none found"; @@ -386,6 +385,8 @@ Each becomes a flag on setup (*First-time setup*, step 4): | "the people I named can assign it work too" | `--allow-assignments-from-authorized`: it covers the whole allowlist setup leaves (this run's `--allow`, or the list kept), not one person, so say who that is and confirm. With nobody on the allowlist setup refuses it, so name someone first. A run that passes `--allow` turns it off unless it's passed again | | "let Jorge talk to it, but only in our group Ping" | `--allow-in-ping :` (repeatable; `--disallow-in-ping :` takes them out). Jorge then reaches it only by @mentioning it in that Ping, as a participant, and nowhere else. Find both ids as *Who may give it work* says. Restart the connector after changing it | | "let Zach's agent ping mine" | `--allow-agent ` (repeatable; `--disallow-agent ` removes one). It wakes this agent only by @mentioning it, as a participant, never by assignment. Mentions are capped per 24 hours: `--agent-thread-cap` (default 3 per thread) and `--agent-daily-cap` (default 20 per agent). Restart the connector after changing it | +| "acknowledge with 👍 instead" | `--ack-boost '👍'` (at most 16 characters; the default is 👀). Restart the connector after changing it | +| "don't boost my requests automatically" | `--no-ack-boost`: the connector no longer boosts, and this session acknowledges each request itself (*Acknowledge*). Restart the connector after changing it | | "also work in project X" | `--serve ` | | "stop working in X" | `--unserve ` | @@ -526,6 +527,13 @@ A run that passes `--allow` replaces the list, a run that passes none keeps it, and `--trust operator` clears it; setup refuses `--trust operator` with `--allow`. +**Acknowledgement.** By default the connector boosts each request that asks +for something with 👀, as the agent, before printing it, and this session then +rewords that boost. `--ack-boost ''` changes the boost (16 characters +at most), and `--no-ack-boost` turns it off, leaving acknowledging to this +session. Neither is asked about on a first setup; pass one only when the +person wants it. + **3. Projects, by name.** The agent hears only the projects it serves, and it can serve only projects it's a member of. List its projects (`basecamp projects list -P '' --json`; if that's refused under an @@ -618,7 +626,9 @@ Requests that arrive while it's stopped are not picked up later. - `basecamp connect doctor -P ''`: the credential, the identity, the event feed and the ledger. Start here when something seems wrong. It posts nothing. -- **"Did that mention reach it?"**: search the connector's output file for the +- **"Did that mention reach it?"**: the connector starts from the present, so + anything asked more than a minute before it started is not picked up; check + that first. Then search the connector's output file for the recording id. A `"type":"request"` line means it was handed to you. A `"type":"event"` line with a `reason` says why it was turned away (for example `untrusted_performer`). No line means the feed never delivered it. diff --git a/skills/basecamp-connect/subagent.md b/skills/basecamp-connect/subagent.md index 2bb1e047f..7ca4dadd8 100644 --- a/skills/basecamp-connect/subagent.md +++ b/skills/basecamp-connect/subagent.md @@ -27,7 +27,9 @@ done ``` If it didn't verifiably land, go on and say so in your report. Skip it for -`subscribed`, `completed` and `boosted`. +`subscribed`, `completed` and `boosted`. It is owed only when no boost +landed: a request line with `ack` was already boosted by the connector, which +acknowledges it even when the session couldn't reword it. **2. Read the context.** The event is the trigger. Basecamp holds the context: