diff --git a/.opencode/plugins/chainloop-trace.ts b/.opencode/plugins/chainloop-trace.ts index 57befcbcf..f023ef8df 100644 --- a/.opencode/plugins/chainloop-trace.ts +++ b/.opencode/plugins/chainloop-trace.ts @@ -1,6 +1,16 @@ import type { Plugin } from "@opencode-ai/plugin" -export const ChainloopTrace: Plugin = async ({ $ }) => { +// HookResponse is what a chainloop hook prints on stdout when it has +// something for the user. It mirrors the Go hookResponse type; the two are +// one contract and have to change together. +type HookResponse = { + // message is shown directly, as a TUI toast. + message?: string + // relayToModel is appended to the tool output, so the model repeats it. + relayToModel?: string +} + +export const ChainloopTrace: Plugin = async ({ $, client }) => { const fileWritingTools = ["edit","write","apply_patch"] const commandTools = ["bash"] @@ -30,14 +40,34 @@ export const ChainloopTrace: Plugin = async ({ $ }) => { return paths } - // fire-and-forget: tracing must never block tool execution. If chainloop - // is unavailable or errors, log to stderr and move on. - async function fire(event: string, payload: Record) { + // fire runs a chainloop hook and returns whatever it asked us to show the + // user, or nothing at all, which is the common case. Tracing must never + // block tool execution, so a chainloop that is missing, that fails, or + // that prints something other than JSON is logged to stderr and otherwise + // ignored — the thrown error says which it was. + // + // .text() implies .quiet(), so the hook's JSON reply is captured instead + // of being echoed into the terminal as raw text. + async function fire(event: string, payload: Record): Promise { const json = JSON.stringify(payload) try { - await $`echo ${json} | chainloop trace hook opencode ${event}` + const stdout = (await $`echo ${json} | chainloop trace hook opencode ${event}`.text()).trim() + if (!stdout) return {} + return JSON.parse(stdout) as HookResponse } catch (err) { console.error(`chainloop-trace: ${event} hook failed: ${err}`) + return {} + } + } + + // toast puts a message in front of the user. Guarded: a headless run has + // no TUI to show it in, and a notification is never worth interrupting a + // session over. + async function toast(message: string) { + try { + await client.tui.showToast({ body: { message, variant: "info" } }) + } catch (err) { + console.error("chainloop-trace: could not show toast: " + err) } } @@ -45,7 +75,8 @@ export const ChainloopTrace: Plugin = async ({ $ }) => { event: async ({ event }) => { if (event.type === "session.created") { const sessionID = event.properties?.info?.id ?? "" - await fire("session-start", { session_id: sessionID, hook_event_name: "session.created" }) + const res = await fire("session-start", { session_id: sessionID, hook_event_name: "session.created" }) + if (res.message) await toast(res.message) } if (event.type === "session.deleted") { const sessionID = event.properties?.info?.id ?? "" @@ -71,13 +102,19 @@ export const ChainloopTrace: Plugin = async ({ $ }) => { }) } }, - "tool.execute.after": async (input) => { + "tool.execute.after": async (input, output) => { if (commandTools.includes(input.tool)) { - await fire("post-tool-use", { + const res = await fire("post-tool-use", { session_id: input.sessionID, hook_event_name: "tool.execute.after", tool_name: input.tool, }) + // A shell command may have been a git push, whose pre-push hook + // attested the session and left a link to it. Show it on both + // channels: the toast reaches the user now, the tool output reaches + // the model, whose reply outlives the toast. + if (res.message) await toast(res.message) + if (res.relayToModel) output.output = output.output + "\n\n" + res.relayToModel return } if (!fileWritingTools.includes(input.tool)) return diff --git a/app/cli/internal/trace/claude/announce_test.go b/app/cli/internal/trace/claude/announce_test.go index 02515d797..0bbac9d33 100644 --- a/app/cli/internal/trace/claude/announce_test.go +++ b/app/cli/internal/trace/claude/announce_test.go @@ -73,6 +73,51 @@ func TestAnnounceToUser(t *testing.T) { } } +// TestSystemMessage pins the blank lines Claude Code needs around a +// session-start banner. The caller hands the banner over unadorned, so if this +// framing is lost here it is lost altogether, and the banner runs straight +// into whatever the transcript showed before it. +func TestSystemMessage(t *testing.T) { + const banner = "Chainloop Trace is recording this session." + + testCases := []struct { + name string + msg string + want string + wantEmitted bool + }{ + { + name: "the banner is framed with blank lines", + msg: banner, + want: "\n\n" + banner + "\n", + wantEmitted: true, + }, + { + name: "an empty banner emits nothing, framing included", + msg: "", + wantEmitted: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, New().SystemMessage(tc.msg)) + }) + + if !tc.wantEmitted { + assert.Empty(t, out) + return + } + + var got map[string]string + require.NoError(t, json.Unmarshal([]byte(out), &got)) + + assert.Equal(t, tc.want, got["systemMessage"]) + }) + } +} + // captureStdout runs fn with os.Stdout redirected to a pipe and returns // everything written to it. Reads to EOF rather than into a fixed buffer: a // truncated read would corrupt the payload these tests parse as JSON. diff --git a/app/cli/internal/trace/claude/provider.go b/app/cli/internal/trace/claude/provider.go index 3b70ef0b6..6fe120775 100644 --- a/app/cli/internal/trace/claude/provider.go +++ b/app/cli/internal/trace/claude/provider.go @@ -152,7 +152,13 @@ func (p *Provider) SupportsSystemMessage() bool { return true } -// SystemMessage writes a message to stdout for Claude Code to display on session start. +// SystemMessage writes a message to stdout for Claude Code to display on +// session start. +// +// The blank lines around it are this client's presentation, not the message's: +// Claude Code prints a systemMessage flush against the surrounding transcript, +// so without them the banner reads as part of whatever came before. Providers +// that frame the message themselves add nothing. func (p *Provider) SystemMessage(msg string) error { if msg == "" { return nil @@ -160,7 +166,7 @@ func (p *Provider) SystemMessage(msg string) error { resp := struct { SystemMessage string `json:"systemMessage"` - }{SystemMessage: msg} + }{SystemMessage: "\n\n" + msg + "\n"} return json.NewEncoder(os.Stdout).Encode(resp) } @@ -191,7 +197,7 @@ func (p *Provider) AnnounceToUser(msg string) error { SystemMessage: msg, HookSpecificOutput: hookSpecificOutput{ HookEventName: eventPostToolUse, - AdditionalContext: "Tell the user the following, including any link verbatim: " + msg, + AdditionalContext: trace.RelayToModelInstruction + msg, }, } diff --git a/app/cli/internal/trace/opencode/announce_test.go b/app/cli/internal/trace/opencode/announce_test.go new file mode 100644 index 000000000..6a9f07ba0 --- /dev/null +++ b/app/cli/internal/trace/opencode/announce_test.go @@ -0,0 +1,137 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package opencode + +import ( + "encoding/json" + "io" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const sessionLink = "Coding Session Available at https://app.chainloop.dev/u/chainloop/sessions/ses_1" + +// TestAnnounceToUser pins the wire shape the opencode plugin parses off the +// hook's stdout. Both channels are populated: the plugin shows "message" as a +// TUI toast and appends "relayToModel" to the shell tool's output, so a +// dismissed toast is not the only chance the user gets to see the link. +func TestAnnounceToUser(t *testing.T) { + testCases := []struct { + name string + msg string + wantEmitted bool + }{ + { + name: "a message goes out on both channels", + msg: sessionLink, + wantEmitted: true, + }, + { + name: "nothing to say emits nothing", + msg: "", + wantEmitted: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, New().AnnounceToUser(tc.msg)) + }) + + if !tc.wantEmitted { + assert.Empty(t, out, "no message means no stdout, so the plugin has nothing to parse") + return + } + + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &got)) + + assert.Equal(t, tc.msg, got["message"], "the toast text must be the message verbatim") + // The model needs the message verbatim to be able to repeat it. + assert.Contains(t, got["relayToModel"], tc.msg) + }) + } +} + +// TestSystemMessage covers the session-start banner, which reaches the user as +// a toast and so goes out exactly as given — a toast supplies its own frame. +func TestSystemMessage(t *testing.T) { + const banner = "Chainloop Trace is recording this session." + + testCases := []struct { + name string + msg string + wantEmitted bool + }{ + { + name: "the banner goes out verbatim", + msg: banner, + wantEmitted: true, + }, + { + name: "an empty banner emits nothing", + msg: "", + wantEmitted: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, New().SystemMessage(tc.msg)) + }) + + if !tc.wantEmitted { + assert.Empty(t, out) + return + } + + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &got)) + + assert.Equal(t, tc.msg, got["message"]) + // The banner is not news the model has to repeat; it is shown + // once, at the top of the session, and nowhere else. + assert.NotContains(t, got, "relayToModel") + }) + } +} + +// captureStdout runs fn with os.Stdout redirected to a pipe and returns +// everything written to it. Reads to EOF rather than into a fixed buffer: a +// truncated read would corrupt the payload these tests parse as JSON. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + orig := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + t.Cleanup(func() { os.Stdout = orig }) + + fn() + require.NoError(t, w.Close()) + + out, err := io.ReadAll(r) + require.NoError(t, err) + require.NoError(t, r.Close()) + + return string(out) +} diff --git a/app/cli/internal/trace/opencode/hooks.go b/app/cli/internal/trace/opencode/hooks.go index b86929e25..45ff1e4d6 100644 --- a/app/cli/internal/trace/opencode/hooks.go +++ b/app/cli/internal/trace/opencode/hooks.go @@ -59,7 +59,17 @@ const bt = "`" // literal (Bun shell) lines are split at backtick boundaries and spliced with bt. const pluginTemplate = `import type { Plugin } from "@opencode-ai/plugin" -export const ChainloopTrace: Plugin = async ({ $ }) => { +// HookResponse is what a chainloop hook prints on stdout when it has +// something for the user. It mirrors the Go hookResponse type; the two are +// one contract and have to change together. +type HookResponse = { + // message is shown directly, as a TUI toast. + message?: string + // relayToModel is appended to the tool output, so the model repeats it. + relayToModel?: string +} + +export const ChainloopTrace: Plugin = async ({ $, client }) => { const fileWritingTools = {{FileWritingToolsArray}} const commandTools = {{CommandToolsArray}} @@ -89,14 +99,34 @@ export const ChainloopTrace: Plugin = async ({ $ }) => { return paths } - // fire-and-forget: tracing must never block tool execution. If chainloop - // is unavailable or errors, log to stderr and move on. - async function fire(event: string, payload: Record) { + // fire runs a chainloop hook and returns whatever it asked us to show the + // user, or nothing at all, which is the common case. Tracing must never + // block tool execution, so a chainloop that is missing, that fails, or + // that prints something other than JSON is logged to stderr and otherwise + // ignored — the thrown error says which it was. + // + // .text() implies .quiet(), so the hook's JSON reply is captured instead + // of being echoed into the terminal as raw text. + async function fire(event: string, payload: Record): Promise { const json = JSON.stringify(payload) try { - await $` + bt + `echo ${json} | chainloop trace hook opencode ${event}` + bt + ` + const stdout = (await $` + bt + `echo ${json} | chainloop trace hook opencode ${event}` + bt + `.text()).trim() + if (!stdout) return {} + return JSON.parse(stdout) as HookResponse } catch (err) { console.error(` + bt + `chainloop-trace: ${event} hook failed: ${err}` + bt + `) + return {} + } + } + + // toast puts a message in front of the user. Guarded: a headless run has + // no TUI to show it in, and a notification is never worth interrupting a + // session over. + async function toast(message: string) { + try { + await client.tui.showToast({ body: { message, variant: "info" } }) + } catch (err) { + console.error("chainloop-trace: could not show toast: " + err) } } @@ -104,7 +134,8 @@ export const ChainloopTrace: Plugin = async ({ $ }) => { event: async ({ event }) => { if (event.type === "session.created") { const sessionID = event.properties?.info?.id ?? "" - await fire("session-start", { session_id: sessionID, hook_event_name: "session.created" }) + const res = await fire("session-start", { session_id: sessionID, hook_event_name: "session.created" }) + if (res.message) await toast(res.message) } {{SessionEndBlock}} }, @@ -127,13 +158,19 @@ export const ChainloopTrace: Plugin = async ({ $ }) => { }) } }, - "tool.execute.after": async (input) => { + "tool.execute.after": async (input, output) => { if (commandTools.includes(input.tool)) { - await fire("post-tool-use", { + const res = await fire("post-tool-use", { session_id: input.sessionID, hook_event_name: "tool.execute.after", tool_name: input.tool, }) + // A shell command may have been a git push, whose pre-push hook + // attested the session and left a link to it. Show it on both + // channels: the toast reaches the user now, the tool output reaches + // the model, whose reply outlives the toast. + if (res.message) await toast(res.message) + if (res.relayToModel) output.output = output.output + "\n\n" + res.relayToModel return } if (!fileWritingTools.includes(input.tool)) return diff --git a/app/cli/internal/trace/opencode/hooks_test.go b/app/cli/internal/trace/opencode/hooks_test.go index 1b2e43563..55f2ab1f3 100644 --- a/app/cli/internal/trace/opencode/hooks_test.go +++ b/app/cli/internal/trace/opencode/hooks_test.go @@ -317,3 +317,33 @@ func TestPluginTemplateContainsPatchParsing(t *testing.T) { // Verify the plugin loops over paths rather than sending a single path. assert.Contains(t, content, "for (const fp of filePathsFromArgs") } + +// TestPluginTemplateSurfacesMessages pins the plugin end of the announcement +// contract. The Go side writing a message to stdout only reaches the user if +// the plugin captures that stdout and puts it on one of opencode's channels, +// and the two halves live in different languages, so nothing but this test +// catches them drifting apart. +func TestPluginTemplateSurfacesMessages(t *testing.T) { + repoRoot := t.TempDir() + p := New() + require.NoError(t, p.InstallHooks(repoRoot)) + + data, err := os.ReadFile(filepath.Join(repoRoot, settingsFile)) + require.NoError(t, err) + content := string(data) + + // stdout must be captured, not echoed: .text() implies .quiet(), so the + // hook's JSON reply never lands in the user's terminal as raw text. + assert.Contains(t, content, ".text()") + + // The plugin reads the Go hookResponse fields by name. + assert.Contains(t, content, "res.message") + assert.Contains(t, content, "res.relayToModel") + + // The two delivery channels: a TUI toast, and the shell tool's output. + assert.Contains(t, content, "client.tui.showToast") + assert.Contains(t, content, "output.output") + + // The client is only available if the plugin asks for it. + assert.Contains(t, content, "async ({ $, client })") +} diff --git a/app/cli/internal/trace/opencode/provider.go b/app/cli/internal/trace/opencode/provider.go index d72ea885b..fdfd209aa 100644 --- a/app/cli/internal/trace/opencode/provider.go +++ b/app/cli/internal/trace/opencode/provider.go @@ -17,6 +17,7 @@ package opencode import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -158,24 +159,62 @@ func (p *Provider) CleanupAfterEdit(store *state.Store, input *trace.HookInput) store.DeleteFileSnapshot(input.SessionID, input.FilePath) } -// SystemMessage is a no-op for opencode: the plugin system has no -// systemMessage channel comparable to Claude Code's SessionStart output. -func (p *Provider) SystemMessage(_ string) error { - return nil +// hookResponse is the contract between a hook invocation and the opencode +// plugin that spawned it. opencode defines no hook-response protocol of its +// own — unlike Claude Code, where the shape is the client's — so the plugin +// is the other half of this type and the two must be changed together. +// +// The plugin reads it off the hook's stdout and picks a channel per field; +// an invocation with nothing to say writes nothing at all. +type hookResponse struct { + // Message is shown to the user directly, as a TUI toast. + Message string `json:"message,omitempty"` + + // RelayToModel is appended to the output of the shell tool whose hook + // produced it, so the model can repeat the message in its own reply. + RelayToModel string `json:"relayToModel,omitempty"` +} + +// SystemMessage writes the session-start banner for the plugin to show as a +// TUI toast, which draws its own frame around whatever it is given. +func (p *Provider) SystemMessage(msg string) error { + if msg == "" { + return nil + } + + return writeHookResponse(&hookResponse{Message: msg}) } -// SupportsSystemMessage is false for opencode, so callers skip the cost of -// composing a message that SystemMessage would drop. +// SupportsSystemMessage is true for opencode: the plugin captures the hook's +// stdout and has a TUI channel to put the banner on. func (p *Provider) SupportsSystemMessage() bool { - return false + return true +} + +// AnnounceToUser hands the message to the plugin on both of its channels: a +// toast, which reaches the user without involving the model, and the shell +// tool's output, which reaches the model so it can repeat the message in its +// reply. +// +// Both are used because a toast is dismissed after a few seconds, and the +// user who has looked away is exactly the one this message exists for. The +// model's reply is what is still on screen afterwards. +func (p *Provider) AnnounceToUser(msg string) error { + if msg == "" { + return nil + } + + return writeHookResponse(&hookResponse{ + Message: msg, + RelayToModel: trace.RelayToModelInstruction + msg, + }) } -// AnnounceToUser is unsupported for OpenCode until its plugin's response -// shape for surfacing a message is verified against a live session, the way -// Claude Code's was. The hook after a shell command already fires, so wiring -// this up later is a change to this method alone. -func (p *Provider) AnnounceToUser(_ string) error { - return trace.ErrAnnounceUnsupported +// writeHookResponse emits the response as a single JSON line on stdout, which +// is reserved for it: the hook's logging goes to stderr and to the trace log +// file, so nothing else can corrupt what the plugin parses. +func writeHookResponse(resp *hookResponse) error { + return json.NewEncoder(os.Stdout).Encode(resp) } // ParseSession reads the copied export JSON for sessionID and returns diff --git a/app/cli/internal/trace/opencode/testdata/plugin_full.ts b/app/cli/internal/trace/opencode/testdata/plugin_full.ts index 57befcbcf..f023ef8df 100644 --- a/app/cli/internal/trace/opencode/testdata/plugin_full.ts +++ b/app/cli/internal/trace/opencode/testdata/plugin_full.ts @@ -1,6 +1,16 @@ import type { Plugin } from "@opencode-ai/plugin" -export const ChainloopTrace: Plugin = async ({ $ }) => { +// HookResponse is what a chainloop hook prints on stdout when it has +// something for the user. It mirrors the Go hookResponse type; the two are +// one contract and have to change together. +type HookResponse = { + // message is shown directly, as a TUI toast. + message?: string + // relayToModel is appended to the tool output, so the model repeats it. + relayToModel?: string +} + +export const ChainloopTrace: Plugin = async ({ $, client }) => { const fileWritingTools = ["edit","write","apply_patch"] const commandTools = ["bash"] @@ -30,14 +40,34 @@ export const ChainloopTrace: Plugin = async ({ $ }) => { return paths } - // fire-and-forget: tracing must never block tool execution. If chainloop - // is unavailable or errors, log to stderr and move on. - async function fire(event: string, payload: Record) { + // fire runs a chainloop hook and returns whatever it asked us to show the + // user, or nothing at all, which is the common case. Tracing must never + // block tool execution, so a chainloop that is missing, that fails, or + // that prints something other than JSON is logged to stderr and otherwise + // ignored — the thrown error says which it was. + // + // .text() implies .quiet(), so the hook's JSON reply is captured instead + // of being echoed into the terminal as raw text. + async function fire(event: string, payload: Record): Promise { const json = JSON.stringify(payload) try { - await $`echo ${json} | chainloop trace hook opencode ${event}` + const stdout = (await $`echo ${json} | chainloop trace hook opencode ${event}`.text()).trim() + if (!stdout) return {} + return JSON.parse(stdout) as HookResponse } catch (err) { console.error(`chainloop-trace: ${event} hook failed: ${err}`) + return {} + } + } + + // toast puts a message in front of the user. Guarded: a headless run has + // no TUI to show it in, and a notification is never worth interrupting a + // session over. + async function toast(message: string) { + try { + await client.tui.showToast({ body: { message, variant: "info" } }) + } catch (err) { + console.error("chainloop-trace: could not show toast: " + err) } } @@ -45,7 +75,8 @@ export const ChainloopTrace: Plugin = async ({ $ }) => { event: async ({ event }) => { if (event.type === "session.created") { const sessionID = event.properties?.info?.id ?? "" - await fire("session-start", { session_id: sessionID, hook_event_name: "session.created" }) + const res = await fire("session-start", { session_id: sessionID, hook_event_name: "session.created" }) + if (res.message) await toast(res.message) } if (event.type === "session.deleted") { const sessionID = event.properties?.info?.id ?? "" @@ -71,13 +102,19 @@ export const ChainloopTrace: Plugin = async ({ $ }) => { }) } }, - "tool.execute.after": async (input) => { + "tool.execute.after": async (input, output) => { if (commandTools.includes(input.tool)) { - await fire("post-tool-use", { + const res = await fire("post-tool-use", { session_id: input.sessionID, hook_event_name: "tool.execute.after", tool_name: input.tool, }) + // A shell command may have been a git push, whose pre-push hook + // attested the session and left a link to it. Show it on both + // channels: the toast reaches the user now, the tool output reaches + // the model, whose reply outlives the toast. + if (res.message) await toast(res.message) + if (res.relayToModel) output.output = output.output + "\n\n" + res.relayToModel return } if (!fileWritingTools.includes(input.tool)) return diff --git a/app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts b/app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts index fba6019db..b0af84d75 100644 --- a/app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts +++ b/app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts @@ -1,6 +1,16 @@ import type { Plugin } from "@opencode-ai/plugin" -export const ChainloopTrace: Plugin = async ({ $ }) => { +// HookResponse is what a chainloop hook prints on stdout when it has +// something for the user. It mirrors the Go hookResponse type; the two are +// one contract and have to change together. +type HookResponse = { + // message is shown directly, as a TUI toast. + message?: string + // relayToModel is appended to the tool output, so the model repeats it. + relayToModel?: string +} + +export const ChainloopTrace: Plugin = async ({ $, client }) => { const fileWritingTools = ["edit","write","apply_patch"] const commandTools = ["bash"] @@ -30,14 +40,34 @@ export const ChainloopTrace: Plugin = async ({ $ }) => { return paths } - // fire-and-forget: tracing must never block tool execution. If chainloop - // is unavailable or errors, log to stderr and move on. - async function fire(event: string, payload: Record) { + // fire runs a chainloop hook and returns whatever it asked us to show the + // user, or nothing at all, which is the common case. Tracing must never + // block tool execution, so a chainloop that is missing, that fails, or + // that prints something other than JSON is logged to stderr and otherwise + // ignored — the thrown error says which it was. + // + // .text() implies .quiet(), so the hook's JSON reply is captured instead + // of being echoed into the terminal as raw text. + async function fire(event: string, payload: Record): Promise { const json = JSON.stringify(payload) try { - await $`echo ${json} | chainloop trace hook opencode ${event}` + const stdout = (await $`echo ${json} | chainloop trace hook opencode ${event}`.text()).trim() + if (!stdout) return {} + return JSON.parse(stdout) as HookResponse } catch (err) { console.error(`chainloop-trace: ${event} hook failed: ${err}`) + return {} + } + } + + // toast puts a message in front of the user. Guarded: a headless run has + // no TUI to show it in, and a notification is never worth interrupting a + // session over. + async function toast(message: string) { + try { + await client.tui.showToast({ body: { message, variant: "info" } }) + } catch (err) { + console.error("chainloop-trace: could not show toast: " + err) } } @@ -45,7 +75,8 @@ export const ChainloopTrace: Plugin = async ({ $ }) => { event: async ({ event }) => { if (event.type === "session.created") { const sessionID = event.properties?.info?.id ?? "" - await fire("session-start", { session_id: sessionID, hook_event_name: "session.created" }) + const res = await fire("session-start", { session_id: sessionID, hook_event_name: "session.created" }) + if (res.message) await toast(res.message) } }, "tool.execute.before": async (input, output) => { @@ -67,13 +98,19 @@ export const ChainloopTrace: Plugin = async ({ $ }) => { }) } }, - "tool.execute.after": async (input) => { + "tool.execute.after": async (input, output) => { if (commandTools.includes(input.tool)) { - await fire("post-tool-use", { + const res = await fire("post-tool-use", { session_id: input.sessionID, hook_event_name: "tool.execute.after", tool_name: input.tool, }) + // A shell command may have been a git push, whose pre-push hook + // attested the session and left a link to it. Show it on both + // channels: the toast reaches the user now, the tool output reaches + // the model, whose reply outlives the toast. + if (res.message) await toast(res.message) + if (res.relayToModel) output.output = output.output + "\n\n" + res.relayToModel return } if (!fileWritingTools.includes(input.tool)) return diff --git a/app/cli/internal/trace/provider.go b/app/cli/internal/trace/provider.go index 2d9f6a76c..7c571afb2 100644 --- a/app/cli/internal/trace/provider.go +++ b/app/cli/internal/trace/provider.go @@ -30,6 +30,15 @@ import ( // single-use content can keep it rather than throw it away unseen. var ErrAnnounceUnsupported = errors.New("agent cannot show messages to the user") +// RelayToModelInstruction prefixes a message delivered to the user through +// the model rather than rendered directly. Agents differ in how that channel +// is spelled — Claude Code's additionalContext, opencode's tool output — but +// the instruction does not: the model reads the text as context, not as +// something to pass on, so every provider using that channel has to say so. +// Shared because tuning this wording for one agent and not the others would +// be a silent divergence in what the user ends up reading. +const RelayToModelInstruction = "Tell the user the following, including any link verbatim: " + // Provider discovers and parses AI coding sessions for a specific agent. // // Providers are stateless singletons from a registry, so the state-touching diff --git a/app/cli/internal/trace/providers/capabilities_test.go b/app/cli/internal/trace/providers/capabilities_test.go index 4465abd11..d738c8346 100644 --- a/app/cli/internal/trace/providers/capabilities_test.go +++ b/app/cli/internal/trace/providers/capabilities_test.go @@ -47,8 +47,8 @@ func TestSupportsSystemMessage(t *testing.T) { }, { provider: opencode.Name, - want: false, - why: "opencode's plugin system has no equivalent channel", + want: true, + why: "the opencode plugin reads the hook's stdout and shows the banner as a TUI toast", }, } diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index 6ed55a10b..115993c36 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -111,29 +111,53 @@ func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error return nil } - ensureSessionTracked(provider, store, repoRoot, input, log) - // Composing the banner costs a control-plane round trip, so it is only // worth doing for an agent that can put it in front of the user. Cursor - // and opencode would discard it, and the developer would have paid the - // wait for nothing. - if !provider.SupportsSystemMessage() { + // would discard it, and the developer would have paid the wait for + // nothing. + // + // Start it before tracking the session rather than after: tracking shells + // out to the agent to copy its transcript, which for opencode is a + // subprocess of its own, and neither call needs the other's result. Run in + // turn they would make the developer wait for the sum of the two. + var dashboardURL <-chan string + if provider.SupportsSystemMessage() { + dashboardURL = fetchHookDashboardURLAsync(log) + } + + ensureSessionTracked(provider, store, repoRoot, input, log) + + if dashboardURL == nil { return nil } banner := sessionStartBanner( - hookDashboardURL(log), + <-dashboardURL, config.LoadOrganizationFromYML(repoRoot), config.LoadProjectFromYML(repoRoot), ) - if err := provider.SystemMessage("\n\n" + banner + "\n"); err != nil { + // The banner goes out unadorned; how it is framed on screen is the + // provider's call, since a transcript and a toast want opposite things. + if err := provider.SystemMessage(banner); err != nil { log.Debug().Err(err).Msg("session-start: failed to send system message") } return nil } +// fetchHookDashboardURLAsync starts hookDashboardURL on its own goroutine and +// returns the channel its single result arrives on. The channel is buffered so +// the goroutine finishes even if the caller never reads it, rather than +// leaking while blocked on a send. +func fetchHookDashboardURLAsync(log zerolog.Logger) <-chan string { + ch := make(chan string, 1) + + go func() { ch <- hookDashboardURL(log) }() + + return ch +} + // hookDashboardURL asks the control plane where its web dashboard lives, so // the session banner can name the destination the evidence is bound for. // Returns an empty string when there is no dashboard, no reachable control