Skip to content

Commit 35cd46c

Browse files
authored
feat(cli): surface the trace session link to the user (#3430)
1 parent 374a4ca commit 35cd46c

22 files changed

Lines changed: 1369 additions & 54 deletions

‎.cursor/hooks.json‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"hooks": {
3+
"afterFileEdit": [
4+
{
5+
"command": "chainloop trace hook cursor after-file-edit",
6+
"timeout": 30
7+
}
8+
],
9+
"sessionEnd": [
10+
{
11+
"command": "chainloop trace hook cursor session-end",
12+
"timeout": 30
13+
}
14+
],
15+
"sessionStart": [
16+
{
17+
"command": "chainloop trace hook cursor session-start",
18+
"timeout": 30
19+
}
20+
]
21+
},
22+
"version": 1
23+
}
Lines changed: 64 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,44 @@
11
import type { Plugin } from "@opencode-ai/plugin"
22

33
export const ChainloopTrace: Plugin = async ({ $ }) => {
4-
// The commit-msg hook links sessions to commits by cross-referencing
5-
// staged files against AI line attributions recorded by post-tool-use.
6-
// If no file-writing tools (edit, write, apply_patch) are invoked during
7-
// the session, there will be no attributions and the commit will not be
8-
// marked as AI-assisted.
94
const fileWritingTools = ["edit","write","apply_patch"]
5+
const commandTools = ["bash"]
106

11-
function filePathFromArgs(args: any): string {
12-
if (args?.filePath) return args.filePath
13-
if (args?.path) return args.path
14-
return ""
7+
function filePathsFromArgs(args: any): string[] {
8+
if (args?.filePath) return [args.filePath]
9+
if (args?.path) return [args.path]
10+
if (args?.patchText) return parsePatchPaths(args.patchText)
11+
return []
1512
}
1613

14+
// parsePatchPaths extracts affected file paths from an apply_patch
15+
// patchText payload. Each section starts with *** Add File:, *** Update
16+
// File:, or *** Delete File: followed by the path. Paths are deduplicated
17+
// while preserving first-seen order.
18+
function parsePatchPaths(patchText: string): string[] {
19+
const paths: string[] = []
20+
const seen = new Set<string>()
21+
const re = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm
22+
let m
23+
while ((m = re.exec(patchText)) !== null) {
24+
const p = m[1].trim()
25+
if (p && !seen.has(p)) {
26+
seen.add(p)
27+
paths.push(p)
28+
}
29+
}
30+
return paths
31+
}
32+
33+
// fire-and-forget: tracing must never block tool execution. If chainloop
34+
// is unavailable or errors, log to stderr and move on.
1735
async function fire(event: string, payload: Record<string, any>) {
1836
const json = JSON.stringify(payload)
19-
await $`echo ${json} | chainloop trace hook opencode ${event}`
37+
try {
38+
await $`echo ${json} | chainloop trace hook opencode ${event}`
39+
} catch (err) {
40+
console.error(`chainloop-trace: ${event} hook failed: ${err}`)
41+
}
2042
}
2143

2244
return {
@@ -31,22 +53,42 @@ export const ChainloopTrace: Plugin = async ({ $ }) => {
3153
}
3254
},
3355
"tool.execute.before": async (input, output) => {
56+
if (commandTools.includes(input.tool)) {
57+
await fire("pre-tool-use", {
58+
session_id: input.sessionID,
59+
hook_event_name: "tool.execute.before",
60+
tool_name: input.tool,
61+
})
62+
return
63+
}
3464
if (!fileWritingTools.includes(input.tool)) return
35-
await fire("pre-tool-use", {
36-
session_id: input.sessionID,
37-
hook_event_name: "tool.execute.before",
38-
tool_name: input.tool,
39-
file_path: filePathFromArgs(output.args),
40-
})
65+
for (const fp of filePathsFromArgs(output.args)) {
66+
await fire("pre-tool-use", {
67+
session_id: input.sessionID,
68+
hook_event_name: "tool.execute.before",
69+
tool_name: input.tool,
70+
file_path: fp,
71+
})
72+
}
4173
},
4274
"tool.execute.after": async (input) => {
75+
if (commandTools.includes(input.tool)) {
76+
await fire("post-tool-use", {
77+
session_id: input.sessionID,
78+
hook_event_name: "tool.execute.after",
79+
tool_name: input.tool,
80+
})
81+
return
82+
}
4383
if (!fileWritingTools.includes(input.tool)) return
44-
await fire("post-tool-use", {
45-
session_id: input.sessionID,
46-
hook_event_name: "tool.execute.after",
47-
tool_name: input.tool,
48-
file_path: filePathFromArgs(input.args),
49-
})
84+
for (const fp of filePathsFromArgs(input.args)) {
85+
await fire("post-tool-use", {
86+
session_id: input.sessionID,
87+
hook_event_name: "tool.execute.after",
88+
tool_name: input.tool,
89+
file_path: fp,
90+
})
91+
}
5092
},
5193
}
5294
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package claude
17+
18+
import (
19+
"encoding/json"
20+
"io"
21+
"os"
22+
"testing"
23+
24+
"github.com/stretchr/testify/assert"
25+
"github.com/stretchr/testify/require"
26+
)
27+
28+
// TestAnnounceToUser pins the wire shape Claude Code expects. systemMessage
29+
// must stay top-level: nested inside hookSpecificOutput it is silently ignored.
30+
func TestAnnounceToUser(t *testing.T) {
31+
const msg = "Coding Session Available at https://app.chainloop.dev/u/chainloop/sessions/ses_1"
32+
33+
testCases := []struct {
34+
name string
35+
msg string
36+
wantEmitted bool
37+
}{
38+
{
39+
name: "a message goes out on both channels",
40+
msg: msg,
41+
wantEmitted: true,
42+
},
43+
{
44+
name: "nothing to say emits nothing",
45+
msg: "",
46+
wantEmitted: false,
47+
},
48+
}
49+
50+
for _, tc := range testCases {
51+
t.Run(tc.name, func(t *testing.T) {
52+
out := captureStdout(t, func() {
53+
require.NoError(t, New().AnnounceToUser(tc.msg))
54+
})
55+
56+
if !tc.wantEmitted {
57+
assert.Empty(t, out, "no message means no stdout, so the hook stays a no-op")
58+
return
59+
}
60+
61+
var got map[string]any
62+
require.NoError(t, json.Unmarshal([]byte(out), &got))
63+
64+
assert.Equal(t, tc.msg, got["systemMessage"], "systemMessage must be top-level")
65+
66+
hookOut, ok := got["hookSpecificOutput"].(map[string]any)
67+
require.True(t, ok, "hookSpecificOutput must be present")
68+
assert.Equal(t, "PostToolUse", hookOut["hookEventName"])
69+
70+
// The model needs the message verbatim to be able to repeat it.
71+
assert.Contains(t, hookOut["additionalContext"], tc.msg)
72+
})
73+
}
74+
}
75+
76+
// captureStdout runs fn with os.Stdout redirected to a pipe and returns
77+
// everything written to it. Reads to EOF rather than into a fixed buffer: a
78+
// truncated read would corrupt the payload these tests parse as JSON.
79+
func captureStdout(t *testing.T, fn func()) string {
80+
t.Helper()
81+
82+
orig := os.Stdout
83+
r, w, err := os.Pipe()
84+
require.NoError(t, err)
85+
os.Stdout = w
86+
t.Cleanup(func() { os.Stdout = orig })
87+
88+
fn()
89+
require.NoError(t, w.Close())
90+
91+
out, err := io.ReadAll(r)
92+
require.NoError(t, err)
93+
require.NoError(t, r.Close())
94+
95+
return string(out)
96+
}

‎app/cli/internal/trace/claude/provider.go‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,12 @@ func (p *Provider) IsCommandTool(toolName string) bool {
146146
return slices.Contains(commandTools, toolName)
147147
}
148148

149+
// SupportsSystemMessage is true for Claude Code: it renders the
150+
// systemMessage field of a hook response directly to the user.
151+
func (p *Provider) SupportsSystemMessage() bool {
152+
return true
153+
}
154+
149155
// SystemMessage writes a message to stdout for Claude Code to display on session start.
150156
func (p *Provider) SystemMessage(msg string) error {
151157
if msg == "" {
@@ -159,6 +165,39 @@ func (p *Provider) SystemMessage(msg string) error {
159165
return json.NewEncoder(os.Stdout).Encode(resp)
160166
}
161167

168+
// AnnounceToUser emits a PostToolUse hook response on both of Claude Code's
169+
// delivery channels: systemMessage, which the client prints to the user
170+
// without involving the model, and additionalContext, which reaches the model
171+
// so it can repeat the message in its own reply.
172+
//
173+
// Both are used because only the second is confirmed to render in every
174+
// build. Should systemMessage prove universally reliable, dropping
175+
// additionalContext here would spare the model a turn, and this is the one
176+
// place that would have to change.
177+
func (p *Provider) AnnounceToUser(msg string) error {
178+
if msg == "" {
179+
return nil
180+
}
181+
182+
type hookSpecificOutput struct {
183+
HookEventName string `json:"hookEventName"`
184+
AdditionalContext string `json:"additionalContext,omitempty"`
185+
}
186+
187+
resp := struct {
188+
SystemMessage string `json:"systemMessage"`
189+
HookSpecificOutput hookSpecificOutput `json:"hookSpecificOutput"`
190+
}{
191+
SystemMessage: msg,
192+
HookSpecificOutput: hookSpecificOutput{
193+
HookEventName: eventPostToolUse,
194+
AdditionalContext: "Tell the user the following, including any link verbatim: " + msg,
195+
},
196+
}
197+
198+
return json.NewEncoder(os.Stdout).Encode(resp)
199+
}
200+
162201
// ParseSession parses a Claude Code session JSONL and returns structured evidence.
163202
func (p *Provider) ParseSession(_ context.Context, opts *trace.ParseOpts) (*aicodingsession.Evidence, error) {
164203
jsonlPath, err := findJSONLPath(opts.SessionDir, opts.SessionID)

‎app/cli/internal/trace/cursor/provider.go‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,19 @@ func (p *Provider) SystemMessage(_ string) error {
100100
return nil
101101
}
102102

103+
// SupportsSystemMessage is false for Cursor, so callers skip the cost of
104+
// composing a message that SystemMessage would drop.
105+
func (p *Provider) SupportsSystemMessage() bool {
106+
return false
107+
}
108+
109+
// AnnounceToUser is unsupported for Cursor: it installs only sessionStart,
110+
// sessionEnd and afterFileEdit, so no hook fires after a shell command and
111+
// there is nowhere to deliver the message.
112+
func (p *Provider) AnnounceToUser(_ string) error {
113+
return trace.ErrAnnounceUnsupported
114+
}
115+
103116
// CaptureFileSnapshot is a no-op for Cursor: the afterFileEdit hook
104117
// delivers old/new strings directly, so no pre-edit snapshot is needed.
105118
func (p *Provider) CaptureFileSnapshot(_ *state.Store, _ *trace.HookInput) error {

‎app/cli/internal/trace/opencode/provider.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,20 @@ func (p *Provider) SystemMessage(_ string) error {
164164
return nil
165165
}
166166

167+
// SupportsSystemMessage is false for opencode, so callers skip the cost of
168+
// composing a message that SystemMessage would drop.
169+
func (p *Provider) SupportsSystemMessage() bool {
170+
return false
171+
}
172+
173+
// AnnounceToUser is unsupported for OpenCode until its plugin's response
174+
// shape for surfacing a message is verified against a live session, the way
175+
// Claude Code's was. The hook after a shell command already fires, so wiring
176+
// this up later is a change to this method alone.
177+
func (p *Provider) AnnounceToUser(_ string) error {
178+
return trace.ErrAnnounceUnsupported
179+
}
180+
167181
// ParseSession reads the copied export JSON for sessionID and returns
168182
// structured evidence.
169183
func (p *Provider) ParseSession(_ context.Context, opts *trace.ParseOpts) (*aicodingsession.Evidence, error) {

‎app/cli/internal/trace/provider.go‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,19 @@ package trace
1717

1818
import (
1919
"context"
20+
"errors"
2021
"io"
2122

2223
"github.com/chainloop-dev/chainloop/app/cli/internal/trace/state"
2324
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession"
2425
)
2526

27+
// ErrAnnounceUnsupported is returned by AnnounceToUser when the agent has no
28+
// channel for showing the user a message. It means nothing was displayed, as
29+
// opposed to a delivery that was attempted and failed, so a caller holding
30+
// single-use content can keep it rather than throw it away unseen.
31+
var ErrAnnounceUnsupported = errors.New("agent cannot show messages to the user")
32+
2633
// Provider discovers and parses AI coding sessions for a specific agent.
2734
//
2835
// Providers are stateless singletons from a registry, so the state-touching
@@ -97,6 +104,22 @@ type Provider interface {
97104

98105
// SystemMessage writes a message to stdout for the agent to display on session start.
99106
SystemMessage(msg string) error
107+
108+
// SupportsSystemMessage reports whether SystemMessage reaches the user
109+
// rather than being discarded. Callers check it before assembling a
110+
// message that costs something to produce, since for agents without
111+
// such a channel that work buys nothing.
112+
SupportsSystemMessage() bool
113+
114+
// AnnounceToUser writes a hook response to stdout so the agent puts msg
115+
// in front of the user, after a shell command the agent ran. Which
116+
// channel that uses is the provider's business: agents differ in whether
117+
// they render text directly, relay it through the model, or both.
118+
//
119+
// Providers with no way to reach the user return ErrAnnounceUnsupported,
120+
// so callers can tell "shown" apart from "nothing happened" and avoid
121+
// discarding a message nobody saw.
122+
AnnounceToUser(msg string) error
100123
}
101124

102125
// HookInput represents parsed hook invocation data from an AI agent.

0 commit comments

Comments
 (0)