@pi-in-go/pigpen-herdr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,554 @@
1
+ // Package herdr reports PiG's agent state to herdr, the terminal workspace manager.
2
+ //
3
+ // It follows herdr's published "Add Herdr support to your agent" contract
4
+ // (https://herdr.dev/docs/add-herdr-support/): inside a herdr pane, report idle,
5
+ // working or blocked through `"$HERDR_BIN_PATH" pane report-agent`, keep the
6
+ // report order with an increasing --seq, report the command that resumes the
7
+ // session (herdr 0.9.2+ restores the pane with it after a server restart), and
8
+ // release the pane when the user quits. Outside herdr the factory registers
9
+ // nothing.
10
+ //
11
+ // It is written against PiG's public Go extension SDK only, so it builds as a
12
+ // source extension and fuses into a Piglet Binary.
13
+ package herdr
14
+
15
+ import (
16
+ "context"
17
+ "errors"
18
+ "fmt"
19
+ "os"
20
+ "os/exec"
21
+ "strconv"
22
+ "strings"
23
+ "sync"
24
+ "time"
25
+ "unicode"
26
+
27
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
28
+ )
29
+
30
+ const (
31
+ // source is the stable integration identity. herdr reserves the `herdr:` prefix for its own sources.
32
+ source = "custom:pig"
33
+ agent = "pig"
34
+ // callTimeout bounds every herdr CLI call: herdr must never slow the agent down.
35
+ callTimeout = 3 * time.Second
36
+ // maxMessageLength bounds the sidebar message, in characters.
37
+ maxMessageLength = 120
38
+ // command is the first word of the resume command: a plain command name on PATH, as herdr requires.
39
+ command = "pig"
40
+ // herdr refuses a resume command of more than 64 arguments or 8 KiB in total.
41
+ maxResumeArgs = 64
42
+ maxResumeBytes = 8 * 1024
43
+ )
44
+
45
+ type agentState string
46
+
47
+ const (
48
+ stateIdle agentState = "idle"
49
+ stateWorking agentState = "working"
50
+ stateBlocked agentState = "blocked"
51
+ )
52
+
53
+ type report struct {
54
+ state agentState
55
+ message string
56
+ seq int64
57
+ }
58
+
59
+ var (
60
+ seqMu sync.Mutex
61
+ lastSeq int64
62
+ )
63
+
64
+ // nextSeq is monotonic across every reporter in this process and, because it is
65
+ // seeded from the clock (microseconds), across restarts and session
66
+ // replacements. herdr drops reports whose seq is not higher than the last one it
67
+ // accepted.
68
+ func nextSeq() int64 {
69
+ seqMu.Lock()
70
+ defer seqMu.Unlock()
71
+ lastSeq = max(lastSeq+1, time.Now().UnixMicro())
72
+ return lastSeq
73
+ }
74
+
75
+ type herdrEnv struct {
76
+ bin string
77
+ paneID string
78
+ }
79
+
80
+ // readEnv reports whether this process runs inside a herdr pane. The contract
81
+ // is HERDR_ENV=1 and HERDR_PANE_ID, HERDR_BIN_PATH and HERDR_SOCKET_PATH all set;
82
+ // otherwise the integration does nothing. The reports go through the CLI, which
83
+ // finds the socket itself from the same environment.
84
+ func readEnv() (herdrEnv, bool) {
85
+ bin, pane, socket := os.Getenv("HERDR_BIN_PATH"), os.Getenv("HERDR_PANE_ID"), os.Getenv("HERDR_SOCKET_PATH")
86
+ if os.Getenv("HERDR_ENV") != "1" || bin == "" || pane == "" || socket == "" {
87
+ return herdrEnv{}, false
88
+ }
89
+ return herdrEnv{bin: bin, paneID: pane}, true
90
+ }
91
+
92
+ // Extension is the conventional Go factory. Outside a herdr pane it registers nothing.
93
+ func Extension() *sdk.Extension {
94
+ e := sdk.New("herdr")
95
+ env, ok := readEnv()
96
+ if !ok {
97
+ return e
98
+ }
99
+ r := &reporter{env: env}
100
+
101
+ // PiG tells every extension when a blocking UI prompt (select, confirm,
102
+ // input, editor, custom) opens and settles.
103
+ e.OnEvent(sdk.EventUIPromptStart, func(_ sdk.Context, data map[string]any) (any, error) {
104
+ title, _ := data["title"].(string)
105
+ if title == "" {
106
+ title, _ = data["kind"].(string)
107
+ }
108
+ r.block(title)
109
+ return nil, nil
110
+ })
111
+ e.OnEvent(sdk.EventUIPromptEnd, func(sdk.Context, map[string]any) (any, error) {
112
+ r.unblock()
113
+ return nil, nil
114
+ })
115
+
116
+ e.OnSessionStart(func(ctx sdk.Context, _ map[string]any) (any, error) {
117
+ // herdr can only show a pane's terminal, so report the interactive session
118
+ // and stay silent in RPC, JSON and print runs.
119
+ if ctx.Mode() != "tui" {
120
+ return nil, nil
121
+ }
122
+ path, id := sessionIdentity(ctx)
123
+ // A reload can start this reporter in the middle of a turn.
124
+ idle, err := ctx.IsIdle()
125
+ r.start(path, id, err == nil && !idle)
126
+ return nil, nil
127
+ })
128
+
129
+ e.OnEvent(sdk.EventAgentStart, func(ctx sdk.Context, _ map[string]any) (any, error) {
130
+ if !r.isRoot() {
131
+ return nil, nil
132
+ }
133
+ path, id := sessionIdentity(ctx)
134
+ r.agentStarted(path, id)
135
+ return nil, nil
136
+ })
137
+
138
+ // agent_settled fires once the run is fully over: no retry, compaction or
139
+ // queued continuation follows, so it is the right moment to report idle.
140
+ e.OnEvent(sdk.EventAgentSettled, func(ctx sdk.Context, _ map[string]any) (any, error) {
141
+ if !r.isRoot() {
142
+ return nil, nil
143
+ }
144
+ if idle, err := ctx.IsIdle(); err == nil && !idle {
145
+ return nil, nil
146
+ }
147
+ r.agentSettled()
148
+ return nil, nil
149
+ })
150
+
151
+ e.OnSessionShutdown(func(_ sdk.Context, data map[string]any) (any, error) {
152
+ if !r.isRoot() {
153
+ return nil, nil
154
+ }
155
+ if reason, _ := data["reason"].(string); reason != "quit" {
156
+ // A successor reporter in this pane re-reports on its own. Releasing here
157
+ // would race that report, and a late release would clear the pane.
158
+ r.silence()
159
+ return nil, nil
160
+ }
161
+ r.release()
162
+ return nil, nil
163
+ })
164
+ return e
165
+ }
166
+
167
+ // sessionIdentity reads the session file and id. A host failure or a value herdr
168
+ // cannot use is omitted, not guessed.
169
+ func sessionIdentity(ctx sdk.Context) (path, id string) {
170
+ if file, err := ctx.GetSessionFile(); err == nil && file != nil && isAbsolutePath(*file) {
171
+ path = *file
172
+ }
173
+ if v, err := ctx.GetSessionID(); err == nil {
174
+ id = v
175
+ }
176
+ return path, id
177
+ }
178
+
179
+ // isAbsolutePath accepts POSIX and Windows absolute paths, so a session file
180
+ // from any host is reported.
181
+ func isAbsolutePath(p string) bool {
182
+ if p == "" {
183
+ return false
184
+ }
185
+ if p[0] == '/' || p[0] == '\\' {
186
+ return true
187
+ }
188
+ isLetter := p[0] >= 'a' && p[0] <= 'z' || p[0] >= 'A' && p[0] <= 'Z'
189
+ return len(p) >= 3 && isLetter && p[1] == ':' && (p[2] == '/' || p[2] == '\\')
190
+ }
191
+
192
+ // resumeArgv is the command that reopens this session in the pane's directory,
193
+ // mirroring herdr's built-in pi integration (`pi --session <file>`, no model
194
+ // flag) with pig substituted. It is the same on PiG 0.3.x and 0.4.0: a
195
+ // `--session` argument with a path separator or a .jsonl suffix opens that exact
196
+ // file whatever the directory or session dir. When the file cannot be passed
197
+ // (herdr refuses apostrophes, control characters and more than 8 KiB) the
198
+ // session id goes to `--session-id`, which resumes the id in the pane's project
199
+ // session dir and never asks to fork the way `--session <id>` does from another
200
+ // directory. With neither, there is no resume command. An in-memory session
201
+ // (`pig --no-session`) has an id but no file, and nothing to resume:
202
+ // `--session-id` would create a new, persisted session with that id.
203
+ func resumeArgv(path, id string) []string {
204
+ if path == "" {
205
+ return nil
206
+ }
207
+ for _, argv := range [][]string{
208
+ {command, "--session", path},
209
+ {command, "--session-id", id},
210
+ } {
211
+ if argv[2] != "" && resumeArgvValid(argv) && (argv[1] == "--session" || validSessionID(id)) {
212
+ return argv
213
+ }
214
+ }
215
+ return nil
216
+ }
217
+
218
+ // resumeArgvValid applies herdr's own rules for a resume command.
219
+ func resumeArgvValid(argv []string) bool {
220
+ total := 0
221
+ for _, arg := range argv {
222
+ total += len(arg)
223
+ if strings.ContainsRune(arg, '\'') || strings.ContainsFunc(arg, unicode.IsControl) {
224
+ return false
225
+ }
226
+ }
227
+ return len(argv) <= maxResumeArgs && total <= maxResumeBytes
228
+ }
229
+
230
+ // validSessionID is PiG's rule for a `--session-id` value: letters, digits, `-`,
231
+ // `_` and `.`, starting and ending with a letter or digit.
232
+ func validSessionID(id string) bool {
233
+ if id == "" {
234
+ return false
235
+ }
236
+ alnum := func(c byte) bool { return c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' }
237
+ for i := 0; i < len(id); i++ {
238
+ c := id[i]
239
+ if !alnum(c) && (i == 0 || i == len(id)-1 || c != '-' && c != '_' && c != '.') {
240
+ return false
241
+ }
242
+ }
243
+ return true
244
+ }
245
+
246
+ // messageArgs passes the message in the form every herdr parses: `--message
247
+ // <text>`. Every herdr CLI takes the next argument as the value, even one that
248
+ // starts with "-"; herdr before 0.9.0 does not know the attached
249
+ // `--message=<text>` form and would drop the whole report. Only a message that
250
+ // is exactly `--` goes attached (herdr 0.9.0+), because herdr 0.9.2+ splits the
251
+ // resume command off at the first `--` argument.
252
+ func messageArgs(message string) []string {
253
+ if message == "--" {
254
+ return []string{"--message=" + message}
255
+ }
256
+ return []string{"--message", message}
257
+ }
258
+
259
+ // messageFrom makes one short line for herdr's sidebar; prompt titles can be long and multi-line.
260
+ func messageFrom(value string) string {
261
+ line := strings.Join(strings.FieldsFunc(value, func(r rune) bool { return unicode.IsSpace(r) || r == '\uFEFF' }), " ")
262
+ runes := []rune(line)
263
+ if len(runes) > maxMessageLength {
264
+ return string(runes[:maxMessageLength-1]) + "…"
265
+ }
266
+ return line
267
+ }
268
+
269
+ // reporter is one herdr reporting session. Handlers may run concurrently, so
270
+ // every field is guarded by mu; host and herdr calls run outside it.
271
+ type reporter struct {
272
+ env herdrEnv
273
+
274
+ mu sync.Mutex
275
+ rootSession bool
276
+ sessionPath string
277
+ sessionID string
278
+ agentActive bool
279
+ blockedMessage string
280
+ // Prompts currently open: PiG reports only the outermost prompt of a kind,
281
+ // and producers can overlap, so count.
282
+ blockedCount int
283
+ hasLast bool
284
+ // resumeOff is set once herdr refused a resume command (herdr before 0.9.2,
285
+ // or invalid_resume_argv): later reports leave it out.
286
+ resumeOff bool
287
+ // lastDiagnostic is the last failure written to stderr, so a repeat is not.
288
+ lastDiagnostic string
289
+ lastState agentState
290
+ lastMessage string
291
+ released bool
292
+
293
+ // Only the newest state matters: while a call is in flight, newer states
294
+ // replace the queued one, and calls never overlap, so herdr sees them in order.
295
+ queued *report
296
+ sending bool
297
+ drained chan struct{} // closed when the drain goroutine exits; valid while sending
298
+ }
299
+
300
+ func (r *reporter) isRoot() bool {
301
+ r.mu.Lock()
302
+ defer r.mu.Unlock()
303
+ return r.rootSession
304
+ }
305
+
306
+ func (r *reporter) start(path, id string, active bool) {
307
+ r.mu.Lock()
308
+ defer r.mu.Unlock()
309
+ // A session start opens a fresh reporting epoch.
310
+ r.rootSession = true
311
+ r.released = false
312
+ r.blockedCount, r.blockedMessage = 0, ""
313
+ r.sessionPath, r.sessionID = path, id
314
+ r.agentActive = active
315
+ r.publishLocked(true)
316
+ }
317
+
318
+ func (r *reporter) agentStarted(path, id string) {
319
+ r.mu.Lock()
320
+ defer r.mu.Unlock()
321
+ r.sessionPath, r.sessionID = path, id
322
+ r.agentActive = true
323
+ r.publishLocked(false)
324
+ }
325
+
326
+ func (r *reporter) agentSettled() {
327
+ r.mu.Lock()
328
+ defer r.mu.Unlock()
329
+ r.agentActive = false
330
+ r.publishLocked(false)
331
+ }
332
+
333
+ func (r *reporter) block(label string) {
334
+ r.mu.Lock()
335
+ defer r.mu.Unlock()
336
+ if !r.rootSession {
337
+ return
338
+ }
339
+ r.blockedCount++
340
+ r.blockedMessage = messageFrom(label)
341
+ r.publishLocked(false)
342
+ }
343
+
344
+ func (r *reporter) unblock() {
345
+ r.mu.Lock()
346
+ defer r.mu.Unlock()
347
+ if !r.rootSession {
348
+ return
349
+ }
350
+ r.blockedCount = max(0, r.blockedCount-1)
351
+ if r.blockedCount == 0 {
352
+ r.blockedMessage = ""
353
+ }
354
+ r.publishLocked(false)
355
+ }
356
+
357
+ // silence stops reporting without releasing the pane.
358
+ func (r *reporter) silence() {
359
+ r.mu.Lock()
360
+ defer r.mu.Unlock()
361
+ r.released = true
362
+ r.queued = nil
363
+ }
364
+
365
+ func (r *reporter) desiredLocked() (agentState, string) {
366
+ if r.blockedCount > 0 {
367
+ return stateBlocked, r.blockedMessage
368
+ }
369
+ if r.agentActive {
370
+ return stateWorking, ""
371
+ }
372
+ return stateIdle, ""
373
+ }
374
+
375
+ func (r *reporter) publishLocked(force bool) {
376
+ state, message := r.desiredLocked()
377
+ if !force && r.hasLast && state == r.lastState && message == r.lastMessage {
378
+ return
379
+ }
380
+ r.hasLast, r.lastState, r.lastMessage = true, state, message
381
+ r.queueLocked(state, message)
382
+ }
383
+
384
+ func (r *reporter) queueLocked(state agentState, message string) {
385
+ if r.released {
386
+ // A report after the release would reclaim a pane whose agent has exited.
387
+ return
388
+ }
389
+ r.queued = &report{state: state, message: message, seq: nextSeq()}
390
+ if !r.sending {
391
+ r.sending = true
392
+ r.drained = make(chan struct{})
393
+ go r.drain(r.drained)
394
+ }
395
+ }
396
+
397
+ // drain sends queued reports one at a time until none is left.
398
+ func (r *reporter) drain(done chan struct{}) {
399
+ defer close(done)
400
+ for {
401
+ r.mu.Lock()
402
+ next := r.queued
403
+ r.queued = nil
404
+ if next == nil {
405
+ r.sending = false
406
+ r.mu.Unlock()
407
+ return
408
+ }
409
+ args := r.reportArgsLocked(*next, !r.resumeOff)
410
+ r.mu.Unlock()
411
+ out, err := callHerdr(r.env, args)
412
+ switch {
413
+ case err == nil:
414
+ r.healthy()
415
+ case resumeRefused(out, err):
416
+ // herdr did not apply the report. Send it once more without the command,
417
+ // silently, and stop attaching one: state and release keep working.
418
+ r.mu.Lock()
419
+ r.resumeOff = true
420
+ args = r.reportArgsLocked(*next, false)
421
+ r.mu.Unlock()
422
+ if out, err = callHerdr(r.env, args); err != nil {
423
+ r.diagnose(args, out, err)
424
+ }
425
+ default:
426
+ r.diagnose(args, out, err)
427
+ }
428
+ }
429
+ }
430
+
431
+ // diagnose writes one line to stderr, which PiG collects as the extension's
432
+ // output, when herdr did not take a call: a herdr that never hears about a pane
433
+ // is otherwise indistinguishable from one that was told. The same failure is
434
+ // written once until a call succeeds again, so a herdr that is gone does not
435
+ // flood the output. The agent never waits on it and the transcript never shows it.
436
+ func (r *reporter) diagnose(args []string, out []byte, err error) {
437
+ msg := "herdr: pane " + args[1] + " failed: " + err.Error()
438
+ if line := firstLine(out); line != "" {
439
+ msg += ": " + line
440
+ }
441
+ r.mu.Lock()
442
+ repeat := msg == r.lastDiagnostic
443
+ r.lastDiagnostic = msg
444
+ r.mu.Unlock()
445
+ if !repeat {
446
+ fmt.Fprintln(os.Stderr, msg)
447
+ }
448
+ }
449
+
450
+ // healthy forgets the last failure, so the next one is written again.
451
+ func (r *reporter) healthy() {
452
+ r.mu.Lock()
453
+ r.lastDiagnostic = ""
454
+ r.mu.Unlock()
455
+ }
456
+
457
+ // firstLine is the first non-empty line of herdr's output, cut to a sensible length.
458
+ func firstLine(out []byte) string {
459
+ for _, line := range strings.Split(string(out), "\n") {
460
+ if line = strings.TrimSpace(line); line != "" {
461
+ if len(line) > 200 {
462
+ line = line[:200] + "..."
463
+ }
464
+ return line
465
+ }
466
+ }
467
+ return ""
468
+ }
469
+
470
+ // resumeRefused reports whether a failed report failed because herdr does not
471
+ // take a resume command: herdr before 0.9.2 does not know the `--` separator
472
+ // ("unknown option: --", exit 2), and 0.9.2+ answers `invalid_resume_argv` (exit
473
+ // 1) without applying the report. A timeout or any other failure is not a verdict
474
+ // on resume support.
475
+ func resumeRefused(output []byte, err error) bool {
476
+ var exit *exec.ExitError
477
+ if !errors.As(err, &exit) {
478
+ return false
479
+ }
480
+ text := strings.ToLower(string(output))
481
+ if strings.Contains(text, "invalid_resume_argv") {
482
+ return true
483
+ }
484
+ // "unknown option: --" names the separator itself; "unknown option: --message=..." does not.
485
+ for _, line := range strings.Split(text, "\n") {
486
+ if strings.TrimSpace(line) == "unknown option: --" {
487
+ return true
488
+ }
489
+ }
490
+ return false
491
+ }
492
+
493
+ func (r *reporter) reportArgsLocked(rep report, withResume bool) []string {
494
+ args := []string{
495
+ "pane", "report-agent", r.env.paneID,
496
+ "--source", source, "--agent", agent,
497
+ "--state", string(rep.state), "--seq", strconv.FormatInt(rep.seq, 10),
498
+ }
499
+ if rep.message != "" {
500
+ args = append(args, messageArgs(rep.message)...)
501
+ }
502
+ if r.sessionPath != "" {
503
+ args = append(args, "--agent-session-path", r.sessionPath)
504
+ }
505
+ if r.sessionID != "" {
506
+ args = append(args, "--agent-session-id", r.sessionID)
507
+ }
508
+ // The command goes last, after `--`. The state report in the same call is what
509
+ // makes this source hold the pane, which herdr requires before it takes one.
510
+ if withResume {
511
+ if argv := resumeArgv(r.sessionPath, r.sessionID); argv != nil {
512
+ args = append(args, "--")
513
+ args = append(args, argv...)
514
+ }
515
+ }
516
+ return args
517
+ }
518
+
519
+ // release stops new reports, drops the queued one and lets the in-flight call
520
+ // finish so the release is the last thing herdr hears from this pane.
521
+ func (r *reporter) release() {
522
+ r.mu.Lock()
523
+ r.released = true
524
+ r.queued = nil
525
+ var inFlight chan struct{}
526
+ if r.sending {
527
+ inFlight = r.drained
528
+ }
529
+ seq := nextSeq()
530
+ r.mu.Unlock()
531
+ if inFlight != nil {
532
+ <-inFlight
533
+ }
534
+ args := []string{
535
+ "pane", "release-agent", r.env.paneID,
536
+ "--source", source, "--agent", agent, "--seq", strconv.FormatInt(seq, 10),
537
+ }
538
+ if out, err := callHerdr(r.env, args); err != nil {
539
+ r.diagnose(args, out, err)
540
+ }
541
+ }
542
+
543
+ // callHerdr runs one herdr command and returns its output. Callers do not wait on a
544
+ // failed or slow herdr call (a failure is written once to stderr): the reporter is best effort, and it must never fail
545
+ // or stall the agent.
546
+ func callHerdr(env herdrEnv, args []string) ([]byte, error) {
547
+ ctx, cancel := context.WithTimeout(context.Background(), callTimeout)
548
+ defer cancel()
549
+ cmd := newCommand(ctx, env.bin, args)
550
+ // A herdr that hangs is killed at the deadline; a grandchild holding the pipe
551
+ // must not keep Wait from returning.
552
+ cmd.WaitDelay = time.Second
553
+ return cmd.CombinedOutput()
554
+ }