Repository navigation
Expand file tree
/
Copy pathchatv3.go
More file actions
2971 lines (2861 loc) · 145 KB
/
Copy pathchatv3.go
File metadata and controls
2971 lines (2861 loc) · 145 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 (
"context"
"errors"
"fmt"
"github.com/Agent-Field/codeaf/internal/crewroute"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/Agent-Field/codeaf/internal/approval"
"github.com/Agent-Field/codeaf/internal/buildinfo"
"github.com/Agent-Field/codeaf/internal/catalog"
"github.com/Agent-Field/codeaf/internal/cell"
"github.com/Agent-Field/codeaf/internal/codexauth"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/connect"
"github.com/Agent-Field/codeaf/internal/effort"
"github.com/Agent-Field/codeaf/internal/enginehost"
"github.com/Agent-Field/codeaf/internal/env"
"github.com/Agent-Field/codeaf/internal/gitidentity"
"github.com/Agent-Field/codeaf/internal/guard"
"github.com/Agent-Field/codeaf/internal/handoff"
"github.com/Agent-Field/codeaf/internal/home"
"github.com/Agent-Field/codeaf/internal/leave"
"github.com/Agent-Field/codeaf/internal/openrouterauth"
"github.com/Agent-Field/codeaf/internal/resident"
"github.com/Agent-Field/codeaf/internal/roles"
"github.com/Agent-Field/codeaf/internal/search"
"github.com/Agent-Field/codeaf/internal/session"
"github.com/Agent-Field/codeaf/internal/store"
"github.com/Agent-Field/codeaf/internal/subharness"
"github.com/Agent-Field/codeaf/internal/trace"
"github.com/Agent-Field/codeaf/internal/tui3"
codeupdate "github.com/Agent-Field/codeaf/internal/update"
)
// runChatV3 is the v3 door: one session agent over this directory, and the
// linear surface that shows it. It claims no residency and starts no runner;
// the ONE thing it opens of the graph database is the person's memories
// (milestone V3-1, [v3Memory]), because those are the only rows in it a
// conversation has any business reading.
func runChatV3(args []string) error { return openChatV3("chat", args, false) }
// runResumeV3 is `codeaf resume`: the same door, opened on the session picker.
//
// It is one word rather than a flag on chat because it is what a person is
// doing when they type it — coming back to a conversation, not starting one —
// and it takes chat's flags for the same reason: the session it opens is a chat
// session, and a model or a reasoning level named on the way in is named about
// that.
//
// IT DOES NOT RESUME ANYTHING BY ITSELF. The surface opens exactly as `codeaf`
// bare does, on this directory's most recent conversation, with the picker over
// it — so esc lands where the person would have been anyway, and enter lands
// where they asked to be. A launcher that opened on an empty session instead
// would make "resume" the one command that can leave you with nothing.
func runResumeV3(args []string) error { return openChatV3("resume", args, true) }
// v3OpenRouterConnection is the default provider's browser door. Comparing the
// resolved endpoint, rather than merely looking for CODEAF_BASE_URL, also does
// the right thing for a caller that explicitly names the built-in address and
// for one that carries a harmless trailing slash.
func v3OpenRouterConnection(settings config.Config, interactive bool) func(context.Context) (tui3.OpenRouterFlow, error) {
if !interactive || !v3UsesDefaultOpenRouter(settings) {
return nil
}
return func(ctx context.Context) (tui3.OpenRouterFlow, error) {
return openrouterauth.Begin(ctx, openrouterauth.Options{})
}
}
// v3CodexConnection is the local browser door for the Codex model-service row.
// A hosted surface gets no seam because its profile and callback listener live
// on different machines; /connect already says how to sign in on that machine.
func v3CodexConnection(interactive bool) func(context.Context) (tui3.CodexFlow, error) {
if !interactive {
return nil
}
return func(ctx context.Context) (tui3.CodexFlow, error) {
return codexauth.Begin(ctx, codexauth.Options{})
}
}
func openChatV3(name string, args []string, pickSession bool) error {
restart := &codeupdate.Plan{}
flags := commandFlags(name)
model := flags.String("model", "", "model slug for this session; beats the configured default")
once := flags.String("once", "", "run one message non-interactively, print the reply, and exit")
file := flags.String("session", "", "session transcript to resume; empty resumes this directory's most recent")
noCompact := flags.Bool("no-compact", false, "never compact automatically")
yolo := flags.Bool("yolo", false, "run every tool without asking: the approval default becomes allow")
reasoning := flags.String("reasoning", "", "reasoning override: auto (inherit), low, medium, high, xhigh or max; off is an alias for auto")
host := flags.String("host", "", "run the session on another machine over ssh: host, user@host, or host:path/to/project")
at := flags.String("at", "", "reach a machine that has no ssh, by the name `codeaf serve` prints there: otter-lamp-42, or otter-lamp-42:path/to/project")
// --no-host is the escape hatch off the local dial, and it means here
// exactly what it means on `codeaf engine`: open this conversation in this
// process and never look for a session host. It exists because a fallback
// nobody can ask for is a fallback nobody can use on the day the host
// itself is the thing that is wrong.
noHost := flags.Bool("no-host", false, "open this conversation in this process instead of attaching to this workspace's session host")
oneModel := flags.Bool("one-model", false,
"every text call this session makes runs on the session model: the tier rows, the role pins, "+
"the fallback chain and the task model all stand down")
// THE TWO CEILINGS AN UNATTENDED SESSION MAY BE GIVEN, and they are floats
// rather than durations because a person types `--max-hours 6` and
// `--max-hours 0.5`, not `6h0m0s`. Either alone is a budget; neither is the
// posture this build has always had. They mean nothing without --yolo, and
// the check below says so rather than letting a flag do nothing in silence.
maxHours := flags.Float64("max-hours", envFloat("CODEAF_MAX_HOURS"),
"how many hours an unattended --yolo session may carry its own work on; the window closes itself "+
strconv.Itoa(int(launchWallGrace/time.Minute))+" minutes after (env CODEAF_MAX_HOURS)")
maxCost := flags.Float64("max-cost", envFloat("CODEAF_MAX_COST"),
"how many dollars an unattended --yolo session may carry its own work on (env CODEAF_MAX_COST)")
debug := flags.Bool("debug", false,
debugFlagHelp()+" · /debug turns it on mid-session")
if err := parseCommandFlags(flags, reorder(flags, args)); err != nil {
return err
}
// THE RECORD'S SWITCH IS READ HERE AND THE RUN ID IS MINTED HERE, at the
// door, before anything can make a call — because a record written under a
// second id, or under none, is a record nothing can be joined to. The id is
// minted whether or not the switch is on: /debug can turn the record on
// halfway through a conversation, and it needs a run to turn it on for.
//
// THE RECORD ITSELF OPENS AFTER THE LAUNCH'S OWN UPDATE below. That road
// can hand the terminal to another build, and a record opened first would
// be one that run never writes to. Nothing between here and there makes a
// model call.
if *debug {
trace.Enable()
}
if flags.NArg() != 0 {
// Resume's usage names no --once and no --session: it opens a list of
// the sessions there ARE, so naming one on the command line is the other
// door, and nobody is watching a headless one.
if pickSession {
return fmt.Errorf(`usage: codeaf resume [--model slug] [--reasoning level] [--host host[:path]] [--at name[:path]] [--no-host] [--no-compact] [--yolo [--max-hours n] [--max-cost n]] [--one-model] [--debug]`)
}
return fmt.Errorf(`usage: codeaf chat [--model slug] [--reasoning level] [--session path] [--host host[:path]] [--at name[:path]] [--no-host] [--once "text"] [--no-compact] [--yolo [--max-hours n] [--max-cost n]] [--one-model] [--debug]`)
}
// --one-model is about THIS machine's settings rows, and over --host the
// rows that answer are the far machine's (chatv3_host.go). A flag that
// looked like it applied and did not would be worse than one that is not
// there, so the combination is refused rather than quietly dropped.
if *oneModel && strings.TrimSpace(*host) != "" {
return fmt.Errorf("--one-model settles this machine's model rows; over --host the far machine answers them, so the two cannot be combined")
}
// --at is the same fork as --host and differs only in what carries it, so
// it inherits --host's refusal word for word: the rows that answer are
// still the other machine's.
if *oneModel && strings.TrimSpace(*at) != "" {
return fmt.Errorf("--one-model settles this machine's model rows; over --at the far machine answers them, so the two cannot be combined")
}
// --no-host is about the session host on THIS machine, and over --host or
// --at the conversation is on the far machine either way — there is no
// local host in the picture for the flag to refuse. Accepting it and doing
// nothing is the shrug this tree does not do, so the pair is named.
if *noHost && strings.TrimSpace(*host) != "" {
return fmt.Errorf("--no-host keeps a conversation in this process; over --host the conversation is on the far machine, so the two cannot be combined — `ssh %s codeaf engine --no-host` is where that setting lives", strings.TrimSpace(*host))
}
if *noHost && strings.TrimSpace(*at) != "" {
return fmt.Errorf("--no-host keeps a conversation in this process; over --at the conversation is on the far machine, so the two cannot be combined")
}
// TWO WAYS TO REACH ONE MACHINE IS NOT TWO MACHINES. Naming both is a
// person saying two different things about where the work is, and guessing
// which they meant would open a conversation on a machine they did not
// name.
if strings.TrimSpace(*host) != "" && strings.TrimSpace(*at) != "" {
return fmt.Errorf("--host reaches a machine over ssh and --at reaches one through the sync service: name one or the other, not both")
}
// A BUDGET IS A SENTENCE ABOUT AN UNATTENDED SESSION, so it is refused
// rather than ignored on a session somebody is sitting in front of. What it
// buys — a goal owner that carries the work on by itself (internal/session's
// principal.go) — is the thing --yolo alone must never be read as permission
// for, and a ceiling on a session that was never going to carry anything on
// is a number that quietly did nothing.
//
// IT IS ASKED OF A LOCAL LAUNCH ONLY. Over --host and over --at the ceiling
// cannot travel at all and each door says so in its own words
// (chatv3_host.go's and chatv3_at.go's checks); two refusals for one flag
// would send somebody to add --yolo and straight into the second one.
if budget := chatBudget(*maxHours, *maxCost); budget.Set() && !*yolo && strings.TrimSpace(*host) == "" && strings.TrimSpace(*at) == "" {
return fmt.Errorf("--max-hours and --max-cost bound a session that carries its own work on; say --yolo as well, or leave them off")
}
// A picker with nobody watching is not a picker. --once is the headless
// door, and the two are a contradiction rather than a combination, so it is
// said here — before a session file is opened — instead of being ignored.
if pickSession && strings.TrimSpace(*once) != "" {
return fmt.Errorf(`codeaf resume opens the session picker; for one headless message use: codeaf chat --once "text"`)
}
// --max-hours ENDS THE PROCESS, a grace after the wall (chatwall.go). The
// session's own reader stops its work AT the wall; this is what closes the
// window afterwards, because a window nobody is watching was found alive
// forty hours past a nine-minute cap.
if wall := chatBudget(*maxHours, *maxCost).Wall; wall > 0 && *yolo {
defer armLaunchWall(wall, time.AfterFunc, leaveThisProcess)()
}
// The level is validated HERE, before anything is opened, so a typo is a
// usage error and not a knob that silently did nothing for a whole session.
// It is a one-shot: ctrl+t in the picker overrides it from that moment on,
// and /new — a new agent — starts with no level at all.
level, ok := session.ParseReasoning(*reasoning)
if !ok {
return fmt.Errorf("--reasoning %q: use auto, low, medium, high, xhigh or max (off also clears the override)", *reasoning)
}
// THE LAUNCH'S OWN UPDATE, after every refusal above and before any door
// forks: a released codeaf installs the newest stable and restarts onto it
// here, before a frame is drawn, and every other launch is told what is out
// on one terminal line (prelaunch.go). It is the same for all four doors
// because the file being replaced is this machine's whichever one opens.
// --once is a script, not a person, and is never restarted under it.
if strings.TrimSpace(*once) == "" {
updateBeforeLaunch()
}
ctx := openDebugRecord(name, *model, "")
defer trace.Announce(ctx, os.Stderr)
// THE OTHER DOOR, and it forks BEFORE any of this machine's own resolution
// below: over --host the models, the keys, the session files, the gate and
// the harnesses are all the far machine's, and reading this one's would be
// resolving a launch nobody asked for (chatv3_host.go).
if dest := strings.TrimSpace(*host); dest != "" {
err := openChatV3Host(hostLaunch{
target: dest,
session: strings.TrimSpace(*file),
model: strings.TrimSpace(*model),
level: level,
once: strings.TrimSpace(*once),
pick: pickSession,
noCompact: *noCompact,
yolo: *yolo,
budget: chatBudget(*maxHours, *maxCost).Set(),
restart: restart,
})
return finishChatRestart(err, restart, "")
}
// THE THIRD DOOR, and it forks here for the reason --host does: a machine
// reached by its paired name owns exactly what a machine reached over ssh
// owns. The two differ only in what carries the frames — an ssh child there,
// a relay tunnel here — which is the whole point of the transport being an
// io.ReadWriteCloser and nothing more (chatv3_at.go, docs/REMOTE.md).
if name := strings.TrimSpace(*at); name != "" {
err := openChatV3At(atLaunch{
target: name,
session: strings.TrimSpace(*file),
model: strings.TrimSpace(*model),
level: level,
once: strings.TrimSpace(*once),
pick: pickSession,
noCompact: *noCompact,
yolo: *yolo,
budget: chatBudget(*maxHours, *maxCost).Set(),
restart: restart,
})
return finishChatRestart(err, restart, "")
}
// The fourth door, and the only one with no machine in it: this workspace's
// own session host, on a unix socket. It is the ordinary road for an
// interactive launch — the work outlives this terminal and the next window
// here joins the same conversation — and [v3HostRoad] names the launches
// that keep the in-process door instead.
//
// It forks HERE, beside the other two, and for their reason: the launch
// below assembles this machine's models, keys, gate and session files, and
// the process on the other end of that socket has already assembled its own.
//
// The per-launch postures travel with it. --yolo and its neighbours are how
// the session is BUILT, so they ride the hello and the engine builds with
// them; a conversation that is ALREADY open keeps the shape it was opened
// with, and this launch is told so and comes back here rather than running
// under a posture nobody asked for.
//
// entryNotice is that sentence, or the reason no host could be used. Either
// way it is shown on the surface's own notice line below rather than printed
// into a terminal the surface is about to take over.
entryNotice := ""
if workspace, take := v3HostRoad(v3HostChoice{
noHost: *noHost,
once: strings.TrimSpace(*once) != "",
// THE SWITCH AND NOT THE FLAG. --debug has already turned the record on
// above, and so has CODEAF_DEBUG in the shell that started this process
// (internal/trace's init) — both are this process being told to record,
// and both must keep the calls in this process to have anything to
// record ([v3HostChoice.debug]).
debug: trace.Enabled(),
setup: !v3MachineIsSetUp(),
}); take {
err := openChatV3Local(localLaunch{
workspace: workspace,
session: strings.TrimSpace(*file),
model: strings.TrimSpace(*model),
level: level,
once: strings.TrimSpace(*once),
pick: pickSession,
shape: v3LaunchShape(*yolo, *noCompact, *oneModel, *maxHours, *maxCost, strings.TrimSpace(*once) == ""),
restart: restart,
})
var taken *hostShapeTaken
var unreachable *hostUnreachable
switch {
case errors.As(err, &taken):
entryNotice = taken.sentence
case errors.As(err, &unreachable):
entryNotice = "this conversation opened in this terminal instead, and ends with it: " + unreachable.reason
default:
return finishChatRestart(err, restart, workspace)
}
}
// Everything both v3 doors assemble the same way: the settings, the model
// catalog, the harness registry, the session file, and the config every
// governance row has landed on. `codeaf engine` opens a conversation for a
// surface on another machine and opens it THROUGH HERE (engine.go), so a
// remote session is the same launch this one is rather than a second one
// drifting quietly away from it.
//
// The process half is built first and exactly once (chatv3_process.go): the
// profile, the catalog, the harness registry, the memory database, the
// deliverables index and the accounts manager are things this PROCESS owns,
// and a launch borrows them rather than opening a second of each.
// WHETHER THIS LAUNCH MAY SET ITSELF UP. It is a person at a terminal with
// no particular conversation in mind — a TTY on stdin, no --once (which
// returned above, but the flag is the honest test), no --session and no
// picker — and it is the one launch that may open with no key and ask for
// one on screen (internal/tui3's firstrun.go). A named or picked conversation
// may connect too — it cannot talk without the same key — while --once still
// meets the refusal at the door. --host forked above and never reaches here;
// the far machine's key is the far machine's business.
interactive := stdinIsTerminal(os.Stdin) && strings.TrimSpace(*once) == ""
setup := interactive && strings.TrimSpace(*file) == "" && !pickSession
proc, err := openV3ProcessWith("chat", interactive)
if err != nil {
return err
}
seed := v3Options{
Model: *model,
// A TERMINAL DOOR IS STEERED unless --once is it: --once is the
// headless fork below. The seed carries the fact into the boot
// launch, the seam's /new and /resume, and the hosted shape.
Interactive: strings.TrimSpace(*once) == "",
NoCompact: *noCompact,
Yolo: *yolo,
OneModel: *oneModel,
Budget: chatBudget(*maxHours, *maxCost),
}
boot := seed
boot.Session = *file
launch, err := openV3Launch(proc, boot)
if err != nil {
return err
}
settings, harnesses := launch.Settings, launch.Harnesses
workspace, transcript, resumed := launch.Workspace, launch.SessionFile, launch.Resumed
chosen, cfg := launch.Model, launch.Config
if text := strings.TrimSpace(*once); text != "" {
// Nobody is watching a --once run, so nobody can answer a question. The
// policy's "prompt" therefore refuses the call with a result the model
// can act on (internal/session's consent.go), and a person who wants
// that work to run says so with --yolo. NON-INTERACTIVE WORK NEEDS AN
// EXPLICIT POSTURE: the two honest answers are "refuse what you would
// have asked about" and "I have said in advance that this may run", and
// neither of them is a gate that quietly opens because the terminal
// happens to be a pipe.
cfg.AskConsent = false
// AND NOTHING MAY BE SET UP HERE. An automation spends money at times
// nobody chose, so it needs a yes; a headless run has nobody to give
// one, and a clock that armed it anyway would be the harness agreeing on
// somebody's behalf. The tool is simply absent (internal/session's
// tools.go), which is the same law --once already applies to consent.
cfg.Automations = nil
// AND THE SUBHARNESS SEAMS ARE LEFT ALONE, which is not an oversight
// beside the line above it. Automations have to be taken away here
// because nothing else asks whether anybody is watching before it arms a
// clock. A
// subharness cannot start without somebody confirming an intake card, and
// internal/session already reads that in one place — its own gate is
// `AskConsent && there is something to offer` (tools_subharness.go), and
// the line above has already answered the first half. Nilling the registry
// too would be a second copy of a decision that is made correctly one
// layer down, and it would take the `/subharness` list away from a door
// that may yet grow one.
return runChatV3Once(ctx, cfg, launch.Project, text, level, resumed)
}
// Interactive: there is a surface, and it answers (internal/tui3's
// consent.go). This is the ONLY path that sets it.
cfg.AskConsent = true
// AND THAT SURFACE HOLDS EVERY STANDING LANE, which is a second fact and not
// the same one: a design card, a subharness intake card and an adaptive
// run's fuel gate each arrive on a subscription opened on the agent itself
// (internal/tui3's watchDesigns and watchRuns), and here the surface and the
// session are one process, so all three reach a person by construction. The
// three are set together, in the one place that decides them for every door
// (chatv3_lanes.go), so this launch and a hosted one differ in what the road
// carries rather than in what two files remembered to say.
cfg, open := v3Shape(cfg, v3LanesHere())
agent, cfg, notice, err := openV3Agent(cfg, launch.Project, open)
// LAUNCH-ON-LOCK. The conversation this terminal asked for is open in
// another window, and this door has a screen — so it offers that
// conversation rather than refusing or, as it once did, quietly handing over
// a different one. A fresh conversation is opened beside it and its
// transcript path travels to the surface, which comes up on home with that
// row pointed and ARMED: one enter moves the real one here, esc gets on with
// the new one (internal/tui3's takeover.go).
takeOver := ""
var held *sessionHeldElsewhere
if errors.As(err, &held) {
takeOver = held.transcript
agent, cfg, err = v3TakeOverInstead(cfg, workspace)
// The session file moved, so everything downstream that names it names
// the new one — the same correction the notice below makes.
transcript, resumed = cfg.SessionFile, false
}
if err != nil {
return err
}
// AND NOW THE BELT EXISTS, so a program's tool guard can be answered. Until
// this line every such guard answers no and the work takes the long way,
// which is the safe direction and not the useful one ([beltWatch] states the
// ordering problem this closes).
launch.Subharnesses.Belt.watch(agent.ToolOnBelt)
if notice != "" {
// The session file moved under us, so everything downstream that names
// it — /help, /new, the resumed line — has to name the new one.
transcript, resumed = cfg.SessionFile, false
}
// The boot override lands on the model this session starts on, which is the
// only model it can be about: --reasoning names a strength, not a model, and
// the level is kept per model from here on (internal/session's agent.go).
agent.SetReasoning(level)
proc.track(agent)
// EVERY CONVERSATION THIS PROCESS OPENED, CLOSED HOWEVER THE SURFACE RETURNS.
// Close is the surface's to call — /quit and ctrl+c both go through it — but
// a Run that returns by any other road must still flush the files, release
// the session flocks and stop the presence heartbeats, and after this wave
// there is more than one of each. The defer that used to stand here owned
// exactly the boot agent, and the store's close was registered before it so
// that it ran after; [v3Process.closeAll] holds both halves of that ordering
// itself and is a no-op the second time it is called.
defer proc.closeAll()
proc.warmModels("chatv3/models", agent, chosen)
// The two things this surface keeps on the person's behalf rather than the
// session's: what they have typed before, and what they have half-typed
// now. Both are settings (internal/config), both are off by one row, and
// neither is ever waited for — a history file that cannot be opened costs
// the up arrow and nothing else.
//
// They read through the project layer like every other row: a repository
// that says "record nothing from this directory" is answering about ITS
// directory, which is the whole reason the layer is per-workspace. The
// broken-file error cannot actually arrive here — governance above read the
// same file and would have stopped the launch — and it is still returned
// rather than swallowed, because the only wrong thing to do with an
// unreadable settings file is carry on as though it said nothing.
keepHistory, err := config.ProjectBoolAt(workspace, settings.ProfileDir, config.KeyHistoryEnabled)
if err != nil {
return err
}
keepDraft, err := config.ProjectBoolAt(workspace, settings.ProfileDir, config.KeyDraftPersist)
if err != nil {
return err
}
var recall tui3.History
if keepHistory {
// One file per process, opened on the first workspace that wants it and
// closed by [v3Process.closeAll] (chatv3_process.go). The rows carry the
// directory they were typed in, so the surface asks for its own.
recall = proc.history()
}
draft := ""
if keepDraft {
if dir, err := v3Dir(); err == nil {
draft = tui3.ChatDraftFile(dir, "", workspace, cfg.SessionFile)
}
}
// THE AGENT-BUILDING SEAM (chatv3_process.go's [v3Seam]). Every conversation
// after the first comes through here, carrying the closures that belong to
// ITS agent and ITS workspace — which is what stops an "always" answered
// after a /new from being pushed into the session that /new closed.
//
// It carries the boot launch AS IT ENDED UP rather than as it was assembled:
// [openV3Agent] may have moved the session file under us on a contended
// resume, and a conversation opened later is a sibling of the folder this
// window is actually in.
settled := *launch
settled.Config, settled.Place = cfg, cfg.Place
settled.SessionFile, settled.Resumed = transcript, resumed
seam := &v3Seam{proc: proc, boot: &settled, seed: seed}
// THIS IS A WINDOW ON THIS MACHINE, and its automations run only while one
// is open: it holds its presence for exactly as long as the surface runs,
// and keeps a clock going meanwhile (chatv3_clock.go). The two doors to
// another machine hold presence THERE instead, through the engine they
// attach to, because the automations such a window shows are that
// machine's.
defer keepAutomationsWindow("window")()
// The byte meter and the logger redirect both belong to the surface rather
// than to this door, and [runSurface] (chatv3_surface.go) is where every
// door gets them.
err = runSurface(ctx, tui3.Options{
Agent: agent,
Build: buildinfo.String(),
Seal: sealSeamOf(agent, cfg.Seals),
UnreadProfileKeys: append([]string(nil), proc.UnreadProfileKeys...),
// The memory place reads the conversation's store; a store that would not
// open supplies no seam.
Memory: v3MemorySeam(cfg.Memory),
SearchStatus: v3SearchStatus(cfg.SearchProvider, settings.ProfileDir),
// The machine-wide spending ledger the spend place adds up. It is the
// same file every window on this machine appends a model call to, named
// once by internal/session so a reader and a writer cannot spell it two
// ways (internal/session's UsageLedgerPath).
UsageLedger: session.UsageLedgerPath(),
// The sub-harness registry under the state root, which is where every
// window on this machine writes and reads them: /harness is a list of
// what is SAVED, so it has to be the same directory the builder saved
// into (internal/subharness's store.go).
Harnesses: harnesses,
// Asked at the moment the picker opens, never at boot: a catalog that
// resolved while the person was reading is a catalog the picker can
// use, and one that has not resolved answers nil instead of waiting.
// It reads the shelf, which ctrl+r in /model refills with today's list.
Models: proc.Shelf.pickerModels,
RequireListedModel: true,
RefreshModels: proc.refreshDefaultModels,
ModelsForService: proc.Shelf.modelsForService,
RefreshModelsForService: proc.Shelf.refreshService,
RefreshAllModels: proc.refreshAllModels,
WarmEmptyProviders: proc.warmEmptyProviders,
SubscribeServiceModels: proc.registerServiceNotice,
ProviderFetchError: proc.Shelf.fetchErrorFor,
Sources: settings.Sources,
// The same deliverables index the session's config carries, so the
// surface's /export rows and the session's own land in one file.
ArtifactsIndex: artifactsIndexPath(),
// The profile the settings panel reads and writes, which must be the
// SAME directory this launch read every governance row out of. It was
// missing here while the --host door supplied it (chatv3_host.go), and
// the gap was invisible in the ordinary case and silent in the one that
// mattered: with CODEAF_PROFILE_DIR set, the panel wrote into
// ~/.codeaf/config.json while the session went on reading the profile
// the variable named, so a gate turned off in the sheet stayed on and
// nothing on screen said why.
ProfileDir: settings.ProfileDir,
// The profile answers this question live on every ordinary launch, but
// --yolo is the one launch that opens the gate without writing that row,
// so its forced posture has to reach the surface by hand instead.
ApprovalMode: v3SurfacePosture(*yolo),
// AND WHETHER THOSE ROWS SEAT ANYTHING THIS RUN. `--one-model` empties the
// roles source and the task model above, so the crew in the profile is
// still on disk and still seats nothing — and a surface that did not know
// drew `crew custom` for the whole run and promised a work seat that
// picking a crew would move (#444). It is read off the session's own
// config and not off the flag pointer so the surface reports the bit the
// session is actually carrying, which is [applyV3Governance]'s answer and
// not the door's question.
OneModel: cfg.OneModel,
// The countdown reads the same frozen value the session clock armed. A
// profile edit changes both together on the next session rather than
// moving only the surface's number at a turn boundary.
BashBackgroundAfterSeconds: cfg.BashBackgroundAfterSeconds,
// /new and the picker, as the seam answers them. The two wrappers below
// are the OLDER shape of the same doors, kept because the surface still
// falls back to them and because the hosted door can only answer that
// shape (chatv3_host.go): there is one remote agent by construction, so
// there is nothing per-conversation to hand back.
Start: seam.start,
Open: seam.resume,
AnchorWorkspace: func(path string) (string, error) {
return seam.anchor(agent, path)
},
// Automations, read and changed off the same store the conversation
// proposes into (chatv3_clock.go).
Automations: v3AutomationsSeam(),
Notify: v3NotifyRoute(),
Fresh: func() (tui3.Agent, string, error) {
conv, err := seam.start("")
if err != nil {
return nil, "", err
}
return conv.Agent, conv.SessionFile, nil
},
// Home's `ask here` — the errand answered in the right pane, whose
// transcript lives under the errands root and never under v3/projects
// (chatv3_exchange.go, tui3's homeexchange.go).
Errand: v3Errand(cfg, workspace, settings.ProfileDir, *yolo),
ErrandsRoot: v3ErrandsRoot(),
// Answering another window's question from home: the answer is left in
// that session's own folder and it picks it up on its heartbeat
// (internal/session's answers.go, tui3's homeband_answer.go).
Answer: session.WriteAnswer,
// The conversations this directory has had, and the door back into one
// of them. They are the welcome box's right column and the /resume
// picker's rows; the walk happens on the keystroke that asks for it and
// never at boot.
RecentSessions: func() []tui3.Session { return v3RecentSessions(launch.Bucket) },
Resume: func(file string) (tui3.Agent, error) {
conv, err := seam.resume("", file)
if err != nil {
// Returned rather than wrapped in a surface that would carry a
// typed nil: a locked file's error names the file, and the
// surface prints exactly that.
return nil, err
}
return conv.Agent, nil
},
PickSession: pickSession,
// THE CONVERSATION THIS LAUNCH COULD NOT OPEN, when another window was
// holding its journal. Empty on every ordinary launch; a transcript path
// makes the surface come up on home with that row armed, so one enter
// moves it here (internal/tui3's takeover.go, and [v3TakeOverInstead]
// above for what this window is sitting in meanwhile).
TakeOver: takeOver,
// WHETHER HOME GREETS THIS LAUNCH. It is a person opening codeaf with no
// particular conversation in mind: no --session, no picker asked for,
// and — by the time this line runs — no --once, which returned above.
// The surface applies the rest of the law, including the one condition
// that is about the machine rather than the command line: a machine with
// nowhere else to go is not greeted (tui3's home.go [app.landHome]).
//
// The --host door does not set it at all: the projects under this
// process belong to the wrong machine, and home refuses over --host for
// exactly that reason.
Landing: strings.TrimSpace(*file) == "" && !pickSession,
// THE FIRST-RUN SETUP, on the same launch that may open keyless (see
// `setup` above). The surface applies the rest of its law — a profile
// with every fact answered, a marker saying it was shown, a resumed
// conversation — and this door only says whether anybody is here to
// answer.
Setup: setup,
// And the key arriving after the door: every conversation this process
// holds starts talking with it on its next request, and every one opened
// later is built with it (chatv3_process.go's [v3Process.setAPIKey]).
ApplyAPIKey: proc.setAPIKey,
ReadCredits: v3CreditReader(proc),
PaymentRefusals: v3PaymentRefusals(proc),
ImplicitTalk: strings.TrimSpace(*model) == "" && strings.TrimSpace(env.Get(config.ModelEnv)) == "" && config.ChatModelAt(settings.ProfileDir) == "",
ApplyModelSources: proc.setModelSources,
// With no endpoint named, OpenRouter is the model provider and a missing
// key has a direct browser door. A custom OpenAI-compatible endpoint gets
// no OpenRouter offer, and a non-interactive launch has nobody to finish
// one, so both honestly leave this seam absent.
ConnectOpenRouter: v3OpenRouterConnection(settings, interactive),
ConnectCodex: v3CodexConnection(interactive),
// The accounts panel, and the sign-in a pressed row starts. It is the
// SAME manager the belt reaches through (cfg.Connect), so an account
// connected on the panel is connected for the model in the same breath
// and neither side has to be told about the other.
Connections: v3Connections(cfg.Connect),
Workspace: workspace,
// Whether that workspace is the session's own work/ directory or a
// project this was opened inside of (Decision 26). The surface uses it
// for one thing: what to CALL the place, because an owned workspace's
// path is codeaf's bookkeeping rather than an answer to "where am I".
Owned: launch.Place.Owned,
SessionFile: transcript,
Resumed: resumed,
Restart: restart,
// AND THE LAUNCH FACTS A PERSON CAN ACT ON. The unattended boundary and
// a replaced codeaf both ride the session-moved line — one dim row at
// the top of the conversation — rather than growing surfaces of their
// own. An ordinary attended launch with the same file on disk is still
// shown nothing whatever.
Notice: v3Interrupted(cfg, joinV3Notices(entryNotice, notice, session.UnattendedNotice(cfg), buildinfo.StaleNotice())),
ContextWindow: cfg.ContextWindow,
History: recall,
DraftFile: draft,
// The consent card's "always", written down AND handed to the gate this
// session is running on (chatv3_approval.go). The second half is why a
// banked rule answers the very next call instead of the next launch.
// Where a model chosen in /model, in the picker or on the settings sheet
// is written down, so the next launch opens on it ([v3TalkModel] reads
// the same row back). It is the profile this launch resolved everything
// else out of, which is the point: the surface must not be able to write
// its choice into a file the door does not read.
SaveModel: func(model string) error {
return config.WriteChatModel(settings.ProfileDir, model)
},
SaveApproval: bankToolApproval(agent, workspace, settings.ProfileDir, *yolo),
SaveBashApproval: bankBashApproval(agent, workspace, settings.ProfileDir, *yolo),
ApplyApprovals: applyV3Approvals(agent, workspace, settings.ProfileDir, *yolo),
})
return finishChatRestart(err, restart, "")
}
// finishChatRestart runs only after the surface door has returned, so every
// connection, session, and terminal cleanup deferred by that door has finished.
func finishChatRestart(err error, restart *codeupdate.Plan, localWorkspace string) error {
if err != nil || restart == nil || restart.Path == "" {
return err
}
// A linked-local engine outlives its surface, so replacing the executable
// does not by itself put the new build behind the conversation. Ask it to
// retire only after the surface has detached; the host itself refuses while
// another view or any work is active, and a refusal never stops that work.
if localWorkspace != "" {
retireUpdateEngine(localWorkspace)
}
return restartUpdatedChat(*restart)
}
const updateEngineDetachGrace = time.Second
const updateEngineDetachPoll = 25 * time.Millisecond
var (
retireChatEngine = enginehost.Retire
restartUpdatedChat = codeupdate.Restart
updateRestartNow = time.Now
updateRestartPause = time.Sleep
)
// retireUpdateEngine gives the connection close immediately above long enough
// to reach the host before deciding that somebody else really is keeping it.
// It never uses the force door: another window or live work keeps the old host,
// while the ordinary one-window handoff reliably starts the replacement build.
func retireUpdateEngine(workspace string) {
deadline := updateRestartNow().Add(updateEngineDetachGrace)
for {
err := retireChatEngine(workspace, false)
if !errors.Is(err, enginehost.ErrHostBusy) || !updateRestartNow().Before(deadline) {
return
}
updateRestartPause(updateEngineDetachPoll)
}
}
// ── the shared assembly ─────────────────────────────────────────────────────
//
// A conversation is the same object whoever is looking at it. `codeaf chat`
// draws it on the terminal it was typed into; `codeaf engine` answers frames
// about it from the other end of an ssh pipe. NEITHER DOOR MAY ASSEMBLE ITS OWN
// AGENT: two assemblies would be two sets of governance rows, two model
// catalogs and two session-file rules, and the drift between them would show up
// as a feature that behaves one way at home and another way away — the single
// hardest class of bug to see, because both halves look right on their own.
// v3Options is what a door says about the conversation it wants opened. Every
// field is a flag some door carries; the zero value is what `codeaf` bare does.
type v3Options struct {
// Workspace is the directory the conversation runs in. Empty is the
// process's own, which is what a terminal door means and what the engine
// means once it has changed into the directory the surface asked for.
Workspace string
// Model beats the configured default for this session only.
Model string
// Session is an explicit transcript to open; empty resumes this
// directory's most recent, or makes one.
Session string
// NoCompact and Yolo are the two flags that change what a session may do.
NoCompact bool
Yolo bool
// Interactive is the door's own fact that A PERSON IS STEERING THIS
// CONVERSATION — a screen somebody is typing into, here or over a hosted
// dial. It is not --yolo: yolo is approvals and says nothing about who is
// watching. The terminal doors set it; --once and every headless caller
// leave it unset, keeping the unattended goal owner (internal/session's
// principal.go reads it beside the budget).
Interactive bool
// OneModel settles every text call this session makes onto the session
// model. It is a MEASUREMENT POSTURE rather than a preference: a run whose
// spend and quality are being attributed to one model cannot have a tier
// row quietly answering a quarter of its calls on another. It changes no
// setting and writes nothing — the rows are still there, and the next
// session without the flag reads them exactly as before.
OneModel bool
// Budget is the ceiling an unattended session carries its own work on
// under: hours, dollars, or both (internal/session's principal.go). THE
// ZERO BUDGET IS THE DEFAULT AND IS NOT A CEILING OF ZERO — it is the
// posture every session has always had, where the model stopping is the
// session stopping.
Budget session.Budget
}
// joinV3Notices puts the launch's dim lines on one row, in the order they were
// decided. Either being empty is the ordinary case and leaves the other alone —
// the emptiness law, applied to a separator.
func joinV3Notices(lines ...string) string {
var kept []string
for _, line := range lines {
if line = strings.TrimSpace(line); line != "" {
kept = append(kept, line)
}
}
return strings.Join(kept, " · ")
}
// chatBudget turns what somebody typed into the ceiling internal/session reads.
//
// A NEGATIVE FIGURE IS NO CEILING RATHER THAN AN ERROR, which is the same
// reading every other numeric row in this build makes of one (the repair-round
// resolver's, the rail's): a person who typed a minus sign has not asked for a
// session that stops before it starts.
func chatBudget(hours, cost float64) session.Budget {
var budget session.Budget
if hours > 0 {
budget.Wall = time.Duration(hours * float64(time.Hour))
}
if cost > 0 {
budget.USD = cost
}
return budget
}
// envFloat reads one number out of the environment, and answers zero for
// anything that is not one. It is the default a flag is declared with, so a
// harness that sets the wall once for a campaign does not have to spell it on
// every launch — and the command line still wins, because a flag's own value
// replaces its default.
func envFloat(name string) float64 {
value, err := strconv.ParseFloat(strings.TrimSpace(env.Value(name)), 64)
if err != nil {
return 0
}
return value
}
// v3Launch is that assembly, done. The pieces are handed back rather than kept
// because both doors keep using them after the agent exists: the catalog warms
// in the background and answers the model picker, the harness registry is the
// same store /harness lists, and the config is what a replacement agent — /new,
// the picker, Session.New — is built on.
type v3Launch struct {
Settings config.Config
Models *catalog.Catalog
Harnesses *subharness.Store
// Config is the session config with every governance row applied, and with
// AskConsent NOT yet set: whether anybody is there to answer a question is
// the door's own fact and the one thing this cannot know.
Config session.Config
// Model is the id the session starts on, after the flag and the settings
// have had their say.
Model string
// Workspace, SessionFile and Resumed are the three facts a surface prints
// before its first turn.
Workspace string
SessionFile string
Resumed bool
// Project is the DIRECTORY THIS CONVERSATION IS ABOUT, which is not always
// Workspace above: an OWNED conversation works in its own private work/
// folder under ~/.codeaf/v3/projects, and Workspace is that folder.
//
// It is carried because two things are keyed by the project and not by the
// tools root — the engine host that holds this workspace's conversations
// (internal/enginehost), and therefore the `codeaf engine --stop --workspace
// X` in [sessionHeldElsewhereSentence]. Handing that sentence Workspace
// spelled a command that pointed at a host which does not exist.
Project string
// Place is the session folder this launch opened (internal/session's
// place.go), and Bucket the project directory it sits in. The surface keeps
// both after the launch: the folder is what /new mints a sibling of, and the
// bucket is the list of this project's conversations.
Place session.Place
Bucket string
// Subharnesses is the assembly the four seams on Config were taken from
// (chatv3_subharness.go). It is carried out of the launch for ONE thing the
// config cannot hold: the belt watch, which is a question about an agent that
// does not exist until after this returns and is filled the moment it does
// (see [beltWatch], and the door it is pointed at, session.Agent.ToolOnBelt).
Subharnesses v3Subharness
}
func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) {
// The profile, the catalog, the harness registry and the stores were all
// resolved once, at the door (chatv3_process.go). Everything below this line
// is a function of the DIRECTORY this conversation is about, which is the
// whole of what makes a launch different from the process it runs in.
settings := proc.Settings
chosen := v3TalkModel(opts.Model, settings)
// WHERE THE PERSON IS STANDING, which is not the same fact as which project
// this is: `codeaf` typed in repo/cmd/ is a conversation about the
// repository, and the subdirectory is recorded rather than resolved away
// (Decision 26). A door that named a workspace has already answered the
// project question and its answer is taken as given.
launchDir := proc.LaunchDir
project, owned := v3Workspace(launchDir, opts.Workspace)
found, err := v3ResolveSession(strings.TrimSpace(opts.Session), project,
v3StampLaunchDir(launchDir, project), owned)
if err != nil {
return nil, err
}
transcript, resumed := found.Transcript, found.Resumed
// THE TOOLS ROOT IS THE SESSION'S OWN ANSWER. A borrowed session works in
// the project it borrowed; an owned one works in its own work/ directory,
// which is prepared here because nothing downstream may find it missing.
workspace := project
if root := strings.TrimSpace(found.Place.Workspace); root != "" {
workspace = root
}
if found.Place.Owned {
if err := prepareOwnedWorkspace(found.Place); err != nil {
return nil, err
}
}
// The model pool's judge is the launch's own: it reads every landing of THIS
// conversation, so it is built here and handed to the shared assembly.
// The Model Pool's judge reads every landing through the engine's one hook.
// It is built here, where the seats above are wired, so the answer to
// "may this process read the pool at all" is one posture for the whole
// door: a mode that forbids reading builds no hook, and a nil hook is the
// engine's own nothing. The ask is built once and a client is made from it
// per call, each billed to the judge's own seat.
var taskLanded func(session.TaskLanding)
// AN INDEPENDENT JUDGE CANNOT SHARE THE CREW MODEL. A one-model
// launch therefore leaves this optional scoring to an ordinary launch;
// it must neither judge new landings nor sweep earlier pending work.
if !opts.OneModel {
taskLanded = poolJudgeHook(settings, settings.ProfileDir, workspace,
config.CrewCatalog, poolJudgeAsk(proc.liveSettings(settings), settings.ProfileDir), time.Now, "task")
// The runs a live process would have judged but a process death left unjudged,
// and the headless doors that never had this hook: at start, on a goroutine
// nobody waits on, judge the resumed session's own final-state nodes and the
// pending file's rows, each exactly once, bounded so it never holds the prompt. The
// process tracker cancels and joins it at close.
poolErrandGoCtx(settings.ProfileDir, "pool/judge-sweep", func(ctx context.Context) {
poolJudgeSweepRun(ctx, settings, settings.ProfileDir, found.Place.Tasks(),
config.CrewCatalog, poolJudgeAsk(proc.liveSettings(settings), settings.ProfileDir), time.Now)
})
}
cfg, activeModels, subharnesses, err := v3ConfigFor(proc, opts, workspace, found.Place, transcript, taskLanded)
if err != nil {
return nil, err
}
harnesses := proc.Harnesses
// THE SHELF IS IMPORTED BEFORE THE FIRST MESSAGE. A person's skills for
// other harnesses — Claude Code, Codex, any agentskills.io reader — reach
// the shelf when the graph opens, not when some later tick finds the time:
// this door claims no residency (runChatV3's header), so the pass the
// resident reconciler runs on its own clock is run here, synchronously,
// after [v3Memory]'s graph is open and before the first prompt is built.
// The pass is idempotent — an unchanged disk journals nothing — so an open
// costs one scan and no writes, and a skill edited since the last open is
// re-read before the model ever sees the shelf.
importForeignSkillsBeforeFirstMessage(proc.skillShelf(), workspace)
return &v3Launch{
Settings: settings,
Models: activeModels,
Harnesses: harnesses,
Config: cfg,
Model: chosen,
Workspace: workspace,
Project: project,
SessionFile: transcript,
Resumed: resumed,
Place: found.Place,
Bucket: found.Bucket,
Subharnesses: subharnesses,
}, nil
}
// v3ConfigFor assembles the session configuration for work in workspace: the
// person's models, keys, governance, connected accounts, memory, media and
// harness seams, from what the process already resolved at the door.
//
// IT IS THE ONE ASSEMBLY, shared by a conversation's launch and an automation's
// run (chatv3_clock.go). It finds no conversation and starts nothing — which is
// why the launch keeps those parts and an automation can call this: the launch
// resumes the person's newest conversation in a project, and an automation
// that did that would be writing into somebody's chat.
func v3ConfigFor(proc *v3Process, opts v3Options, workspace string, place session.Place, transcript string, taskLanded func(session.TaskLanding)) (session.Config, *catalog.Catalog, v3Subharness, error) {
settings := proc.Settings
chosen := v3TalkModel(opts.Model, settings)
// The model catalog and the sub-harness registry, borrowed from the process
// rather than opened here (chatv3_process.go states why each must be one).
// The catalog's warm is already running and is waited for NOWHERE on this
// path: everything it feeds — the /model picker's list, the context window —
// has a good answer without it, and an unknown window leaves the session on
// its conservative default.
models := proc.Models
// A DIRECT SERVICE'S OWN CATALOG IS A WARM OF ITS OWN, and it is owned the
// way the process's is (#1274): it runs under the process lifetime and
// closeAll cancels and joins it, so no cache write can land after the
// process has closed or in a later home.
activeModels, activeModel, activeListsModels := v3CatalogForModel(proc.lifetime(), settings, chosen, models)
if activeModels != models {
proc.ownCatalog(activeModels)
}
harnesses := proc.Harnesses
// The typed programs this conversation can reach, and the two stores they are
// found in (chatv3_subharness.go). It is assembled BEFORE the config because
// all four seams below are fields of it, and the zero value is subharnesses
// off — so nothing here has to ask whether the wiring worked.