-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathsession.ts
More file actions
4541 lines (4300 loc) · 195 KB
/
Copy pathsession.ts
File metadata and controls
4541 lines (4300 loc) · 195 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
/**
* Session — wraps a single Claude Agent SDK query (one agent working in one repo).
*
* Protocol v2:
* - Every message carries identity (who produced it)
* - Streaming via SessionMessageDelta (token-by-token)
* - Tool calls are state machines (streaming → confirmation → executing → completed)
* - Thinking indicator as first-class message
* - Scrollback stores merged SessionMessage (not deltas)
*/
import {
CanonicalHistoryAccumulator,
type CanonicalTurn,
type HistorySeedResult,
} from "./providers/canonical.js";
import { targetContextWindow, seedBudgetChars } from "./providers/context-windows.js";
import { CONDUCTOR_SYSTEM_PROMPT_APPEND, isFleetSendTool } from "./fleet.js";
import type { McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk";
import {
type ProviderEvent,
type NormalizedTurnResult,
type TurnRun,
type ToolApprovalFn,
type SessionProvider,
type UiRequest,
type UiResponse,
isSubagentEvent,
type BackgroundTaskSnapshot,
type SessionScopedEvent,
} from "./providers/interface.js";
import { createDefaultProviderRegistry, type ProviderRegistry } from "./providers/registry.js";
import { selectContextStrategy, renderSessionMap, renderRotationSeed, type ContextStrategy } from "./providers/context-strategy.js";
import type { HookBus } from "./hooks/bus.js";
import type { HookSessionContext } from "./hooks/types.js";
import { randomUUID } from "node:crypto";
import type {
AuthContext,
CollaborationConfig,
SessionInfo,
SessionMode,
SessionStatus,
SessionUsage,
CollaborationCost,
TurnUsage,
DaemonMessage,
SessionMessage,
SessionMessageDelta,
SessionUiRequestMsg,
SessionUiResolvedMsg,
MessageIdentity,
ContentPart,
ProviderCommand,
ToolState,
SessionWorktree,
} from "../protocol/types.js";
import { removeForkWorktree } from "./git-worktree.js";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileP = promisify(execFile);
/** Max wall-clock for a fork.setup command (deps install can be slow). */
const FORK_SETUP_TIMEOUT_MS = 600_000;
import { authToIdentity, CAPABILITIES, isActiveStatus, SYSTEM_IDENTITY } from "../protocol/types.js";
import type { Store } from "./store.js";
import type { AgentIdentityManager } from "./agent-identity.js";
import { ScrollbackBuffer } from "./scrollback.js";
import { reconcileResumedMessage } from "./resume-reconcile.js";
import type { TranscriptStore } from "./transcript.js";
import { contextWindowForModel } from "./context-windows.js";
import {
EpisodeChunker,
IndexScheduler,
workspaceIdFromPath,
type MemoryEngine,
type MemoryMcpMount,
} from "./memory/index.js";
import { isElicitationTool, isSafeTool, roleDeniesTool } from "./providers/tool-safety.js";
import type { PackActivation } from "./pipeline/index.js";
import type { McpRegistry } from "./mcp/registry.js";
import type { McpHub } from "./mcp/hub.js";
import type { Attachment } from "../protocol/types.js";
import { resolveAttachments } from "./attachments.js";
import type { CodeoidConfig } from "../config.js";
import type { CompressionRegistry } from "./compress/index.js";
import {
CLAUDE_PROVIDER_ID,
findModel,
resolveModelId,
resolveModelIdForProvider,
} from "./models.js";
import {
callContextSize,
decideRotation,
type LLMCallUsage,
} from "./context-math.js";
/**
* System-prompt append used when memory is enabled. Deliberately brief and
* action-oriented — long preambles eat the cache hit. This string is stable
* per-workspace so it becomes part of the cached prompt prefix.
*/
const MEMORY_SYSTEM_PROMPT_APPEND = [
"You have access to durable cross-session memory for this workspace via four tools: recall, recall_file, timeline, and get_episode.",
"",
"- Before reading a file, call recall_file(path) — if it was read recently and hasn't changed, reuse that content instead of issuing a fresh Read.",
"- When the user references earlier work ('what we did yesterday', 'the bug we hit', 'that auth flow'), call recall(query) first. Don't guess from your own session history; it may be out of date.",
"- At the start of a new session in a known workspace, consider calling timeline() to orient yourself on recent activity.",
"- recall and timeline results each carry an episode_id; pass it to get_episode(episode_id) to fetch that turn's exact stored bytes verbatim, with nothing summarized or dropped.",
"",
"Memory stores every tool call and assistant reply across all past sessions in this directory verbatim. It is the source of truth for history — the transcript in your context may be partial or truncated, so when a detail matters, page it in with these tools rather than relying on what you can see.",
].join("\n");
/**
* Trailing-debounce window for persisting ACTIVE status flips (thinking ↔
* tool_running — several per tool call). Terminal states bypass it; this only
* bounds how stale a crashed daemon's view of an in-flight turn can be.
*/
const STATUS_PERSIST_DEBOUNCE_MS = 500;
/** Max wait for a sub-agent's ZeroID registration before attributing its tool
* call — a hung identity service must not stall the event loop; we fall back to
* the session identity (mis-attribution, logged at debug) rather than block. */
const SUBAGENT_REGISTRATION_FENCE_MS = 5_000;
/**
* Max serialized message payload per scrollback.replay frame (#84). Kept well
* under the server's 16 MB WS outbound backpressure limit (server.ts) so a
* single chunk — plus the frame envelope and any concurrent traffic — never
* trips closeOnBackpressureLimit. Scrollback whose total fits one chunk is
* still replayed as a single legacy frame; only larger sessions are chunked.
*/
const REPLAY_CHUNK_BYTES = 4 * 1024 * 1024;
/**
* Tail window replayed on attach for `scrollback.paging` clients — enough
* context to continue the conversation instantly; everything older is pulled
* on demand via `scrollback.page`. Legacy clients get the full buffer.
*/
const ATTACH_TAIL_BYTES = 512 * 1024;
/** Default / ceiling for one `scrollback.page` response. */
const PAGE_DEFAULT_BYTES = 256 * 1024;
const PAGE_MAX_BYTES = 2 * 1024 * 1024;
/** How much of the on-disk transcript (newest end) one page request may scan
* when the anchor is older than the in-memory buffer. Bounds the I/O of a
* single page against multi-GB transcript files. */
const PAGE_TRANSCRIPT_SCAN_BYTES = 64 * 1024 * 1024;
/** A connected client that can receive messages from this session. */
export interface AttachedClient {
id: string;
auth: AuthContext;
send(msg: DaemonMessage): void;
/**
* Optional backpressure signal: resolves once the client's outbound buffer
* has drained enough to accept more data. Used to pace a chunked scrollback
* replay (#84) so chunks don't accumulate past the WS backpressure limit and
* force-close the socket. Transports without backpressure awareness
* (in-memory test clients, Telegram) omit it — callers treat absence as
* "always ready" (`await client.flush?.()` is a no-op).
*/
flush?(): Promise<void>;
/**
* Capability ids the client declared on its auth frame. Capability-gated
* frames (`session.ui_request`) are only sent to clients that declared the
* matching capability. Absent = legacy client (no capabilities).
*/
capabilities?: readonly string[];
}
export interface SessionCreateOptions {
name: string;
workdir: string;
auth: AuthContext;
store: Store;
transcriptStore: TranscriptStore;
identityManager?: AgentIdentityManager;
existingId?: string;
/**
* Called once per session with the live model catalog the backend
* supports (e.g. the Claude Code SDK's `supportedModels()`), tagged with
* the reporting provider's id so the manager can cache catalogs
* per-provider — codeoid is provider-agnostic and each backend serves a
* different model list. The manager caches it daemon-wide so `/model`
* validation + the picker use the real list.
*/
onModels?: (
providerId: string,
models: ReadonlyArray<{ value: string; displayName: string; description?: string }>,
) => void;
/** Optional memory engine — when provided, episodes are chunked and stored for recall. */
memory?: MemoryEngine;
/** Shared in-daemon memory MCP endpoint + URL, for URL-mounting backends. */
memoryMcp?: MemoryMcpMount;
/** Cross-backend MCP registry + daemon-owned client pool — mounted on every backend. */
mcpRegistry?: McpRegistry;
mcpHub?: McpHub;
/**
* Full parsed config — carries compress / workspaceIndex / telemetry
* toggles. When absent, compression stays off (safe default).
*/
config?: CodeoidConfig;
/**
* Optional pre-built compression registry. If provided, PreToolUse hook
* rewrites Bash commands to route through the wrapper CLI when enabled.
*/
compressionRegistry?: CompressionRegistry;
/**
* Session role. "conductor" = the per-tenant fleet supervisor: it gets the
* conductor system prompt and the codeoid_fleet MCP server (when `fleet`
* is provided). "worker" = a disposable dispatch-spawned worker. Shown in
* SessionInfo.role for clients.
*/
role?: "conductor" | "worker";
/**
* Ambient pack activation (docs/pack-loading.md): the resolved pack whose
* constitution is injected into the system prompt, whose subagents are handed
* to the backend, and whose (optional) capability role gates this session's
* tools. Resolved by the SessionManager from `session.create.pack`.
*/
pack?: PackActivation;
/**
* Provider id backing this session ("claude" | "gemini" | "openai").
* Absent = claude. Every session carries its own selection so any session
* — the conductor included — can run on a different backend (e.g. an
* open-weight provider once one is registered).
*/
providerId?: string;
/**
* Fork lineage — set by SessionManager#fork. Recorded on the session,
* persisted in the transcript meta, and surfaced in SessionInfo.
*/
forkedFrom?: { sessionId: string; name: string; atTurn: number };
/**
* Git worktree backing this session's workdir (fork isolation / bind).
* Set by SessionManager#fork, persisted in meta, surfaced in SessionInfo.
*/
worktree?: SessionWorktree;
/**
* Collaboration this session orchestrates — goal + role→backend bindings,
* already validated and normalized by `validateCollaboration`. Persisted
* in meta and surfaced in SessionInfo, so it survives a daemon restart.
* Absent = a normal session.
*/
collaboration?: CollaborationConfig;
/**
* Set on a role-CHILD of a collaborative session. The mirror of
* `collaboration` (which is set on the orchestrating parent), so the
* manager can DERIVE a collaboration's membership from the live session set
* rather than keeping a side registry that could drift out of sync and
* orphan an agent subprocess.
*/
collaborationRole?: SessionInfo["collaborationRole"];
/**
* Role-scoped goal-blackboard mount for a collaboration child: the endpoint
* URL plus a bearer token that IS the scope (one goal, this role's read/write
* set). Handed to the provider like `memoryMcp`, so any backend able to mount
* an MCP URL gets it — which is the point of making the blackboard mountable
* rather than an in-process Claude-SDK server (#245).
*/
blackboardMcp?: { url: string; token: string };
/**
* Pre-built codeoid_fleet MCP server (conductor sessions only). Built by
* the SessionManager because its tools close over the manager's tenant-
* scoped session view; the Session just hands it to the provider.
*/
fleet?: McpSdkServerConfigWithInstance;
/**
* Model default that outranks config.session.defaultModel for THIS
* session (still loses to a persisted per-session choice). Used by the
* conductor's config.conductor.model override.
*/
defaultModel?: string;
/**
* Observe every status transition of this session. The dispatcher uses
* this to detect a worker's turn completing (→ idle/error) or wedging
* (→ waiting_approval) without polling. Called AFTER the transition is
* applied; exceptions are swallowed (observability must not break turns).
*/
onStatusChange?: (sessionId: string, status: SessionStatus) => void;
/**
* What this session's collaboration has spent so far, for the approval
* prompt on a send-class dispatch. Injected by the manager because a Session
* cannot see its siblings — the roll-up spans the orchestrator and every live
* role-child. Returns undefined when there is nothing to report.
*/
collaborationCost?: () => CollaborationCost | undefined;
/**
* Initial execution mode + autonomous tool budget. Spawned workers start
* "autonomous" with a bounded budget so they can work unattended; when the
* budget exhausts, the mode reverts to guarded and the session waits for
* approval — which the dispatcher detects as a wedge.
*/
initialMode?: { mode: SessionMode; maxTurns?: number };
/**
* Shape of a dispatch-spawned worker ("ship" | "scout"). Selects the
* shape-capped LEAF identity profile (registerWorker) instead of the
* standard session-agent registration: scouts hold no tools:write, and no
* worker ever holds session:* — a worker cannot see or direct the fleet.
*/
workerShape?: "ship" | "scout";
/**
* The daemon's provider registry. Built once at startup by the
* SessionManager and shared across sessions; when absent (unit tests
* constructing Session directly) a default registry is built on the fly.
*/
providers?: ProviderRegistry;
/**
* The daemon's hook bus (config-declared hooks dispatched at this
* session's seams — see hooks/bus.ts). Built once at startup and shared
* across sessions, conductor and workers included (tenant hooks apply
* uniformly). Absent = no hooks, zero overhead.
*/
hooks?: HookBus;
/**
* Provider override for testing. When present, replaces the registry
* lookup so integration tests run without the Claude Agent SDK subprocess.
* Name prefix signals this is test-only infrastructure — do not use in production.
*/
_testProvider?: SessionProvider;
}
export class Session {
readonly id: string;
/**
* User-visible session label. Mutable via `rename()` — callers must go
* through the setter so the SessionInfo broadcast fires and transcript
* audits record the change.
*/
name: string;
readonly workdir: string;
/** "conductor" = fleet supervisor; "worker" = dispatch-spawned; undefined = normal. */
readonly role?: "conductor" | "worker";
/** Active pack activation (constitution + capability role + subagents). Not
* readonly: a pipeline run swaps this between phases via applyPhaseActivation
* so one bound session runs each phase under its own role. */
#pack?: PackActivation;
/** Fork lineage (set from opts / restored from meta). */
readonly forkedFrom?: { sessionId: string; name: string; atTurn: number };
/** Git worktree backing workdir, when isolated (set from opts / meta). */
readonly worktree?: SessionWorktree;
/**
* Goal + role→backend bindings when this session was created with the
* Collaborative toggle (set from opts / restored from meta). Readonly: the
* bindings are fixed for the life of the goal (§2, per-goal child
* lifetime); changing backends mid-goal would orphan live children.
*/
readonly collaboration?: CollaborationConfig;
/** Which collaboration + role this session serves, when it is a child. */
readonly collaborationRole?: SessionInfo["collaborationRole"];
/** Role-scoped blackboard mount. NOT readonly: the orchestrator's own mount
* is scoped to a goal id that IS this session's id, so it can only be
* attached after construction (see attachBlackboard). */
#blackboardMcp?: { url: string; token: string };
readonly createdBy: string;
readonly createdAt: string;
/**
* Tenancy stamps captured at session creation (or restored from disk
* on resume). Persisted alongside the transcript meta on every
* `setStatus` so a daemon restart picks them back up — without this
* the fields drift to "" the moment the first status flip after
* resume happens, and any future multi-tenant scoping on the
* `Store.listSessions(accountId, projectId)` filter would
* silently drop everything that's been resumed.
*/
readonly accountId: string;
readonly projectId: string;
// Provider (re-)construction inputs — see switchProvider().
#providersRegistry?: ProviderRegistry;
#fleet?: McpSdkServerConfigWithInstance;
#compressionRegistry?: CompressionRegistry;
#onModels?: SessionCreateOptions["onModels"];
#hookBus?: HookBus;
#status: SessionStatus = "idle";
/** True from an interrupt() until the next turn STARTS. An interrupt leaves
* the session `idle` (indistinguishable from a normal turn rest), so a driver
* awaiting the turn (a pipeline phase) needs this to tell "the user stopped"
* from "the model finished" and NOT re-drive over a stop. */
#turnInterrupted = false;
/** Trailing-debounce timer coalescing persistence of ACTIVE status flips
* (thinking ↔ tool_running). See #setStatus. */
#statusPersistTimer: ReturnType<typeof setTimeout> | null = null;
/**
* Last time this session actually did something, as persisted in
* `TranscriptMeta.lastActivityAt`. Tracked (rather than stamped fresh at
* every meta write) because `resumeSortKey` orders the resumed session list
* by it — so a metadata-only write like `rename()` must reuse the current
* value instead of bumping it and silently reordering the user's list.
* Initialised to createdAt in the constructor.
*/
#lastActivityAt: string;
#clients = new Map<string, AttachedClient>();
#store: Store;
#transcriptStore: TranscriptStore;
#identityManager?: AgentIdentityManager;
#agentIdentity: MessageIdentity;
#scrollback = new ScrollbackBuffer();
/**
* Identity of this Session instance's replay buffer (`replay.resume`).
* A client cursor (`sinceSeq`) is only valid against the buffer that
* issued it; regenerating the key on every construction (incl. restart
* resume, where the buffer is rebuilt from the transcript with fresh
* seqs) forces stale cursors down the full-snapshot path.
*/
#resumeKey = randomUUID();
/**
* Recently-processed `session.send.clientMsgId`s (`send.idempotency`) —
* insertion-ordered for FIFO eviction. Bounds the window in which an
* ambiguous-delivery retry is recognized as a duplicate; 256 comfortably
* outlives any client resend queue while staying O(1) per send.
*/
#seenClientMsgIds = new Set<string>();
#provider!: SessionProvider;
#activeRun: TurnRun | null = null;
#eventConsumerTask: Promise<void> | null = null;
// Wall-clock ms of the most recent provider event for the active run. The
// stall watchdog in #consumeEvents and the liveness guard in #sendInner read
// this to detect a turn whose event stream has gone silent (hung tool / dead
// subprocess) so the session can self-recover instead of wedging forever.
#lastEventAt = 0;
#accumulator = new CanonicalHistoryAccumulator();
/** Pluggable seed policy for switch/fork. Default `transcript` (no change);
* `CODEOID_CONTEXT_STRATEGY=vws` opts into the compact session map. */
#contextStrategy: ContextStrategy = selectContextStrategy();
// Tracks the sender of the most recently started turn. The onRecoveryNeeded
// closure reads this instead of closing over the original send()'s sender,
// which may have been overwritten by a subsequent send() before recovery fires.
#currentSender: AuthContext | null = null;
#approvalIdToMessageId = new Map<string, string>();
// Pending ZeroID registration promises keyed by subagent id. tool_start
// awaits this fence before attributing identity so the real WIMSE URI is
// used even for a subagent's very first tool call.
#subagentRegistrations = new Map<string, Promise<void>>();
#seq = 0;
#memory?: MemoryEngine;
#memoryMcp?: MemoryMcpMount;
#mcpRegistry?: McpRegistry;
#mcpHub?: McpHub;
#chunker?: EpisodeChunker;
// Counts mid-turn messages in flight. When the SDK interrupts the current
// turn to process a pushMidTurn() injection, it emits a turn_done for the
// aborted partial turn BEFORE the continuation turn starts. This counter lets
// #consumeEvents absorb those intermediate turn_dones and keep looping instead
// of exiting the consumer and leaving the continuation turn without a reader.
#pendingMidTurnCount = 0;
#indexScheduler?: IndexScheduler;
/** The workspace memory index FROZEN into the system-prompt append for the
* current context. Snapshotted once (per context; refreshed on rotation), NOT
* refreshed per turn — the live index's relative times + counts fluctuate by a
* few bytes each turn, and since the SDK fixes the system prompt at query
* construction, that would force a query-loop rebuild every turn (which aborts
* the in-flight turn — the systemPromptAppend-oscillation bug). Orientation is
* enough in the prompt; live memory is via the recall/timeline tools. */
#frozenMemoryIndex: string | null = null;
#workspaceId: string;
#config?: CodeoidConfig;
// ── Model selection ───────────────────────────────────────────────────
// Both fields resolved to full Anthropic model ids (never aliases). Null
// means "use whatever the SDK / Claude Code picks as default" — we don't
// force a choice if neither session nor config specified one. Takes
// effect on the NEXT send() (current stream is torn down on change).
#model: string | null = null;
#fallbackModel: string | null = null;
// ── Auto-rotation (Layer D) ────────────────────────────────────────────
// Claude Code's backing session id — distinct from codeoid's public
// session.id so we can rotate the underlying context while keeping the
// user-visible identity stable. Initialized to this.id at construction;
// mutates when rotate() fires.
// When true, the next send() injects the task-anchor seed prefix so the
// fresh context knows it's a continuation and memory recall is the path
// to prior detail. Cleared after the first send post-rotation.
#justRotated = false;
// In-memory rotation counter (Store has the persistent one). Used for the
// "X total rotations" display without hitting SQLite on every broadcast.
#rotationCount = 0;
#lastRotatedAt: number | null = null;
/**
* Turns elapsed since the last rotation (or since session start
* if we've never rotated). The auto-rotate min-turns guard now
* uses THIS instead of `usage.numTurns` (which is cumulative
* across rotations and can't gate the post-rotation thrash).
*/
#turnsSinceLastRotation = 0;
// Last user turn BEFORE rotation — seeded into the new session's opening
// prompt so the agent knows what it was working on. Captured inside
// rotate() from the most recent user_turn episode.
#lastUserTurnBeforeRotate: string | null = null;
// Claude's context window. The current Opus and Sonnet families share 1M;
// we compute occupancy against this constant. Making it tunable per-session
// was considered overkill — users rarely run sub-1M models via codeoid.
static readonly CONTEXT_WINDOW = 1_000_000;
// Execution mode + turn budget (autonomous mode only).
// Default `guarded` (≈ Claude Code's default): read-only tools (Read/Grep/Glob
// + memory) auto-approve, while Write/Edit/Bash and other mutations prompt.
// `interactive` (prompt for everything, incl. reads) and `autonomous` (auto
// until budget) are opt-in via /mode.
#mode: SessionMode = "guarded";
#onStatusChange?: (sessionId: string, status: SessionStatus) => void;
#collaborationCost?: () => CollaborationCost | undefined;
#workerShape?: "ship" | "scout";
#turnsRemaining: number | undefined = undefined;
// Cumulative token + cost totals, aggregated from SDK `result` messages
// (one per turn). Broadcast via session.info_update so StatusBar-style
// UIs can render a running counter without polling. The authoritative
// store is the `turn_usage` SQLite table — #usage is a cached projection
// rebuilt from the DB on session load and kept fresh per-turn.
#usage: SessionUsage = {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
totalCostUsd: 0,
numTurns: 0,
durationMs: 0,
recentTurns: [],
peakInputTokens: 0,
};
/** Cap on how many recent turns we embed in SessionInfo broadcasts. */
static readonly RECENT_TURNS_KEEP = 20;
// Pinned files — prepended to every turn until unpinned. Kept both in
// memory (for hot reads) and in the Store (for restart persistence).
#pinnedFiles: string[] = [];
// Sub-agent tracking — identity-first attribution for delegated work.
// Populated by SubagentStart / SubagentStop hooks; consulted when building
// a tool_call SessionMessage so each tool call carries the identity of the
// agent that actually made it (parent session OR sub-agent worker).
/**
* Live background tasks, from the provider's session-scoped LEVEL event.
* REPLACE semantics on every event (the contract's rule, so a missed event
* cannot wedge a stale indicator), and cleared on provider teardown — the
* level is per-harness-process, so a rebuilt provider starts empty.
*/
#backgroundTasks = new Map<string, BackgroundTaskSnapshot>();
/**
* Settled-task digests waiting to be delivered as a wake turn. Queued here
* when they arrive mid-turn (or while a wake is already in flight) and
* drained in ONE batched injection at the next idle — burst-collapse, same
* rule as the dispatcher's <fleet_events>.
*/
#pendingBackgroundReports: Array<{ taskId: string; status: string; summary: string }> = [];
/** Task ids already queued or delivered — a settle must wake exactly once. */
#reportedBackgroundTasks = new Set<string>();
/** Re-entrancy guard: one wake injection at a time. */
#deliveringBackgroundReports = false;
#subagents = new Map<
string,
{
identity: MessageIdentity;
agentType: string;
spawnedAt: number;
active: boolean;
}
>();
// ── Per-turn usage accumulator (primary vs subagent split) ─────────────
// SDK's `result.usage` sums ALL API calls in a turn — including any
// subagents spawned via the Task tool. But subagents have their own
// context windows; summing them into "ctx" would defeat the whole
// point of delegating work to subagents (to keep the primary context
// clean). We stream `SDKAssistantMessage` events and split by
// `parent_tool_use_id`:
// - null → primary agent's LLM call (accumulate into `primary`)
// - non-null → subagent call (accumulate separately, for diagnostics)
//
// `primary.maxCallContext` is the CURRENT primary context size — the
// biggest single primary-agent API call seen this turn. That's the
// number we report as `ctx` + use for rotation decisions.
#primaryTurnCalls: LLMCallUsage[] = [];
#subagentTurnCalls: LLMCallUsage[] = [];
/**
* Running max of primaryCtx across all turns this session — the real
* "peak" indicator. Persisted implicitly via being recomputed from the
* Store's aggregated peak as a floor, then bumped as new turns come in.
*/
#primaryPeakContext = 0;
/** Running total of cache_read from primary calls only (for honest avg). */
#primaryCacheReadCumulative = 0;
// Active streaming message — accumulates deltas into a complete message for scrollback
#activeAssistantMsg: SessionMessage | null = null;
// Active thinking message — Claude's extended reasoning, streamed live so
// the user can see what the model is considering before it acts.
#activeThinkingMsg: SessionMessage | null = null;
// Which content block index the active thinking corresponds to (so we
// only finalize it on the matching content_block_stop).
#activeThinkingIndex: number | null = null;
// Pending tool approvals: approvalId → resolve({approved, updatedInput?})
// `updatedInput` is the form-data patch the client may attach (e.g.
// AskUserQuestion's `answers` map) — see SessionApproveMsg.
#pendingApprovals = new Map<
string,
(result: { approved: boolean; updatedInput?: Record<string, unknown> }) => void
>();
/**
* Decisions that arrived BEFORE canUseTool registered its resolver. The
* event consumer broadcasts the waiting_confirmation tool message (and the
* waiting_approval status) a beat before #waitForApproval runs, so a fast
* client — or an automation — can approve/deny inside that window; without
* this buffer the decision was silently dropped and the turn hung forever.
* Keyed by approvalId; consumed (or discarded) by #waitForApproval.
*/
#earlyApprovals = new Map<
string,
{ approved: boolean; updatedInput?: Record<string, unknown> }
>();
/**
* Pending provider-initiated dialogs (`session.ui_request`), keyed by
* requestId. Settled by the first client `session.ui_response`, by the
* request's own timeout, or by interrupt/destroy (as cancelled). Pending
* requests are re-sent to newly attaching capable clients so a dialog
* raised while nobody was watching still gets answered.
*/
#pendingUiRequests = new Map<
string,
{
msg: SessionUiRequestMsg;
resolve: (r: UiResponse) => void;
timer?: ReturnType<typeof setTimeout>;
}
>();
/**
* Per-approval patchable-keys whitelist declared by the provider on
* `tool_start` (form-style tools). Consumed by canUseTool's approval
* sanitizer; cleaned up on resolution or interrupt.
*/
#approvalPatchKeys = new Map<string, string[]>();
// Active tool call messageIds — completed when next assistant message arrives
#activeToolMsgIds: string[] = [];
// SDK tool_use_id → our internal messageId — lets us correlate tool_result
// blocks (emitted in SDKUserMessage) back to the originating tool_call message
// so we can record the real tool output in scrollback, transcript, and memory.
#toolUseIdToMessageId = new Map<string, string>();
// Reverse of #toolUseIdToMessageId — needed so _applyInterruptedStateToTool
// and the denial path can clean up both maps without a full scan.
#messageIdToToolUseId = new Map<string, string>();
// messageIds of tool_calls already closed via a tool_result — so the
// fallback #completeActiveTools() path doesn't clobber their output.
#toolCallsClosedByResult = new Set<string>();
// messageId → canonical tool_call message, kept around so the completion
// update preserves the original tool input.
#toolCallMessages = new Map<string, SessionMessage>();
// Live MCP state captured from the SDK's `system/init` events. The SDK
// emits one init per query, so these are refreshed on every send(). Keyed
// by the SDK-reported server name (which matches what we read from
// ~/.claude.json). Empty until the first turn starts.
#sdkMcpStatus = new Map<string, string>();
#sdkMcpTools = new Map<string, string[]>();
constructor(opts: SessionCreateOptions) {
this.id = opts.existingId ?? randomUUID();
this.name = opts.name;
this.workdir = opts.workdir;
this.role = opts.role;
this.#pack = opts.pack;
this.forkedFrom = opts.forkedFrom;
this.worktree = opts.worktree;
this.collaboration = opts.collaboration;
this.collaborationRole = opts.collaborationRole;
this.#blackboardMcp = opts.blackboardMcp;
this.#onStatusChange = opts.onStatusChange;
this.#workerShape = opts.workerShape;
this.#collaborationCost = opts.collaborationCost;
if (opts.initialMode) {
this.#mode = opts.initialMode.mode;
this.#turnsRemaining =
opts.initialMode.mode === "autonomous" ? opts.initialMode.maxTurns : undefined;
}
this.createdBy = opts.auth.sub;
this.createdAt = new Date().toISOString();
this.#lastActivityAt = this.createdAt;
this.accountId = opts.auth.accountId;
this.projectId = opts.auth.projectId;
this.#store = opts.store;
this.#transcriptStore = opts.transcriptStore;
this.#identityManager = opts.identityManager;
this.#memory = opts.memory;
this.#memoryMcp = opts.memoryMcp;
this.#mcpRegistry = opts.mcpRegistry;
this.#mcpHub = opts.mcpHub;
this.#config = opts.config;
// Retained for provider (re-)construction — switchProvider() rebuilds
// the backend long after the constructor options are gone.
this.#providersRegistry = opts.providers;
this.#fleet = opts.fleet;
this.#compressionRegistry = opts.compressionRegistry;
this.#onModels = opts.onModels;
this.#hookBus = opts.hooks;
// Tenant-scoped (auth carries account_id/project_id) so two accounts in
// the same directory never share memory.
this.#workspaceId = workspaceIdFromPath(opts.workdir, opts.auth);
// Rotation counters — populated from Store so they survive restart.
const stats = this.#store.getRotationStats(this.id);
this.#rotationCount = stats.count;
this.#lastRotatedAt = stats.lastRotatedAt;
// Model selection — prefer persisted session choice, then a per-session
// default (conductor's config.conductor.model, or a dispatch task's
// per-child model), then the config default, else leave null (provider
// default). Always resolve to full id so downstream code doesn't see
// aliases. Resolution is provider-aware: `config.session.defaultModel`
// is a global, so on a non-Claude backend a Claude alias like "opus"
// must NOT expand to claude-opus-* and get handed to that backend — it
// resolves to null and the provider picks its own default instead.
const persistedModel = this.#store.getSessionModel(this.id);
// Resolve against the provider the session will ACTUALLY be built from —
// an explicit choice, else the registry's default. Using `opts.providerId`
// directly would silently assume claude whenever the caller didn't
// specify, which happens to be right only because the built-in registry's
// default is claude. Don't bake that coincidence in.
const effectiveProviderId =
opts.providerId ?? this.#providersRegistry?.defaultId ?? CLAUDE_PROVIDER_ID;
this.#model =
persistedModel.model ??
resolveModelIdForProvider(
opts.defaultModel ?? opts.config?.session.defaultModel ?? "",
effectiveProviderId,
) ??
null;
this.#fallbackModel =
persistedModel.fallbackModel ??
resolveModelIdForProvider(
opts.config?.session.fallbackModel ?? "",
effectiveProviderId,
) ??
null;
this.#provider = opts._testProvider ?? this.#createProvider(opts.providerId);
this.#provider.onSessionEvent = (e) => this.#onSessionScopedEvent(e);
// Restore any pinned files the user had on this session before.
try {
this.#pinnedFiles = this.#store.listPins(this.id);
} catch {
this.#pinnedFiles = [];
}
// Default agent identity — upgraded to ZeroID identity in SessionStart hook if manager is available
this.#agentIdentity = {
sub: `agent:${this.id}`,
name: `${opts.name} (Claude)`,
type: "agent",
};
// Restore cumulative usage from SQLite so the StatusBar reflects any
// prior turns after a daemon restart. No-op on first-ever session start.
if (opts.memory) {
try {
this.#refreshUsageFromStore();
} catch (err) {
console.error(
`[codeoid/usage] restore failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
if (this.#memory) {
const memory = this.#memory;
this.#indexScheduler = new IndexScheduler({
store: memory.store,
workspaceId: this.#workspaceId,
currentSessionId: this.id,
workdir: opts.workdir,
});
const scheduler = this.#indexScheduler;
this.#chunker = new EpisodeChunker(
{
workspaceId: this.#workspaceId,
sessionId: this.id,
createdBy: opts.auth.sub,
},
(episode) => {
try {
memory.ingest(episode);
scheduler.onEpisode();
} catch (err) {
console.error(
`[codeoid/memory] ingest failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
},
);
}
if (!opts.existingId) {
this.#store.createSession({
...this.toInfo(),
accountId: opts.auth.accountId,
projectId: opts.auth.projectId,
});
this.#store.audit(opts.auth.sub, "session.create", this.id, `name=${this.name}`);
this.#transcriptStore.saveMeta({
sessionId: this.id,
sessionName: this.name,
workdir: this.workdir,
createdBy: this.createdBy,
createdAt: this.createdAt,
lastStatus: "idle",
lastActivityAt: this.createdAt,
accountId: opts.auth.accountId,
projectId: opts.auth.projectId,
role: this.role,
providerId: this.#provider.id,
forkedFrom: this.forkedFrom,
worktree: this.worktree,
collaboration: this.collaboration,
collaborationRole: this.collaborationRole,
// Fire-and-forget: saveMeta's write chain owns the failure log; an
// unconsumed rejection here would be an unhandled-rejection crash.
}).catch(() => {});
}
// Hook seam: session lifecycle. `resume` = rebuilt from persisted state
// (daemon restart) — hooks filter on `source` so they don't fire
// en-masse at boot. Fire-and-forget by contract.
this.#hookBus?.emit("session_start", this.#hookContext(), {
source: opts.existingId ? "resume" : "new",
});
}
/** Session identity stamped on every hook payload. */
#hookContext(): HookSessionContext {
return {
sessionId: this.id,
sessionName: this.name,
workdir: this.workdir,
providerId: this.#provider.id,
};
}
/**
* Construct the backing provider for this session from `opts.providerId`
* via the daemon's ProviderRegistry. Every session carries its own
* selection (the conductor takes its from config.conductor.provider), so
* any session can run on a different backend. Unknown ids warn and fall
* back to the registry default rather than throw — resume must survive a
* meta written by a newer codeoid.
*/
#createProvider(requestedProviderId: string | undefined): SessionProvider {
const registry =
this.#providersRegistry ?? createDefaultProviderRegistry(this.#config);
const factory = registry.resolve(requestedProviderId, `session ${this.id}`);
return factory.create({
sessionId: this.id,
// Pass the tenant-scoped workspace id in rather than have the provider
// re-derive it (which would drop the tenant and desync the memory MCP
// binding from where episodes are actually stored).
workspaceId: this.#workspaceId,
model: this.#model,
initialBackingId: this.#store.getClaudeCodeSessionId(this.id) ?? this.id,
store: this.#store,
identityManager: this.#identityManager,
memory: this.#memory,
memoryMcp: this.#memoryMcp,
// A getter, so a mount attached after construction still reaches the
// provider when it next builds its server list.
blackboardMcp: () => this.#blackboardMcp,
mcpRegistry: this.#mcpRegistry,
mcpHub: this.#mcpHub,
fleet: this.#fleet,
config: this.#config,
compressionRegistry: this.#compressionRegistry,
// Tag model reports with the factory's id (known before construction),
// so the manager caches catalogs per-provider.
onModels: (m) => this.#onModels?.(factory.id, m),
});
}
/**
* Switch this session's backend mid-session (`session.set_provider`).
* The session id, scrollback, transcript, and identity stay; the backing
* agent is replaced and the canonical history is offered to the incoming
* provider (`seedFromHistory`, best-effort). Serialized on the send chain
* so a racing prompt can't land between teardown and rebuild.
*
* Fail-closed on unknown ids; rejected while a turn (or any pending
* approval/dialog) is in flight — interrupt first, then switch.
*/
async switchProvider(
requested: string,
sender: AuthContext,
): Promise<{ ok: true; providerId: string } | { ok: false; code: "invalid_request"; error: string }> {
const registry =
this.#providersRegistry ?? createDefaultProviderRegistry(this.#config);
if (!registry.has(requested)) {
const hint = registry.unavailableHint(requested);
return {
ok: false,
code: "invalid_request",
error: hint
? `Provider "${requested}" is supported but not available: ${hint}`
: `Unknown provider "${requested}" — available: ${registry.ids().join(", ")}`,
};
}
if (this.#provider.id === requested) {
return { ok: true, providerId: requested };
}
// Fast-path rejection for callers switching a visibly busy session.
// NOT sufficient on its own: a send() already queued on the chain can
// start a turn between this check and our chain slot — the guard is
// re-run inside #switchProviderInner where it's authoritative.
const busy = this.#switchBusyReason();
if (busy) return busy;
// Serialize with send(): a prompt already queued on the chain completes
// its dispatch against the OLD provider before we run; prompts arriving
// after us run against the NEW one.
let result!: Awaited<ReturnType<Session["switchProvider"]>>;
this.#sendChain = this.#sendChain
.catch(() => {})
.then(async () => {
result = await this.#switchProviderInner(requested, sender);
});
await this.#sendChain;
return result;
}
/** Non-null when the session cannot be switched right now (mid-turn). */
#switchBusyReason(): { ok: false; code: "invalid_request"; error: string } | null {
if (
isActiveStatus(this.#status) ||
this.#status === "waiting_approval" ||
this.#pendingApprovals.size > 0 ||
this.#pendingUiRequests.size > 0
) {
return {
ok: false,
code: "invalid_request",
error: "Session is mid-turn — interrupt it, then switch providers",
};
}
return null;
}
async #switchProviderInner(
requested: string,
sender: AuthContext,
): Promise<{ ok: true; providerId: string } | { ok: false; code: "invalid_request"; error: string }> {
// Authoritative mid-turn guard: #sendInner (queued ahead of us on the
// chain) starts the turn consumer and RETURNS while the turn is still
// streaming — the pre-check in switchProvider() can't see that turn.
// Rejecting here means we never tear down an actively-running provider.
const busy = this.#switchBusyReason();
if (busy) return busy;
const previous = this.#provider.id;
await this.#teardownProvider();
// Fresh backing id BEFORE building the new provider — the incoming
// backend must never try to resume the outgoing one's native state
// (a claude session id means nothing to pi and vice versa).
const newBackingId = randomUUID();
try {
this.#store.setClaudeCodeSessionId(this.id, newBackingId);
} catch (err) {
console.error(
`[codeoid/session ${this.id}] failed to persist switch backing id: ${err instanceof Error ? err.message : String(err)}`,
);
}
// Model ids are provider-specific ("opus" means nothing to pi's
// catalog) — reset to the incoming provider's default.
this.#model = null;
this.#fallbackModel = null;
// A pending rotation seed is Claude-worded and now redundant — the
// switch seeds its own transcript. Without this, the next send() would
// stack the rotation anchor on top of seedFromHistory's block.
this.#justRotated = false;
try {
this.#store.setSessionModel(this.id, null, null);
} catch {
// Non-fatal: the in-memory reset governs this lifetime.
}
this.#provider = this.#createProvider(requested);
this.#provider.onSessionEvent = (e) => this.#onSessionScopedEvent(e);
// Offer the canonical history to the incoming provider. Best-effort by
// contract: a seed failure degrades to an unseeded switch, never a
// wedged session.
const seeded = await this.#seedProviderFromHistory();