detached 149c6c3

fix(agent): preserve alert delivery during startup

commit
149c6c32ea6874886cf067b2762282e36f5101ce
author
brice · · 2026-08-04 08:45 UTC
parent
40856e8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +188 -47
M packages/agent/cli.go +43 -10
@@ -30,17 +30,40 @@ import (
3030
3131 // interactiveExtHooks is a tiny adapter that lets the extension
3232 // manager call back into the Interactive instance built later in
33-// runInteractive. The forward-declared *modes.Interactive is filled
34-// in immediately after manager construction.
33+// runInteractive. Alerts are buffered until the instance is attached so
34+// an extension that signals attention during startup is not silently lost.
3535 type interactiveExtHooks struct {
36- ivPtr **modes.Interactive
36+ mu sync.Mutex
37+ interactive *modes.Interactive
38+ pendingAlerts []interactiveAlert
39+}
40+
41+type interactiveAlert struct {
42+ extName string
43+ alert extproto.AlertRequest
3744 }
3845
3946 func (h *interactiveExtHooks) iv() *modes.Interactive {
40- if h == nil || h.ivPtr == nil {
47+ if h == nil {
4148 return nil
4249 }
43- return *h.ivPtr
50+ h.mu.Lock()
51+ defer h.mu.Unlock()
52+ return h.interactive
53+}
54+
55+func (h *interactiveExtHooks) attachInteractive(iv *modes.Interactive) {
56+ if h == nil || iv == nil {
57+ return
58+ }
59+ h.mu.Lock()
60+ h.interactive = iv
61+ pending := h.pendingAlerts
62+ h.pendingAlerts = nil
63+ for _, item := range pending {
64+ iv.Alert(item.extName, item.alert)
65+ }
66+ h.mu.Unlock()
4467 }
4568
4669 func (h *interactiveExtHooks) Notify(extName, level, message string) {
@@ -49,9 +72,18 @@ func (h *interactiveExtHooks) Notify(extName, level, message string) {
4972 }
5073 }
5174 func (h *interactiveExtHooks) Alert(extName string, alert extproto.AlertRequest) {
52- if iv := h.iv(); iv != nil {
53- iv.Alert(extName, alert)
75+ if h == nil {
76+ return
77+ }
78+ h.mu.Lock()
79+ if h.interactive == nil {
80+ h.pendingAlerts = append(h.pendingAlerts, interactiveAlert{extName: extName, alert: alert})
81+ h.mu.Unlock()
82+ return
5483 }
84+ iv := h.interactive
85+ h.mu.Unlock()
86+ iv.Alert(extName, alert)
5587 }
5688 func (h *interactiveExtHooks) Submit(text string) {
5789 if iv := h.iv(); iv != nil {
@@ -659,10 +691,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
659691 sharedSandbox := r.Sandbox
660692
661693 // Build the extension manager BEFORE the agent so we can fold
662- // extension-defined tools into the registry. Forward-declare iv so
663- // the host hooks adapter can dereference it after construction.
694+ // extension-defined tools into the registry. Attach the interactive
695+ // host after constructing it below so startup alerts can be buffered.
664696 var iv *modes.Interactive
665- extHooks := &interactiveExtHooks{ivPtr: &iv}
697+ extHooks := &interactiveExtHooks{}
666698 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
667699 var startupExtensionErrors []string
668700 // --ext paths first so they win against installed extensions of
@@ -1447,6 +1479,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14471479 }
14481480 },
14491481 })
1482+ extHooks.attachInteractive(iv)
14501483
14511484 // Bind the interactive TUI as the Confirmer. We deferred this
14521485 // until now because the gate is constructed before the TUI
@@ -30,17 +30,40 @@ import (
30 30
31 // interactiveExtHooks is a tiny adapter that lets the extension31 // interactiveExtHooks is a tiny adapter that lets the extension
32 // manager call back into the Interactive instance built later in32 // manager call back into the Interactive instance built later in
33-// runInteractive. The forward-declared *modes.Interactive is filled33+// runInteractive. Alerts are buffered until the instance is attached so
34-// in immediately after manager construction.34+// an extension that signals attention during startup is not silently lost.
35 type interactiveExtHooks struct {35 type interactiveExtHooks struct {
36- ivPtr **modes.Interactive36+ mu sync.Mutex
37+ interactive *modes.Interactive
38+ pendingAlerts []interactiveAlert
39+}
40+
41+type interactiveAlert struct {
42+ extName string
43+ alert extproto.AlertRequest
37 }44 }
38 45
39 func (h *interactiveExtHooks) iv() *modes.Interactive {46 func (h *interactiveExtHooks) iv() *modes.Interactive {
40- if h == nil || h.ivPtr == nil {47+ if h == nil {
41 return nil48 return nil
42 }49 }
43- return *h.ivPtr50+ h.mu.Lock()
51+ defer h.mu.Unlock()
52+ return h.interactive
53+}
54+
55+func (h *interactiveExtHooks) attachInteractive(iv *modes.Interactive) {
56+ if h == nil || iv == nil {
57+ return
58+ }
59+ h.mu.Lock()
60+ h.interactive = iv
61+ pending := h.pendingAlerts
62+ h.pendingAlerts = nil
63+ for _, item := range pending {
64+ iv.Alert(item.extName, item.alert)
65+ }
66+ h.mu.Unlock()
44 }67 }
45 68
46 func (h *interactiveExtHooks) Notify(extName, level, message string) {69 func (h *interactiveExtHooks) Notify(extName, level, message string) {
@@ -49,9 +72,18 @@ func (h *interactiveExtHooks) Notify(extName, level, message string) {
49 }72 }
50 }73 }
51 func (h *interactiveExtHooks) Alert(extName string, alert extproto.AlertRequest) {74 func (h *interactiveExtHooks) Alert(extName string, alert extproto.AlertRequest) {
52- if iv := h.iv(); iv != nil {75+ if h == nil {
53- iv.Alert(extName, alert)76+ return
77+ }
78+ h.mu.Lock()
79+ if h.interactive == nil {
80+ h.pendingAlerts = append(h.pendingAlerts, interactiveAlert{extName: extName, alert: alert})
81+ h.mu.Unlock()
82+ return
54 }83 }
84+ iv := h.interactive
85+ h.mu.Unlock()
86+ iv.Alert(extName, alert)
55 }87 }
56 func (h *interactiveExtHooks) Submit(text string) {88 func (h *interactiveExtHooks) Submit(text string) {
57 if iv := h.iv(); iv != nil {89 if iv := h.iv(); iv != nil {
@@ -659,10 +691,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
659 sharedSandbox := r.Sandbox691 sharedSandbox := r.Sandbox
660 692
661 // Build the extension manager BEFORE the agent so we can fold693 // Build the extension manager BEFORE the agent so we can fold
662- // extension-defined tools into the registry. Forward-declare iv so694+ // extension-defined tools into the registry. Attach the interactive
663- // the host hooks adapter can dereference it after construction.695+ // host after constructing it below so startup alerts can be buffered.
664 var iv *modes.Interactive696 var iv *modes.Interactive
665- extHooks := &interactiveExtHooks{ivPtr: &iv}697+ extHooks := &interactiveExtHooks{}
666 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)698 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
667 var startupExtensionErrors []string699 var startupExtensionErrors []string
668 // --ext paths first so they win against installed extensions of700 // --ext paths first so they win against installed extensions of
@@ -1447,6 +1479,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1447 }1479 }
1448 },1480 },
1449 })1481 })
1482+ extHooks.attachInteractive(iv)
1450 1483
1451 // Bind the interactive TUI as the Confirmer. We deferred this1484 // Bind the interactive TUI as the Confirmer. We deferred this
1452 // until now because the gate is constructed before the TUI1485 // until now because the gate is constructed before the TUI
A packages/agent/cli_alert_test.go +30 -0
@@ -0,0 +1,30 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/agent/extproto"
7+ "github.com/patriceckhart/zot/packages/agent/modes"
8+)
9+
10+func TestInteractiveExtHooksBufferStartupAlerts(t *testing.T) {
11+ hooks := &interactiveExtHooks{}
12+ hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
13+
14+ hooks.mu.Lock()
15+ pending := len(hooks.pendingAlerts)
16+ hooks.mu.Unlock()
17+ if pending != 1 {
18+ t.Fatalf("pending startup alerts = %d, want 1", pending)
19+ }
20+
21+ iv := &modes.Interactive{}
22+ hooks.attachInteractive(iv)
23+ hooks.mu.Lock()
24+ pending = len(hooks.pendingAlerts)
25+ attached := hooks.interactive == iv
26+ hooks.mu.Unlock()
27+ if pending != 0 || !attached {
28+ t.Fatalf("after attach: pending=%d attached=%v, want no pending alert and attached host", pending, attached)
29+ }
30+}
@@ -0,0 +1,30 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/agent/extproto"
7+ "github.com/patriceckhart/zot/packages/agent/modes"
8+)
9+
10+func TestInteractiveExtHooksBufferStartupAlerts(t *testing.T) {
11+ hooks := &interactiveExtHooks{}
12+ hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
13+
14+ hooks.mu.Lock()
15+ pending := len(hooks.pendingAlerts)
16+ hooks.mu.Unlock()
17+ if pending != 1 {
18+ t.Fatalf("pending startup alerts = %d, want 1", pending)
19+ }
20+
21+ iv := &modes.Interactive{}
22+ hooks.attachInteractive(iv)
23+ hooks.mu.Lock()
24+ pending = len(hooks.pendingAlerts)
25+ attached := hooks.interactive == iv
26+ hooks.mu.Unlock()
27+ if pending != 0 || !attached {
28+ t.Fatalf("after attach: pending=%d attached=%v, want no pending alert and attached host", pending, attached)
29+ }
30+}
M packages/agent/modes/interactive.go +5 -9
@@ -3014,21 +3014,17 @@ func (i *Interactive) emitAlertLocked(alert extproto.AlertRequest) {
30143014 _ = tui.WriteBell(i.cfg.Terminal)
30153015 }
30163016
3017-// scheduleMainAlert emits a main-session alert now when no paced text remains,
3018-// or defers it until the pacer drains and the next redraw commits the final frame.
3017+// scheduleMainAlert defers a main-session alert until the next redraw
3018+// commits the final frame. Keeping this deferred even when no paced text is
3019+// pending avoids racing the pacer's final state transition.
30193020 func (i *Interactive) scheduleMainAlert(reason string) {
30203021 if reason == "" {
30213022 return
30223023 }
30233024 i.mu.Lock()
3024- if len(i.streamPending) > 0 {
3025- i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason}
3026- i.mu.Unlock()
3027- i.invalidate()
3028- return
3029- }
3030- i.emitAlertLocked(extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason})
3025+ i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason}
30313026 i.mu.Unlock()
3027+ i.invalidate()
30323028 }
30333029
30343030 // ClearNotes removes every note line owned by extName from the
@@ -3014,21 +3014,17 @@ func (i *Interactive) emitAlertLocked(alert extproto.AlertRequest) {
3014 _ = tui.WriteBell(i.cfg.Terminal)3014 _ = tui.WriteBell(i.cfg.Terminal)
3015 }3015 }
3016 3016
3017-// scheduleMainAlert emits a main-session alert now when no paced text remains,3017+// scheduleMainAlert defers a main-session alert until the next redraw
3018-// or defers it until the pacer drains and the next redraw commits the final frame.3018+// commits the final frame. Keeping this deferred even when no paced text is
3019+// pending avoids racing the pacer's final state transition.
3019 func (i *Interactive) scheduleMainAlert(reason string) {3020 func (i *Interactive) scheduleMainAlert(reason string) {
3020 if reason == "" {3021 if reason == "" {
3021 return3022 return
3022 }3023 }
3023 i.mu.Lock()3024 i.mu.Lock()
3024- if len(i.streamPending) > 0 {3025+ i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason}
3025- i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason}
3026- i.mu.Unlock()
3027- i.invalidate()
3028- return
3029- }
3030- i.emitAlertLocked(extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason})
3031 i.mu.Unlock()3026 i.mu.Unlock()
3027+ i.invalidate()
3032 }3028 }
3033 3029
3034 // ClearNotes removes every note line owned by extName from the3030 // ClearNotes removes every note line owned by extName from the
M packages/agent/rpc.go +89 -27
@@ -49,10 +49,22 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
4949 return err
5050 }
5151
52+ // Construct the RPC event sink before extension startup. Alerts can
53+ // arrive as soon as an extension is ready; rpcServer queues them until
54+ // the command loop has started and, when configured, authenticated.
55+ server := &rpcServer{
56+ ctx: ctx,
57+ args: args,
58+ provider: r.Provider,
59+ model: r.Model,
60+ out: os.Stdout,
61+ version: version,
62+ }
63+
5264 // Extensions: same lifecycle as interactive mode, minus the
5365 // host-hooks integration. Notify/Display calls from extensions
5466 // emit RPC events instead of TUI lines so any consumer can react.
55- extHooks := &rpcExtHooks{}
67+ extHooks := &rpcExtHooks{server: server}
5668 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
5769 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
5870 fmt.Fprintln(os.Stderr, "extension load:", e)
@@ -67,6 +79,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
6779 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
6880
6981 ag := r.NewAgent()
82+ server.agent = ag
7083 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
7184 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
7285 if r.Block {
@@ -103,16 +116,6 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
103116
104117 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
105118
106- server := &rpcServer{
107- ctx: ctx,
108- args: args,
109- agent: ag,
110- provider: r.Provider,
111- model: r.Model,
112- out: os.Stdout,
113- version: version,
114- }
115- extHooks.server = server
116119 return server.run(os.Stdin)
117120 }
118121
@@ -126,7 +129,7 @@ type rpcExtHooks struct {
126129
127130 func (h *rpcExtHooks) Notify(extName, level, message string) {
128131 if h.server != nil {
129- h.server.writeEvent(map[string]any{
132+ h.server.writeExtensionEvent(map[string]any{
130133 "type": "ext_notify",
131134 "extension": extName,
132135 "level": level,
@@ -136,7 +139,7 @@ func (h *rpcExtHooks) Notify(extName, level, message string) {
136139 }
137140 func (h *rpcExtHooks) Alert(extName string, alert extproto.AlertRequest) {
138141 if h.server != nil {
139- h.server.writeEvent(map[string]any{
142+ h.server.writeExtensionEvent(map[string]any{
140143 "type": "ext_alert",
141144 "extension": extName,
142145 "kind": alert.Kind,
@@ -146,7 +149,7 @@ func (h *rpcExtHooks) Alert(extName string, alert extproto.AlertRequest) {
146149 }
147150 func (h *rpcExtHooks) Display(extName, text string) {
148151 if h.server != nil {
149- h.server.writeEvent(map[string]any{
152+ h.server.writeExtensionEvent(map[string]any{
150153 "type": "ext_display",
151154 "extension": extName,
152155 "text": text,
@@ -155,7 +158,7 @@ func (h *rpcExtHooks) Display(extName, text string) {
155158 }
156159 func (h *rpcExtHooks) ClearNotes(extName string) {
157160 if h.server != nil {
158- h.server.writeEvent(map[string]any{
161+ h.server.writeExtensionEvent(map[string]any{
159162 "type": "ext_clear_notes",
160163 "extension": extName,
161164 })
@@ -177,10 +180,12 @@ type rpcServer struct {
177180 out io.Writer
178181 version string
179182
180- writeMu sync.Mutex
181- turnMu sync.Mutex // serialises one prompt at a time
182- activeCancel context.CancelFunc
183- authed bool
183+ writeMu sync.Mutex
184+ turnMu sync.Mutex // serialises one prompt at a time
185+ activeCancel context.CancelFunc
186+ started bool
187+ authed bool
188+ pendingExtEvents [][]byte
184189
185190 // inFlight tracks long-running command goroutines so run() can
186191 // wait for them before returning when stdin closes. Without this,
@@ -196,7 +201,13 @@ type rpcServer struct {
196201 // still produces full output before the process exits.
197202 func (s *rpcServer) run(in io.Reader) error {
198203 requireToken := os.Getenv("ZOTCORE_RPC_TOKEN") != ""
204+ s.writeMu.Lock()
205+ s.started = true
199206 s.authed = !requireToken
207+ if s.authed {
208+ s.flushPendingExtEventsLocked()
209+ }
210+ s.writeMu.Unlock()
200211
201212 sc := bufio.NewScanner(in)
202213 sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
@@ -213,7 +224,7 @@ func (s *rpcServer) run(in io.Reader) error {
213224 s.writeError("", "", fmt.Sprintf("malformed json: %v", err))
214225 continue
215226 }
216- if !s.authed {
227+ if !s.isAuthed() {
217228 if head.Type != "hello" {
218229 s.writeError(head.ID, head.Type, "auth required: send hello with token first")
219230 continue
@@ -226,13 +237,7 @@ func (s *rpcServer) run(in io.Reader) error {
226237 s.writeError(head.ID, head.Type, "invalid token")
227238 return fmt.Errorf("rpc: bad auth token")
228239 }
229- s.authed = true
230- s.writeResponse(head.ID, head.Type, map[string]any{
231- "protocol_version": 1,
232- "version": s.version,
233- "provider": s.provider,
234- "model": s.model,
235- })
240+ s.authenticate(head.ID, head.Type)
236241 continue
237242 }
238243 s.dispatch(head.Type, head.ID, []byte(line))
@@ -473,6 +478,59 @@ func (s *rpcServer) writeEvent(payload map[string]any) {
473478 s.write(payload)
474479 }
475480
481+func (s *rpcServer) writeExtensionEvent(payload map[string]any) {
482+ b, err := json.Marshal(payload)
483+ if err != nil {
484+ return
485+ }
486+ s.writeMu.Lock()
487+ defer s.writeMu.Unlock()
488+ if !s.started || !s.authed {
489+ s.pendingExtEvents = append(s.pendingExtEvents, b)
490+ return
491+ }
492+ s.writeLocked(b)
493+}
494+
495+func (s *rpcServer) flushPendingExtEventsLocked() {
496+ for _, b := range s.pendingExtEvents {
497+ s.writeLocked(b)
498+ }
499+ s.pendingExtEvents = nil
500+}
501+
502+func (s *rpcServer) isAuthed() bool {
503+ s.writeMu.Lock()
504+ defer s.writeMu.Unlock()
505+ return s.authed
506+}
507+
508+func (s *rpcServer) authenticate(id, cmd string) {
509+ frame := map[string]any{
510+ "type": "response",
511+ "command": cmd,
512+ "success": true,
513+ "data": map[string]any{
514+ "protocol_version": 1,
515+ "version": s.version,
516+ "provider": s.provider,
517+ "model": s.model,
518+ },
519+ }
520+ if id != "" {
521+ frame["id"] = id
522+ }
523+ b, err := json.Marshal(frame)
524+ if err != nil {
525+ return
526+ }
527+ s.writeMu.Lock()
528+ defer s.writeMu.Unlock()
529+ s.authed = true
530+ s.writeLocked(b)
531+ s.flushPendingExtEventsLocked()
532+}
533+
476534 func (s *rpcServer) write(v any) {
477535 b, err := json.Marshal(v)
478536 if err != nil {
@@ -480,6 +538,10 @@ func (s *rpcServer) write(v any) {
480538 }
481539 s.writeMu.Lock()
482540 defer s.writeMu.Unlock()
541+ s.writeLocked(b)
542+}
543+
544+func (s *rpcServer) writeLocked(b []byte) {
483545 _, _ = s.out.Write(b)
484546 _, _ = s.out.Write([]byte("\n"))
485547 }
@@ -49,10 +49,22 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
49 return err49 return err
50 }50 }
51 51
52+ // Construct the RPC event sink before extension startup. Alerts can
53+ // arrive as soon as an extension is ready; rpcServer queues them until
54+ // the command loop has started and, when configured, authenticated.
55+ server := &rpcServer{
56+ ctx: ctx,
57+ args: args,
58+ provider: r.Provider,
59+ model: r.Model,
60+ out: os.Stdout,
61+ version: version,
62+ }
63+
52 // Extensions: same lifecycle as interactive mode, minus the64 // Extensions: same lifecycle as interactive mode, minus the
53 // host-hooks integration. Notify/Display calls from extensions65 // host-hooks integration. Notify/Display calls from extensions
54 // emit RPC events instead of TUI lines so any consumer can react.66 // emit RPC events instead of TUI lines so any consumer can react.
55- extHooks := &rpcExtHooks{}67+ extHooks := &rpcExtHooks{server: server}
56 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)68 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
57 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {69 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
58 fmt.Fprintln(os.Stderr, "extension load:", e)70 fmt.Fprintln(os.Stderr, "extension load:", e)
@@ -67,6 +79,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
67 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})79 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
68 80
69 ag := r.NewAgent()81 ag := r.NewAgent()
82+ server.agent = ag
70 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {83 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
71 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)84 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
72 if r.Block {85 if r.Block {
@@ -103,16 +116,6 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
103 116
104 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})117 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
105 118
106- server := &rpcServer{
107- ctx: ctx,
108- args: args,
109- agent: ag,
110- provider: r.Provider,
111- model: r.Model,
112- out: os.Stdout,
113- version: version,
114- }
115- extHooks.server = server
116 return server.run(os.Stdin)119 return server.run(os.Stdin)
117 }120 }
118 121
@@ -126,7 +129,7 @@ type rpcExtHooks struct {
126 129
127 func (h *rpcExtHooks) Notify(extName, level, message string) {130 func (h *rpcExtHooks) Notify(extName, level, message string) {
128 if h.server != nil {131 if h.server != nil {
129- h.server.writeEvent(map[string]any{132+ h.server.writeExtensionEvent(map[string]any{
130 "type": "ext_notify",133 "type": "ext_notify",
131 "extension": extName,134 "extension": extName,
132 "level": level,135 "level": level,
@@ -136,7 +139,7 @@ func (h *rpcExtHooks) Notify(extName, level, message string) {
136 }139 }
137 func (h *rpcExtHooks) Alert(extName string, alert extproto.AlertRequest) {140 func (h *rpcExtHooks) Alert(extName string, alert extproto.AlertRequest) {
138 if h.server != nil {141 if h.server != nil {
139- h.server.writeEvent(map[string]any{142+ h.server.writeExtensionEvent(map[string]any{
140 "type": "ext_alert",143 "type": "ext_alert",
141 "extension": extName,144 "extension": extName,
142 "kind": alert.Kind,145 "kind": alert.Kind,
@@ -146,7 +149,7 @@ func (h *rpcExtHooks) Alert(extName string, alert extproto.AlertRequest) {
146 }149 }
147 func (h *rpcExtHooks) Display(extName, text string) {150 func (h *rpcExtHooks) Display(extName, text string) {
148 if h.server != nil {151 if h.server != nil {
149- h.server.writeEvent(map[string]any{152+ h.server.writeExtensionEvent(map[string]any{
150 "type": "ext_display",153 "type": "ext_display",
151 "extension": extName,154 "extension": extName,
152 "text": text,155 "text": text,
@@ -155,7 +158,7 @@ func (h *rpcExtHooks) Display(extName, text string) {
155 }158 }
156 func (h *rpcExtHooks) ClearNotes(extName string) {159 func (h *rpcExtHooks) ClearNotes(extName string) {
157 if h.server != nil {160 if h.server != nil {
158- h.server.writeEvent(map[string]any{161+ h.server.writeExtensionEvent(map[string]any{
159 "type": "ext_clear_notes",162 "type": "ext_clear_notes",
160 "extension": extName,163 "extension": extName,
161 })164 })
@@ -177,10 +180,12 @@ type rpcServer struct {
177 out io.Writer180 out io.Writer
178 version string181 version string
179 182
180- writeMu sync.Mutex183+ writeMu sync.Mutex
181- turnMu sync.Mutex // serialises one prompt at a time184+ turnMu sync.Mutex // serialises one prompt at a time
182- activeCancel context.CancelFunc185+ activeCancel context.CancelFunc
183- authed bool186+ started bool
187+ authed bool
188+ pendingExtEvents [][]byte
184 189
185 // inFlight tracks long-running command goroutines so run() can190 // inFlight tracks long-running command goroutines so run() can
186 // wait for them before returning when stdin closes. Without this,191 // wait for them before returning when stdin closes. Without this,
@@ -196,7 +201,13 @@ type rpcServer struct {
196 // still produces full output before the process exits.201 // still produces full output before the process exits.
197 func (s *rpcServer) run(in io.Reader) error {202 func (s *rpcServer) run(in io.Reader) error {
198 requireToken := os.Getenv("ZOTCORE_RPC_TOKEN") != ""203 requireToken := os.Getenv("ZOTCORE_RPC_TOKEN") != ""
204+ s.writeMu.Lock()
205+ s.started = true
199 s.authed = !requireToken206 s.authed = !requireToken
207+ if s.authed {
208+ s.flushPendingExtEventsLocked()
209+ }
210+ s.writeMu.Unlock()
200 211
201 sc := bufio.NewScanner(in)212 sc := bufio.NewScanner(in)
202 sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)213 sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
@@ -213,7 +224,7 @@ func (s *rpcServer) run(in io.Reader) error {
213 s.writeError("", "", fmt.Sprintf("malformed json: %v", err))224 s.writeError("", "", fmt.Sprintf("malformed json: %v", err))
214 continue225 continue
215 }226 }
216- if !s.authed {227+ if !s.isAuthed() {
217 if head.Type != "hello" {228 if head.Type != "hello" {
218 s.writeError(head.ID, head.Type, "auth required: send hello with token first")229 s.writeError(head.ID, head.Type, "auth required: send hello with token first")
219 continue230 continue
@@ -226,13 +237,7 @@ func (s *rpcServer) run(in io.Reader) error {
226 s.writeError(head.ID, head.Type, "invalid token")237 s.writeError(head.ID, head.Type, "invalid token")
227 return fmt.Errorf("rpc: bad auth token")238 return fmt.Errorf("rpc: bad auth token")
228 }239 }
229- s.authed = true240+ s.authenticate(head.ID, head.Type)
230- s.writeResponse(head.ID, head.Type, map[string]any{
231- "protocol_version": 1,
232- "version": s.version,
233- "provider": s.provider,
234- "model": s.model,
235- })
236 continue241 continue
237 }242 }
238 s.dispatch(head.Type, head.ID, []byte(line))243 s.dispatch(head.Type, head.ID, []byte(line))
@@ -473,6 +478,59 @@ func (s *rpcServer) writeEvent(payload map[string]any) {
473 s.write(payload)478 s.write(payload)
474 }479 }
475 480
481+func (s *rpcServer) writeExtensionEvent(payload map[string]any) {
482+ b, err := json.Marshal(payload)
483+ if err != nil {
484+ return
485+ }
486+ s.writeMu.Lock()
487+ defer s.writeMu.Unlock()
488+ if !s.started || !s.authed {
489+ s.pendingExtEvents = append(s.pendingExtEvents, b)
490+ return
491+ }
492+ s.writeLocked(b)
493+}
494+
495+func (s *rpcServer) flushPendingExtEventsLocked() {
496+ for _, b := range s.pendingExtEvents {
497+ s.writeLocked(b)
498+ }
499+ s.pendingExtEvents = nil
500+}
501+
502+func (s *rpcServer) isAuthed() bool {
503+ s.writeMu.Lock()
504+ defer s.writeMu.Unlock()
505+ return s.authed
506+}
507+
508+func (s *rpcServer) authenticate(id, cmd string) {
509+ frame := map[string]any{
510+ "type": "response",
511+ "command": cmd,
512+ "success": true,
513+ "data": map[string]any{
514+ "protocol_version": 1,
515+ "version": s.version,
516+ "provider": s.provider,
517+ "model": s.model,
518+ },
519+ }
520+ if id != "" {
521+ frame["id"] = id
522+ }
523+ b, err := json.Marshal(frame)
524+ if err != nil {
525+ return
526+ }
527+ s.writeMu.Lock()
528+ defer s.writeMu.Unlock()
529+ s.authed = true
530+ s.writeLocked(b)
531+ s.flushPendingExtEventsLocked()
532+}
533+
476 func (s *rpcServer) write(v any) {534 func (s *rpcServer) write(v any) {
477 b, err := json.Marshal(v)535 b, err := json.Marshal(v)
478 if err != nil {536 if err != nil {
@@ -480,6 +538,10 @@ func (s *rpcServer) write(v any) {
480 }538 }
481 s.writeMu.Lock()539 s.writeMu.Lock()
482 defer s.writeMu.Unlock()540 defer s.writeMu.Unlock()
541+ s.writeLocked(b)
542+}
543+
544+func (s *rpcServer) writeLocked(b []byte) {
483 _, _ = s.out.Write(b)545 _, _ = s.out.Write(b)
484 _, _ = s.out.Write([]byte("\n"))546 _, _ = s.out.Write([]byte("\n"))
485 }547 }
M packages/agent/rpc_alert_test.go +21 -1
@@ -11,7 +11,7 @@ import (
1111
1212 func TestRPCExtAlertIsJSONOnly(t *testing.T) {
1313 var out bytes.Buffer
14- hooks := &rpcExtHooks{server: &rpcServer{out: &out}}
14+ hooks := &rpcExtHooks{server: &rpcServer{out: &out, started: true, authed: true}}
1515 hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
1616
1717 if strings.ContainsRune(out.String(), '\a') {
@@ -25,3 +25,23 @@ func TestRPCExtAlertIsJSONOnly(t *testing.T) {
2525 t.Fatalf("RPC alert frame = %#v", frame)
2626 }
2727 }
28+
29+func TestRPCExtAlertWaitsForAuthentication(t *testing.T) {
30+ t.Setenv("ZOTCORE_RPC_TOKEN", "synthetic-token")
31+
32+ var out bytes.Buffer
33+ server := &rpcServer{out: &out, provider: "test", model: "test-model", version: "test-version"}
34+ hooks := &rpcExtHooks{server: server}
35+ hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
36+ if out.Len() != 0 {
37+ t.Fatalf("pre-auth RPC output = %q, want empty", out.String())
38+ }
39+
40+ if err := server.run(strings.NewReader(`{"type":"hello","id":"1","token":"synthetic-token"}` + "\n")); err != nil {
41+ t.Fatalf("run RPC server: %v", err)
42+ }
43+ lines := strings.Split(strings.TrimSpace(out.String()), "\n")
44+ if len(lines) != 2 || !strings.Contains(lines[0], `"type":"response"`) || !strings.Contains(lines[1], `"type":"ext_alert"`) {
45+ t.Fatalf("authenticated RPC frames = %q, want response followed by ext_alert", out.String())
46+ }
47+}
@@ -11,7 +11,7 @@ import (
11 11
12 func TestRPCExtAlertIsJSONOnly(t *testing.T) {12 func TestRPCExtAlertIsJSONOnly(t *testing.T) {
13 var out bytes.Buffer13 var out bytes.Buffer
14- hooks := &rpcExtHooks{server: &rpcServer{out: &out}}14+ hooks := &rpcExtHooks{server: &rpcServer{out: &out, started: true, authed: true}}
15 hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})15 hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
16 16
17 if strings.ContainsRune(out.String(), '\a') {17 if strings.ContainsRune(out.String(), '\a') {
@@ -25,3 +25,23 @@ func TestRPCExtAlertIsJSONOnly(t *testing.T) {
25 t.Fatalf("RPC alert frame = %#v", frame)25 t.Fatalf("RPC alert frame = %#v", frame)
26 }26 }
27 }27 }
28+
29+func TestRPCExtAlertWaitsForAuthentication(t *testing.T) {
30+ t.Setenv("ZOTCORE_RPC_TOKEN", "synthetic-token")
31+
32+ var out bytes.Buffer
33+ server := &rpcServer{out: &out, provider: "test", model: "test-model", version: "test-version"}
34+ hooks := &rpcExtHooks{server: server}
35+ hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
36+ if out.Len() != 0 {
37+ t.Fatalf("pre-auth RPC output = %q, want empty", out.String())
38+ }
39+
40+ if err := server.run(strings.NewReader(`{"type":"hello","id":"1","token":"synthetic-token"}` + "\n")); err != nil {
41+ t.Fatalf("run RPC server: %v", err)
42+ }
43+ lines := strings.Split(strings.TrimSpace(out.String()), "\n")
44+ if len(lines) != 2 || !strings.Contains(lines[0], `"type":"response"`) || !strings.Contains(lines[1], `"type":"ext_alert"`) {
45+ t.Fatalf("authenticated RPC frames = %q, want response followed by ext_alert", out.String())
46+ }
47+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close