Repository navigation
Expand file tree
/
Copy pathchatv3_host.go
More file actions
1579 lines (1498 loc) · 68.2 KB
/
Copy pathchatv3_host.go
File metadata and controls
1579 lines (1498 loc) · 68.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package main
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/Agent-Field/codeaf/internal/buildinfo"
"github.com/Agent-Field/codeaf/internal/catalog"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/enginehost"
"github.com/Agent-Field/codeaf/internal/env"
"github.com/Agent-Field/codeaf/internal/guard"
"github.com/Agent-Field/codeaf/internal/history"
"github.com/Agent-Field/codeaf/internal/home"
"github.com/Agent-Field/codeaf/internal/leave"
"github.com/Agent-Field/codeaf/internal/remote"
"github.com/Agent-Field/codeaf/internal/session"
"github.com/Agent-Field/codeaf/internal/store"
"github.com/Agent-Field/codeaf/internal/tui3"
codeupdate "github.com/Agent-Field/codeaf/internal/update"
)
// ── `codeaf chat --host devbox` ─────────────────────────────────────────────
//
// The surface runs here and the session runs there. This file is the door: it
// parses the destination, starts the matching program over ssh, completes the
// handshake, and hands the connection to the same surface a local launch opens.
//
// THE PROMPT LAW, and it is the reason this whole path is shaped the way it is:
// EVERYTHING THAT MIGHT ASK THE PERSON A QUESTION HAPPENS BEFORE THE SURFACE
// TAKES THE SCREEN. ssh asks for a passphrase; ssh asks whether an unknown host
// key is really theirs; the engine may answer that it speaks a different version
// of the protocol. All three are plain text on a plain terminal, because they
// arrive while stderr is still the terminal's and tui3.Run has not been called.
// A TUI that came up first would either eat those questions or draw a frame over
// them, and a person would be looking at a hung screen with an invisible
// password prompt behind it.
//
// AND IT IS THE PERSON'S OWN SSH. No library, no key handling, no config file of
// ours: `ssh` is on their path, reads their ~/.ssh/config, knows their aliases
// and their agent. `--host devbox` works because `ssh devbox` already worked.
//
// WHAT THIS DOOR DOES NOT RESOLVE is everything the local door resolves and the
// far machine owns: the API key, the model catalog's credentials, the tool gate,
// the spend rail, the harness registry, the session files. Reading this
// machine's would be assembling a launch that never happens. What stays local is
// what belongs to the SURFACE — the input history, the unsent draft, the model
// picker's cached list — and that is stated once here and once in host.go.
// hostLaunch is one `--host` launch as the flag parser saw it.
type hostLaunch struct {
// target is the flag as typed: host, user@host, host:path or host:/abs.
target string
// session is --session, passed through as the engine's own --session means it.
session string
// model and level are --model and --reasoning, carried in the hello itself
// ([remote.Hello]'s Model and Level) so the ENGINE opens the session on them
// rather than being switched a millisecond later — which is the difference
// between a journal whose first line names the model the person asked for
// and one that names whatever the machine happened to default to.
model string
level string
// once is --once: one message, printed, no terminal ownership.
once string
// pick is `codeaf resume`: the same surface, opened on the session picker.
pick bool
// noCompact and yolo are refused rather than ignored — see [hostLaunch.check].
noCompact bool
yolo bool
// budget is --max-hours / --max-cost, and it is refused for yolo's reason
// and one more: what it bounds is a goal owner that lives in the session
// (internal/session's principal.go), and the session is on the far machine.
// A ceiling accepted here would bound nothing at all.
budget bool
// restart is filled by the surface and read after this door has closed the
// ssh child and all conversation connections.
restart *codeupdate.Plan
}
// check refuses the flags this door cannot honour.
//
// A FLAG THAT COULD NOT TRAVEL IS A REFUSAL AND NEVER A SHRUG. --no-compact and
// --yolo are properties of the SESSION, the session is built on the far machine
// by `codeaf engine`, and the wire's hello carries neither. Accepting them and
// doing nothing would be the worst outcome available: a person types --yolo,
// watches the gate ask about every tool, and has no way to tell whether the flag
// or the gate is broken. So the door says which machine the setting lives on.
func (l hostLaunch) check() error {
var named []string
if l.noCompact {
named = append(named, "--no-compact")
}
if l.yolo {
named = append(named, "--yolo")
}
if l.budget {
named = append(named, "--max-hours/--max-cost")
}
if len(named) == 0 {
return nil
}
dest, _, _ := parseHostTarget(l.target)
return fmt.Errorf("%s cannot travel over --host: the session is built on %s, so set it there — `ssh %s codeaf chat %s` — or open the settings panel on that machine",
strings.Join(named, " and "), dest, dest, strings.Join(named, " "))
}
// parseHostTarget splits the flag scp-style: everything before the FIRST colon
// is the ssh destination, everything after it is the workspace.
//
// THE WORKSPACE IS NOT RESOLVED HERE, and that is the whole point of "as typed".
// `--host devbox:code/app` means the directory `code/app` on devbox, relative to
// whatever devbox's home is — a directory this machine has never seen and must
// not have an opinion about. Making it absolute here would resolve it against
// THIS machine's cwd and send the far end a path out of somebody else's
// filesystem. The engine resolves it, and the welcome says what it resolved to.
//
// No colon is no workspace, which the engine reads as its own home. A trailing
// colon is the same thing said out loud.
func parseHostTarget(raw string) (dest, workspace string, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", fmt.Errorf("--host needs a machine: --host devbox, --host me@devbox, or --host devbox:code/app")
}
at := strings.Index(raw, ":")
if at < 0 {
return raw, "", nil
}
dest = strings.TrimSpace(raw[:at])
workspace = strings.TrimSpace(raw[at+1:])
if dest == "" {
return "", "", fmt.Errorf("--host %q has no machine in front of the colon", raw)
}
return dest, workspace, nil
}
// engineLink is a live ssh process and the client speaking to it.
//
// THE PROCESS IS A SLOT AND NOT A CONSTANT, which is the whole of what roaming
// changed here: a dropped link is redialled by [remote.Roam], every redial is a
// FRESH ssh child, and this holds whichever one is current so the door can still
// wait on its exit code and read what it said on stderr.
type engineLink struct {
client *remote.Client
// dest and workspace are what every spawn needs, kept because the spawning
// now happens again on a link that died and not only once at the door.
dest string
workspace string
mu sync.Mutex
// process is the ssh child currently carrying the connection. Closing the
// client's pipe is what ends it; this is held so the door can wait for its
// exit code when the handshake failed and the exit code is the only witness
// to why.
process *exec.Cmd
// stderr is the tail of what ssh and the far shell said, kept so a failed
// handshake can be diagnosed in the person's own words rather than in a
// pipe error. Only pre-handshake prompts also reach the terminal.
stderr *tailWriter
// reaped says this process has already been waited on, and err is what that
// wait answered.
reaped bool
err error
}
// spawn starts one `ssh <dest> codeaf engine` and hands back its pipes. It is
// [remote.Dialer]: the door owns processes, the wire owns frames, and this is
// the one function the redial loop reaches back through when a link dies.
func (l *engineLink) spawn() (io.ReadWriteCloser, error) {
// The remote command, as the far machine's login shell will read it. The
// workspace is quoted because a path with a space in it is a path, and an
// unquoted one would arrive at `codeaf engine` as two arguments.
remoteCommand := hostEngineCommand(programName(), l.dest, l.workspace)
// -T because there is nothing interactive on the far end: the engine reads
// frames on stdin and writes them on stdout, and a pseudo-terminal in the
// middle would turn a newline into a carriage return and a frame into
// nonsense. ssh's OWN questions do not go through this — it asks them on
// /dev/tty, which is still the person's terminal.
process := exec.Command("ssh", sshTransportArgs(l.dest, remoteCommand)...)
stdin, err := process.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := process.StdoutPipe()
if err != nil {
return nil, err
}
// STDERR BELONGS TO THE TERMINAL UNTIL THE HANDSHAKE. Launch prompts reach
// the person before the surface takes the frame; later diagnostics stay in
// the tail so a disconnect cannot paint over that frame.
tail := &tailWriter{}
process.Stderr = l.stderrWriter(tail, os.Stderr)
if err := process.Start(); err != nil {
if strings.Contains(err.Error(), "executable file not found") {
return nil, fmt.Errorf("this machine has no ssh on its path, and --host is ssh")
}
return nil, fmt.Errorf("could not start ssh: %w", err)
}
l.hold(process, tail)
return pipePair{r: stdout, w: stdin}, nil
}
// programName follows the executable file, including custom installation names.
func programName() string { return codeupdate.InstallName(enginehost.ThisBinary()) }
// hostEngineCommand selects on the far machine in the same ssh hop. An engine
// failure never triggers a fallback: only absence of the requested file does.
func hostEngineCommand(name, dest, workspace string) string {
args := " engine"
if workspace != "" {
args += " --workspace " + shellQuote(workspace)
}
primary := "exec " + shellQuote(name) + args
if name == "codeaf" {
return primary
}
notice := "printf '%s has no %s; using its codeaf (%s)\\n' " + shellQuote(dest) + " " + shellQuote(name) + " \"$(codeaf version)\" >&2"
return "if command -v " + shellQuote(name) + " >/dev/null 2>&1; then " + primary +
"; elif command -v codeaf >/dev/null 2>&1; then " + notice + "; exec codeaf" + args + "; else " + primary + "; fi"
}
// stderrWriter keeps the launch-time prompts visible but keeps redial output
// inside the session's diagnostic tail. A reconnect happens while Bubble Tea
// owns the terminal's alternate screen, so writing ssh's transient errors to
// os.Stderr would paint over the frame instead of becoming a status detail.
func (l *engineLink) stderrWriter(tail *tailWriter, terminal io.Writer) io.Writer {
l.mu.Lock()
defer l.mu.Unlock()
if l.process == nil {
return &launchStderr{link: l, terminal: terminal, tail: tail}
}
return tail
}
// launchStderr stays installed on the initial carrier after its handshake.
// The connection boundary changes its destination under the link's lock, so
// even that carrier's later disconnect errors stay out of the owned frame.
type launchStderr struct {
link *engineLink
terminal io.Writer
tail *tailWriter
}
func (w *launchStderr) Write(p []byte) (int, error) {
w.link.mu.Lock()
defer w.link.mu.Unlock()
if w.link.client == nil {
return io.MultiWriter(w.terminal, w.tail).Write(p)
}
return w.tail.Write(p)
}
// sshTransportArgs keeps the carrier's latency policy in one place. -T remains
// first because a pseudo-terminal changes bytes; the other options keep a warm
// connection available for redials, notice a machine that stopped answering,
// and keep interactive frames out of bulk queues on networks that distinguish
// them. The values come through config's registry, so a network that needs a
// different policy has a supported override rather than a private environment
// variable hidden from the settings sheet.
func sshTransportArgs(dest, remoteCommand string) []string {
settings := config.SSHTransportAt(env.Get("CODEAF_PROFILE_DIR"))
args := []string{
"-T",
"-o", fmt.Sprintf("ServerAliveInterval=%d", settings.ServerAliveSeconds),
"-o", fmt.Sprintf("ServerAliveCountMax=%d", settings.ServerAliveMisses),
"-o", "IPQoS=" + settings.IPQoS,
}
if control := sshControlPath(); control != "" {
args = append(args,
"-o", "ControlMaster=auto",
"-o", "ControlPath="+control,
"-o", fmt.Sprintf("ControlPersist=%d", settings.ControlPersistSeconds),
)
}
return append(args, dest, remoteCommand)
}
// sshControlPath is short by construction and lives under the state root. %C
// lets OpenSSH hash the resolved host, port and user rather than us guessing at
// identities hidden in ~/.ssh/config. A state root too long for a unix socket
// loses multiplexing only: --host must still work, just as enginehost falls
// back to its pipe when its own socket cannot be made.
func sshControlPath() string {
dir := home.Join("v3", "ssh")
if err := os.MkdirAll(dir, 0o700); err != nil {
return ""
}
if err := os.Chmod(dir, 0o700); err != nil {
return ""
}
path := filepath.Join(dir, "ctl-%C")
if !sshControlPathFits(path) {
return ""
}
return path
}
// sshControlPathFits accounts for OpenSSH's temporary control-master name as
// well as the final hashed path, so a path accepted here cannot fail at bind.
func sshControlPathFits(path string) bool {
// OpenSSH expands %C to a 40-character SHA-1 digest and briefly appends a
// 17-character suffix before bind(2), so both forms must fit the shared
// macOS/Linux ceiling.
expanded := strings.Replace(path, "%C", strings.Repeat("0", 40), 1)
return enginehost.SocketPathFits(expanded) && enginehost.SocketPathFits(expanded+strings.Repeat("0", 17))
}
// hold takes the new child and lets go of the old one. THE PREVIOUS SSH IS
// REAPED IN THE BACKGROUND, because a redial happens after its pipe died and a
// child nobody waits on is a zombie for as long as this terminal is open — five
// minutes of retries against a machine that is switched off would leave a row of
// them.
func (l *engineLink) hold(process *exec.Cmd, tail *tailWriter) {
previous, reaped := l.swap(process, tail)
if previous != nil && !reaped {
guard.Go("chatv3/host-reap", func() { _ = previous.Wait() })
}
}
// swap installs the new child and answers the old one and whether it was
// already reaped. It is its own method so the mutex is held from a defer while
// the wait on the previous child stays outside the lock.
func (l *engineLink) swap(process *exec.Cmd, tail *tailWriter) (previous *exec.Cmd, reaped bool) {
l.mu.Lock()
defer l.mu.Unlock()
previous, reaped = l.process, l.reaped
l.process, l.stderr, l.reaped, l.err = process, tail, false, nil
return previous, reaped
}
// dialEngine starts the engine on the far machine, completes the handshake, and
// leaves the connection able to redial itself.
//
// THE HELLO IS THE CALLER'S because this door is now dialled more than once per
// window: the launch's own hello opens the conversation this terminal came for,
// and one naming [remote.Hello.New] opens another beside it (chatv3_beside.go).
// The workspace is still a separate argument because it is also an argument to
// ssh — it goes on the remote command line so the engine starts in the right
// place even if the handshake never happens.
func dialEngine(dest, workspace string, hello remote.Hello) (*engineLink, error) {
link := &engineLink{dest: dest, workspace: workspace}
hello.Workspace = workspace
client, err := remote.Roam(dest, hello, remote.Roaming{Dial: link.spawn})
if err != nil {
return nil, link.diagnose(dest, err)
}
link.mu.Lock()
defer link.mu.Unlock()
link.client = client
return link, nil
}
// launchHello is the first hello a terminal door says: the conversation the
// person named, on the model and the reasoning level they asked for.
//
// --model AND --reasoning RIDE THE FRAME THAT BUILDS THE SESSION, so the engine
// opens on them rather than being switched a millisecond afterwards — the
// journal's first line then names the model the session was born on.
func launchHello(session, model, level string) remote.Hello {
return remote.Hello{Session: session, Model: model, Level: level}
}
// besideHello is the hello for a conversation opened BESIDE the one a window
// already has. It carries the launch's model and level for the same reason the
// launch's own does — a conversation opened an hour later is the same launch —
// and it carries the ask's own intention (chatv3_beside.go's [engineAsk]).
//
// IT DOES NOT SAY [remote.Hello.Window], deliberately: this is the same window
// on another connection, and the boot connection already holds the far
// machine's presence for it. Saying it here would count one window once per
// conversation it has open — a window with three tabs reading itself as three
// windows, which is the very count that says whether anybody else is still
// there when this one closes.
func besideHello(ask engineAsk, model, level string) remote.Hello {
return remote.Hello{Session: ask.session, New: ask.mint, Model: model, Level: level}
}
// diagnose turns a failed handshake into the truest sentence available.
//
// IT SNIFFS AND IT DOES NOT GUESS. A shell that could not find the command says
// so on stderr and exits 127; that is a fact, and "codeaf is not installed on
// devbox" is what it means. ssh's own failures exit 255 and have already printed
// their own reason, which is better than anything this function could invent, so
// they are not paraphrased. Everything else falls back to what the handshake
// itself said. The one thing this never does is offer a cause it cannot see.
func (l *engineLink) diagnose(dest string, cause error) error {
_ = l.close()
code := l.exitCode()
said := l.said()
switch {
case code == 127 || mentionsMissingCommand(said):
return missingHostCommand(dest)
case code == 255:
// ssh has already said why, in its own words, above this line.
return fmt.Errorf("ssh could not open a session on %s", dest)
default:
return cause
}
}
func missingHostCommand(dest string) error {
return fmt.Errorf("%s is not installed on %s — run %s on %s; put it on the PATH that a non-login ssh command sees", programName(), dest, codeupdate.CurlLine(enginehost.ThisBinary(), codeupdate.FollowedChannel(buildinfo.String())), dest)
}
// mentionsMissingCommand reads the far shell's own wording. The three spellings
// are bash/zsh, sh/dash and busybox, which is every shell an engine is likely to
// be started from.
func mentionsMissingCommand(said string) bool {
said = strings.ToLower(said)
const legacyCommand = "aforge" // legacy-name
return strings.Contains(said, "command not found") ||
strings.Contains(said, "not found") && (strings.Contains(said, "codeaf") || strings.Contains(said, legacyCommand)) ||
strings.Contains(said, "no such file or directory") && (strings.Contains(said, "codeaf") || strings.Contains(said, legacyCommand))
}
// close shuts the connection and reaps the process. Closing the client also
// ends the redialling, so no further ssh child is started after this.
func (l *engineLink) close() error {
if l.client != nil {
_ = l.client.Close()
}
return l.reap()
}
// reap waits on the current ssh child, once.
func (l *engineLink) reap() error {
process, err := l.claimReap()
if process == nil {
return err
}
err = process.Wait()
l.noteReaped(err)
return err
}
// claimReap marks the current child as being waited on and hands it back, or
// answers nil and the error already recorded when there is nothing to wait on.
// It is its own method so the mutex is held from a defer while the wait itself
// stays outside the lock.
func (l *engineLink) claimReap() (*exec.Cmd, error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.process == nil || l.reaped {
return nil, l.err
}
l.reaped = true
return l.process, nil
}
// noteReaped records what the wait answered. It is its own method so the mutex
// is held from a defer.
func (l *engineLink) noteReaped(err error) {
l.mu.Lock()
defer l.mu.Unlock()
l.err = err
}
// exitCode is ssh's exit status once it has been waited on, or -1.
func (l *engineLink) exitCode() int {
_ = l.reap()
l.mu.Lock()
defer l.mu.Unlock()
if l.process == nil || l.process.ProcessState == nil {
return -1
}
return l.process.ProcessState.ExitCode()
}
// said is the tail of what ssh and the far shell printed on the current link.
func (l *engineLink) said() string {
tail := l.tail()
if tail == nil {
return ""
}
return tail.String()
}
// tail is the current link's stderr keeper. It is its own method so the mutex
// is held from a defer while the read of the keeper, which takes a lock of its
// own, stays outside this one.
func (l *engineLink) tail() *tailWriter {
l.mu.Lock()
defer l.mu.Unlock()
return l.stderr
}
// pipePair is the ssh child's two halves as one [io.ReadWriteCloser], which is
// what internal/remote dials on. Closing it closes the WRITE half, which is what
// tells the engine there is nothing more coming and lets it exit cleanly; the
// read half then ends by itself.
type pipePair struct {
r io.ReadCloser
w io.WriteCloser
}
func (p pipePair) Read(b []byte) (int, error) { return p.r.Read(b) }
func (p pipePair) Write(b []byte) (int, error) { return p.w.Write(b) }
func (p pipePair) Close() error {
err := p.w.Close()
_ = p.r.Close()
return err
}
// tailWriter keeps the last few kilobytes of what was written through it, so a
// failure can be diagnosed from what the far end actually said. It is bounded
// because a remote command can print for ever and a diagnosis needs a sentence.
type tailWriter struct {
mu sync.Mutex
seen bytes.Buffer
}
const tailKept = 8 << 10
func (t *tailWriter) Write(b []byte) (int, error) {
t.mu.Lock()
defer t.mu.Unlock()
t.seen.Write(b)
if t.seen.Len() > tailKept {
kept := t.seen.Bytes()
kept = kept[t.seen.Len()-tailKept:]
t.seen.Reset()
t.seen.Write(kept)
}
return len(b), nil
}
func (t *tailWriter) String() string {
t.mu.Lock()
defer t.mu.Unlock()
return t.seen.String()
}
// shellQuote wraps a path for the far machine's shell, single quotes and all.
func shellQuote(word string) string {
return "'" + strings.ReplaceAll(word, "'", `'\''`) + "'"
}
// openChatV3Host is the launch.
func openChatV3Host(launch hostLaunch) error {
if err := launch.check(); err != nil {
return err
}
dest, workspace, err := parseHostTarget(launch.target)
if err != nil {
return err
}
if launch.pick && launch.once != "" {
return fmt.Errorf(`codeaf resume opens the session picker; for one headless message use: codeaf chat --host %s --once "text"`, dest)
}
hello := launchHello(launch.session, launch.model, launch.level)
hello.Headless = launch.once != ""
// AND A WINDOW IS A WINDOW OPEN ON THE FAR MACHINE, so that machine's
// automations clock runs while it is attached (internal/remote's
// [remote.Hello.Window]). It is said on THIS connection and on no other: the
// boot connection lives exactly as long as the window does, and every
// conversation opened beside it is the same window on another pipe
// ([besideHello] says nothing). A headless `--once` is a script, not a window.
hello.Window = launch.once == ""
link, err := dialEngine(dest, workspace, hello)
if err != nil {
return err
}
client := link.client
agent := client.Agent()
welcome := client.Welcome()
correctHostChoices(agent, launch, welcome)
if launch.once != "" {
defer func() { _ = link.close() }()
return runHostOnce(agent, launch.once)
}
// ANOTHER CONVERSATION IS ANOTHER ssh, and it is the same one as far as the
// network is concerned: the transport args ask OpenSSH to multiplex
// (`ControlMaster=auto`, [sshTransportArgs]), so the second link rides the
// connection the first one opened rather than authenticating again.
fleet := newEngineFleet(dest, client, link.close, func(ask engineAsk) (*engineConn, error) {
beside, err := dialEngine(dest, ask.workspace, besideHello(ask, launch.model, launch.level))
if err != nil {
return nil, err
}
return &engineConn{client: beside.client, shut: beside.close}, nil
})
defer fleet.closeAll()
options, _ := hostOptions(fleet, welcome, launch.pick)
options.Restart = launch.restart
// THE SAME WAY THE SURFACE IS RUN AT EVERY OTHER DOOR (chatv3_surface.go):
// the byte meter and the logger redirect are the terminal's business rather
// than this connection's, and a door does not state them for itself.
return runSurface(context.Background(), options)
}
// correctHostChoices makes the session agree with --model and --reasoning when
// the welcome says it does not.
//
// THE HELLO IS WHERE THESE BELONG AND THE HELLO IS WHERE THEY GO NOW. The engine
// opens the session on them ([remote.Hello]'s Model and Level), so the journal's
// first line names the model the person asked for rather than the one the far
// machine happens to default to. This is not that work done twice: it is the
// check that the ask LANDED. The welcome states the model the engine actually
// opened on, so a disagreement with --model is a fact the surface can see, and a
// surface that saw it and drew the engine's answer anyway would be showing a
// person a model they did not choose.
//
// The level cannot be read off the welcome — there is no field for it — so when
// one was asked for it is read back with the one round trip that can answer it,
// and set only when it differs.
func correctHostChoices(agent *remote.Agent, launch hostLaunch, welcome remote.Welcome) {
model := welcome.Model
if launch.model != "" && launch.model != model {
agent.SetModel(launch.model)
model = launch.model
}
// The boot override lands on the model this session starts on, which is the
// only model it can be about — the same law the local door states.
if launch.level != "" && model != "" && agent.ReasoningFor(model) != launch.level {
agent.SetReasoningFor(model, launch.level)
}
}
// hostOptions assembles the surface. Everything the far machine knows comes off
// the welcome; everything this machine keeps on the person's behalf is resolved
// here, exactly as the local door resolves it.
//
// ── LOOKING INTO ANOTHER CONVERSATION'S TASK IS NOT WIRED HERE, AND SAYING SO ──
//
// [tui3.Options.OpenTaskOwner] is deliberately left nil on this road, which the
// surface reads as a capability this window has not got: the row still takes the
// cursor and still answers `enter`, and what it opens is the card naming where
// the work is (internal/tui3's taskowner.go). That is the absence law rather
// than a gap somebody forgot.
//
// THE REASON IS THAT THE ROWS DO NOT EXIST ON THIS ROAD AT ALL. A row belonging
// to another conversation is minted from the PRESENCE DIRECTORY of the project,
// read off this process's own disk (internal/tui3's [app.refreshElsewhere], which
// asserts [session.Agent.Elsewhere] and gets nothing over a connection —
// [remote.Agent] does not implement it). Over `--host` and `--at` the conversation
// is on the far machine and its neighbours' presence files are on the far
// machine's disk, so this surface never learns they are there and draws no such
// row for the door to be behind.
//
// WIRING THE DOOR ALONE WOULD NOT FIX THAT and would be worse than leaving it:
// it would be a capability that could never be reached, which this codebase
// leaves off rather than ships broken. What the road actually owes first is the
// presence reading crossing the wire — a method on the protocol answering
// `Elsewhere` for the engine's machine — and that is a lane of its own with a
// version bump in it. Until it lands, the honest claim is the one the manual
// makes: reading another conversation's task works where the engine is local.
// IT IS ASSEMBLED AROUND A FLEET AND NOT AROUND A CONNECTION, which is the one
// thing that changed when this door learned to hold more than one conversation.
// The boot connection is still where every reading about the MACHINE goes; what
// the fleet adds is the pair of seams that open a conversation BESIDE this one,
// each on a connection of its own (chatv3_beside.go).
func hostOptions(fleet *engineFleet, welcome remote.Welcome, pick bool) (tui3.Options, config.Config) {
client, agent, dest := fleet.client(), fleet.agent(), fleet.dest
// THE PICKER'S LIST IS RESOLVED WITHOUT CREDENTIALS. The catalog is opened
// with whatever this machine happens to have — usually nothing, because the
// key lives on the engine's machine — and that is fine: a catalog with no key
// answers nil, the picker falls back to ~/.codeaf/v3/models.json and then to
// its built-ins, and the list is a list of NAMES rather than a claim about
// what this machine can reach. What the model actually costs and whether the
// switch took is the session's answer, and the session is over there.
//
// AND A MISSING KEY IS NOT AN ERROR ON THIS PATH, which is the one place this
// door differs from the local one in a way a person would notice. The local
// door stops with "codeaf chat needs a model to talk with" because it is about
// to build a session; this one is not — the session, and the key that pays for
// it, are on the other machine. Refusing to open a remote conversation because
// THIS laptop has no OPENROUTER_API_KEY would be asking for a credential
// nothing is going to spend.
settings, err := config.Load()
profileDir := settings.ProfileDir
if err != nil {
settings = config.Config{BaseURL: config.DefaultBaseURL}
profileDir = env.Get("CODEAF_PROFILE_DIR")
}
discovery := catalog.Options{BaseURL: settings.BaseURL, APIKey: settings.APIKey, Dir: profileDir}
models := catalog.LoadLazy(context.Background(), discovery)
// THE WARM IS THE WINDOW'S, and the window's fleet joins it when the door
// lets go (#1274): its fetch writes a cache when it lands, and one nobody
// joined could write after this window had closed.
fleet.own(models.Close)
// The crew router picks its seats from this catalog (config.CrewCatalog):
// the same non-blocking read, never a fetch, and set once at start-up.
seatCrewRows(models.ModelsNow)
// The pool's errands start beside it, against the same profile the catalog
// was read from.
wirePoolIndex(profileDir)
// The refresh key in /model asks the same router THIS machine's list came
// from, and refills the same shelf — the list is this laptop's list of
// names on both doors (chatv3_modelshelf.go).
shelf := newV3ModelShelf(models, discovery)
seams := newHostSeams(client)
// EVERY BACKGROUND READING IS ARMED THROUGH ONE SEAM: the connection, and the
// notice line a duty that fell over speaks to once (chatv3_host_duty.go). A
// door with no client behind it arms duties that never start, rather than
// goroutines that die on the wire.
news := &hostNews{}
seams.Notice = news.join(seams.Notice)
far := hostFar{client: client, tell: news.say}
// THE PLACES FOLLOW THE SESSION'S MACHINE. The world behind home, tasks,
// spend and search is asked of the ENGINE and kept warm here, and
// it is asked once now so the first frame after launch already has it
// ([hostWorld] holds both laws).
world := newHostWorld(far)
world.prime()
ledger := newHostLedger(far)
ledger.prime()
memory := newHostMemory(far)
memory.prime()
// The counting gate is here and not on the roads: a session this window
// only watches through its link is counted from the events it receives
// (telemetry_events.go), and hostOptions is the one place the surface's
// boot agent is assembled on all of them. IT IS WRAPPED ONCE, because the
// shared door below hands the same handle back from Resume and Fresh and
// the surface holds it to that (chatv3_host_shared_test.go).
counted := countedAgent(agent)
options := tui3.Options{
Agent: counted,
Build: welcome.Build,
Host: dest,
SyncService: welcome.SyncService,
Workspace: welcome.Workspace,
// The engine's own journal path, shown with its machine in front of it
// wherever the surface shows it (internal/tui3's host.go).
SessionFile: welcome.SessionFile,
Resumed: welcome.Resumed,
Notice: hostEntryNotice(welcome),
UnreadProfileKeys: append([]string(nil), welcome.UnreadProfileKeys...),
// The engine's own tool-approval posture, so the YOLO badge names the
// machine that actually decides whether a tool runs unattended
// (internal/tui3's app.approvalPosture).
ApprovalMode: welcome.ApprovalMode,
// The engine's own foreground-command clock, for the same reason. The
// laptop's profile may carry a different value and cannot describe the
// timer that is already armed around a far process.
BashBackgroundAfterSeconds: welcome.BashBackgroundAfterSeconds,
ContextWindow: v3Window(models, welcome.Model),
Models: shelf.pickerModels,
RefreshModels: shelf.refresh,
ProviderFetchError: shelf.fetchErrorFor,
// /export writes on THIS machine (host.go's honesty table), so its row
// goes in this machine's index — the same one the local launch spells.
ArtifactsIndex: artifactsIndexPath(),
ProfileDir: profileDir,
PickSession: pick,
// The conversations the ENGINE's disk holds, and the door back into one of
// them. Both go over the wire; neither reads a session file here.
RecentSessions: func() []tui3.Session { return hostSessions(client) },
// ── THE PLACES, AS THE ENGINE MACHINE HOLDS THEM ────────────────────
//
// The seven places are a listing of one machine's disk, and until this
// pair existed the surface listed its OWN: over --host the tasks place
// walked this laptop's `~/.codeaf/v3` and drew eight rows and $22.54 of
// work under a conversation on a server that had run none of it. The
// world crosses the wire now (internal/remote's Places.World), and the
// root it was walked under travels with it so that the conversation this
// window is sitting in can be put back into a walk taken before it
// existed (internal/tui3's [app.worldRoot]).
World: world.world,
WorldRoot: welcome.PlacesRoot,
// AND ONE ROW OF THAT RECORD, WHEN SOMEBODY OPENS ITS CARD. It is a call
// and not a cache, unlike the world above: the world is asked on every
// place's open and on the beat, and this is asked once, on a press, about
// one of four hundred rows. The surface only ever makes it off its loop
// (internal/tui3's [app.readTaskTail]), so the wire's own deadline is the
// only clock it needs.
TaskRecord: client.TaskRecord,
TaskRoom: agent.TaskRoom,
// TaskIndex is filled below, from the same shared walk, once this
// conversation's transcript is the only thing left to key it by.
Ledger: ledger.read,
Memory: memory,
// AND THE ONE WRITE HOME MAKES AGAINST THAT WORLD GOES THROUGH THE SAME
// CACHE IT READS. A bare client.Archive landed on the engine's disk and
// left what is held saying the opposite, so the row `ctrl+e` put away sat
// on the screen until a later beat fetched it back ([hostWorld.archive]
// carries the whole of why).
Archive: world.archive,
// THE TEAMS, AS THE ENGINE MACHINE KEEPS THEM. The far session's team
// tools write the teams file and each team's Traffic into the engine's
// profile, so the window reads and writes those, over the wire
// ([hostTeams]); an engine without the doors hands no seam and the
// surface turns teams off rather than keeping them on this laptop.
Teams: hostTeamsSeam(far, welcome),
// THE AUTOMATIONS, AS THE ENGINE MACHINE KEEPS AND RUNS THEM. The
// conversation over there proposes into that machine's store and that
// machine's clock runs what it keeps — while this window is attached,
// because the boot hello says this connection is a window
// ([openChatV3Host]) — so the list, the run lines and the notifications
// are read over the wire ([hostAutomationsSeam]). An engine without the
// doors hands no seam and this window shows no automations, never this
// laptop's, which run somewhere else. The linked-local road puts its own
// machine's store back on top ([openChatV3Local]).
Automations: hostAutomationsSeam(client, welcome),
// AND THE BANNERS ARE THIS MACHINE'S: they are raised by this window, in
// the terminal in front of the person, whichever machine ran the run.
Notify: v3NotifyRoute(),
// ── WHAT THE CONNECTION ITSELF SAYS ─────────────────────────────────
//
// The four facts only a connection has: the sentence to draw while a
// dropped link is being redialled, the measured round trip, the one-off
// news a redial discovered, and the questions this conversation raised
// while nobody was attached.
// Each lands somewhere different on the screen and internal/tui3's
// hostlink.go says where; what this door owes is the answer, and the
// client answers all four.
//
// AND IT IS ASSEMBLED THROUGH ONE FUNCTION, because every conversation
// beside this one has a connection and therefore a seam of its own
// (chatv3_beside.go's [hostLink]). Two spellings of these seven fields is
// how the conversation on screen and the conversation behind it would come
// to disagree about which questions are waiting.
Link: hostLink(seams),
// ── WHAT IS DELIBERATELY NOT WIRED ──────────────────────────────────
//
// These absences belong to --host and --at. The linked-local road uses
// this builder too, then [localDoors] in chatv3_local.go puts back the
// stores whose engine and surface are on this same machine.
//
// Connections: the accounts panel signs in through a browser HERE and
// stores the result HERE, while the session reads the store THERE. A
// panel wired to this machine's manager would offer rows that landed in
// the wrong place, so none is handed over and /connect says so in one
// sentence (internal/tui3's host.go).
//
// ConnectCodex: the same split for the Codex row — the sign-in's
// callback listener would be on this loopback and its tokens in this
// profile, while the session that needs them runs over there. The row
// says the sign-in is unavailable here, and the terminal door on that
// machine (`codeaf connect codex`) is the road that works.
//
// Harnesses: the registry the engine matches turns against is on the
// engine's machine. Listing this machine's under /harness would be
// offering to run harnesses that are not there.
//
// SaveApproval and SaveBashApproval: the consent card's "always" writes a
// row into a profile, and the gate that reads it is the far machine's. Nil
// is the honest wiring — the card keeps the answer for the session, over
// the wire, and its row says "allowed" rather than "saved", which is
// exactly what happened (internal/tui3's consent.go).
//
// ErrandsRoot: the local errands folder, which is where a question asked
// from home leaves its short transcript. It is a path on
// THIS machine and the exchange it hosts is a session this surface would
// have to open here, so it stays unset over a connection — the same
// posture the errand pair takes.
//
// SaveModel: the same rule for the same reason. The model this session
// opens on next time is resolved where the session is built, which is over
// there; writing the choice into THIS machine's profile would change which
// model a local conversation starts on because somebody switched models on
// a remote one. The switch itself still takes — it goes over the wire like
// everything else — it simply does not outlive the session.
//
// Sources and ApplyModelSources: the model-service profile belongs to the
// engine's machine too. A far surface must neither draw this laptop's
// services nor write one here and claim the engine can use it. The local
// road hands its resolved set to the surface, while the engine re-reads
// that same profile through the existing model-set call.
//
// AND THE FILE DOOR IS NOT A SEAM HERE EITHER, which is worth saying
// because it looks like an omission and is not. Remote files — the links
// in a reply, the browse page /files opens, the fetch behind opening one
// — ride the CLIENT under this agent: the surface asserts `Client()` on
// whatever it was handed and gets ListDir, StatPaths and FetchFile off it
// (internal/tui3's remotefiles.go, on attach.go's narrow-interface
// pattern). So the door this file already passes is the whole wiring, a
// local session satisfies none of it and gets none of it, and there is no
// closure here that could go stale around a conversation that was swapped.
}
// The two things this surface keeps on the person's behalf, resolved the way
// the local door resolves them and keyed by the REMOTE workspace — which is
// right: what a person typed while working on devbox:code/app belongs to that
// place, not to whatever directory this terminal happens to be sitting in.
if dir, err := v3Dir(); err == nil {
store := history.New(filepath.Join(dir, "history.jsonl"))
options.History = store
options.DraftFile = tui3.ChatDraftFile(dir, dest, welcome.Workspace, welcome.SessionFile)
}
// ── AND THE DOORS ONTO ANOTHER CONVERSATION ─────────────────────────────
//
// The four readings a conversation opened beside borrows are handed to the
// fleet HERE rather than resolved again per conversation: every one of them
// is about this window or about the engine's disk, all four are already
// cached behind the surface, and a second copy per tab would be a second walk
// of the far machine's places on every switch.
fleet.machine = machineReadings{
recent: options.RecentSessions,
world: options.World,
window: func(model string) int { return v3Window(models, model) },
draft: options.DraftFile,
history: options.History,
}
// THIS CONVERSATION'S OWN ROWS, out of the same shared walk every conversation
// beside it reads its own out of (chatv3_beside.go's [farTaskRows]). It
// replaces a closure that named this session file and was left bound to it
// through every switch.
options.TaskIndex = farTaskRows(options.World, welcome.SessionFile)
if dest == "" {
// The linked engine and this window read the same profile, so its shelf
// can list every connected provider just as the in-process door does.
engineProfile := strings.TrimSpace(welcome.ProfileDir)
if engineProfile == "" {
engineProfile = profileDir
}
shelf.options.Dir = engineProfile
shelf.setSources(config.ResolveSources(engineProfile, settings.APIKey, settings.BaseURL))
listing := &v3Process{Shelf: shelf}
options.RefreshModels = listing.refreshDefaultModels
options.ModelsForService = shelf.modelsForService
options.RefreshModelsForService = shelf.refreshService
options.RefreshAllModels = listing.refreshAllModels
options.WarmEmptyProviders = listing.warmEmptyProviders
options.SubscribeServiceModels = listing.registerServiceNotice
options.ApplyModelSources = shelf.setSources
}
if welcome.TaskDelete {
options.DeleteTask = func(file, id string) error {
if err := client.DeleteTask(file, id); err != nil {
return err
}
world.fetch()
return nil
}
}
if !fleet.canBeside() {
// A DOOR THAT CANNOT DIAL AGAIN REALLY DOES HOLD ONE CONVERSATION AT A
// TIME, and says so ([tui3.Options.SharedAgent]) rather than letting
// somebody discover it. The two seams below select in place on the one
// connection: the engine mints or opens the conversation and ENDS the one
// it replaced (internal/remote's Session.swap), so this handle names
// whichever conversation is now open and there is nothing left for the
// surface to close.
options.SharedAgent = true
options.Resume = func(file string) (tui3.Agent, error) {
if _, err := client.OpenSession(file); err != nil {
return nil, err
}
return counted, nil
}
options.Fresh = func() (tui3.Agent, string, error) {
next, err := client.NewSession()
if err != nil {
return nil, "", err
}
return counted, next.SessionFile, nil
}
return options, settings
}
// AND A DOOR THAT CAN GETS THE WHOLE SEAM AND NOT THE OLDER HALF OF IT.
// [tui3.Options.Start] and [tui3.Options.Open] hand back a conversation with
// its own agent, its own connection, its own transcript and its own waiting
// room; [Options.Fresh] and [Options.Resume] are deliberately left unset, so
// there is exactly one door and no road on which a swap can still happen.
options.Start = fleet.start
options.Open = fleet.open
if welcome.ConversationDelete {
options.DeleteConversation = func(file string, choices map[string]string, affected map[string][]string) error {
if err := client.DeleteConversation(file, choices, affected); err != nil {
return err
}
world.mu.Lock()
defer world.mu.Unlock()
for i := range world.held.Projects {
rows := world.held.Projects[i].Sessions
kept := rows[:0:0]
for _, row := range rows {
if row.Transcript != file {
kept = append(kept, row)
}
}
world.held.Projects[i].Sessions = kept
}
return nil
}
}
return options, settings
}
// ── what the engine says about the room ─────────────────────────────────────
// hostEntryNotice is the sentence the surface shows once on the way in: what the
// engine said about opening the session, and who else is already in it.
//
// WHO ELSE IS HERE IS NOT A DETAIL. A session with another surface attached is a
// conversation somebody else can type into, and a screen that kept that quiet
// would be the one place codeaf hid something about the room. The count is the
// engine's ([remote.Welcome]'s Attached), because only the machine holding the