Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 45 additions & 8 deletions .opencode/plugins/chainloop-trace.ts
Original file line number Diff line number Diff line change
@@ -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"]

Expand Down Expand Up @@ -30,22 +40,43 @@ 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<string, any>) {
// 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<string, any>): Promise<HookResponse> {
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)
}
}

return {
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 ?? ""
Expand All @@ -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
Expand Down
45 changes: 45 additions & 0 deletions app/cli/internal/trace/claude/announce_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 9 additions & 3 deletions app/cli/internal/trace/claude/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,15 +152,21 @@ 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
}

resp := struct {
SystemMessage string `json:"systemMessage"`
}{SystemMessage: msg}
}{SystemMessage: "\n\n" + msg + "\n"}

return json.NewEncoder(os.Stdout).Encode(resp)
}
Expand Down Expand Up @@ -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,
},
}

Expand Down
137 changes: 137 additions & 0 deletions app/cli/internal/trace/opencode/announce_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading