-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathtypes.ts
More file actions
2490 lines (2335 loc) · 90.1 KB
/
Copy pathtypes.ts
File metadata and controls
2490 lines (2335 loc) · 90.1 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
/**
* Codeoid Server Protocol v2
*
* Design principles:
* 1. Every message is self-contained, serializable JSON — no observables, no callbacks
* 2. Every message carries identity (who produced it) — auditable top to bottom
* 3. Discriminated unions with `kind` fields — frontends switch on kind, ignore unknown
* 4. Simple frontends (Telegram) use role + content string, rich frontends use parts[]
* 5. Tool calls are state machines — streaming → confirmation → executing → completed
* 6. Streaming via delta messages — reference a messageId, append content
* 7. Extensible — new roles, content parts, tool states added without breaking existing frontends
*
* Inspired by VS Code's IChatProgress union and tool invocation state machine.
* Adapted for network transport (JSON over WebSocket) and multi-frontend/multi-user.
*/
import type { Scope } from "./scopes.js";
import type {
SettingsSchemaMsg,
SettingsGetMsg,
SettingsSetMsg,
SettingsSchemaResultMsg,
SettingsGetResultMsg,
SettingsSetResultMsg,
} from "./settings.js";
/**
* Wire-protocol version. Bump on breaking changes (renamed/removed fields,
* renamed message kinds, altered semantics). Additive changes (new optional
* fields, new message kinds) do NOT require a bump — the "ignore unknown"
* discipline covers those.
*
* Native clients (e.g. the Rust Ratatui frontend) compare this against their
* own compiled-in version on `auth.ok` and warn the user if they've drifted.
*/
export const PROTOCOL_VERSION = 1;
/**
* Capability identifiers exchanged during the auth handshake (see `AuthMsg` /
* `AuthOkMsg`). Capabilities are the additive-evolution mechanism for
* behaviour (as opposed to message shape, which the "ignore unknown" rule
* covers): a client declares what it can consume, the daemon declares what it
* can produce, and either side simply doesn't use what the other didn't
* declare. Unknown capability strings MUST be ignored, never rejected.
*/
export const CAPABILITIES = {
/** Client renders rich `parts[]` content (vs the plain `content` fallback). */
PARTS: "parts",
/** Chunked scrollback replay (`scrollback.replay` with `seq`/`final`). */
CHUNKED_REPLAY: "replay.chunked",
/** Sequence-based incremental resume on `session.attach`. */
SEQ_RESUME: "replay.resume",
/** Duplicate-send suppression via `session.send.clientMsgId`. */
SEND_IDEMPOTENCY: "send.idempotency",
/**
* Provider-initiated dialogs (`session.ui_request` / `session.ui_response`).
* Declared by clients that can render the request methods; the daemon only
* targets `ui_request` frames at connections that declared it.
*/
UI_DIALOGS: "ui.dialogs",
/**
* Session-scoped provider command discovery (`session.commands`). Declared
* by the daemon; clients feature-detect before fetching.
*/
DYNAMIC_COMMANDS: "commands.dynamic",
/**
* Tail-first attach + on-demand history paging. Clients that declare this
* receive only the newest scrollback window on attach (`scrollback.replay`
* with `tail: true` + `hasMore`) and backfill older history on demand via
* `scrollback.page`. Clients that don't declare it keep the legacy
* full-buffer replay.
*/
SCROLLBACK_PAGING: "scrollback.paging",
/**
* Push notifications. Declared by the DAEMON when a push transport is
* configured (so clients can feature-detect before registering) and by
* CLIENTS that can receive them. A client registers a device token via
* `push.register`; the daemon then sends content-blind wake-ups (only opaque
* ids, never tool args) when one of that user's sessions blocks on approval.
*/
PUSH: "push",
/**
* Goal-blackboard inspection (`blackboard.index` / `blackboard.read`).
* Declared by the daemon; clients feature-detect before offering an artifact
* panel, so an older daemon simply doesn't grow the affordance rather than
* showing one that errors on click.
*/
BLACKBOARD: "blackboard",
/**
* Live collaboration panel state (`collaboration.panels`). Declared by the
* daemon so a client only offers panel UI when the data exists behind it.
*/
PANELS: "collaboration.panels",
/**
* Push via NATIVE device tokens (APNs/FCM) rather than Expo. Advertised by
* the daemon when the `native` or `relay` transport is configured; a client
* seeing this registers its native `getDevicePushTokenAsync` token (vs the
* Expo token it sends for `PUSH`).
*/
PUSH_NATIVE: "push.native",
} as const;
export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES];
/**
* Wire-level input limits, enforced by the daemon on inbound messages and
* published here so clients can pre-validate instead of learning limits from
* `invalid_request` errors. All limits are counted in UTF-16 code units
* (JS `string.length`) unless stated otherwise.
*
* `SEND_TEXT_MAX` exists as a token-bill safety net: prompt text goes
* straight into the model's context, so an accidental multi-megabyte paste
* would burn real money in a single turn. Large inputs belong in
* `attachments` (bounded per-file, surfaced to the model as files it can
* read selectively).
*/
export const LIMITS = {
/** Max `session.send.text` length. ~1M chars ≈ hundreds of thousands of tokens. */
SEND_TEXT_MAX: 1_000_000,
/** Max session name length (`session.create` / `session.rename`). */
NAME_MAX: 256,
/** Max filesystem path length accepted anywhere a path is sent. */
PATH_MAX: 4096,
/** Max `session.search.query` length. */
QUERY_MAX: 1024,
/** Max number of attachments on a single `session.send`. */
ATTACHMENTS_MAX: 32,
/** Max inline `Attachment.content` length. */
ATTACHMENT_CONTENT_MAX: 2_000_000,
/** Max `Attachment.data` (base64) length — bounded by the 16 MiB WS frame cap. */
ATTACHMENT_DATA_MAX: 12_000_000,
/** Max correlation / approval / client-generated id length. */
ID_MAX: 128,
/** Max model id / alias length (`session.set_model`). */
MODEL_MAX: 256,
/**
* Max length of a single `settings.set` value (a string field, one array
* element, or a secret). Generous — API keys, shell commands, and paths all
* fit comfortably under 8 KiB.
*/
SETTING_VALUE_MAX: 8192,
/** Max free-text length on a `session.ui_response` (`value`). */
UI_TEXT_MAX: 65_536,
/** Max number of options on a `session.ui_request` select. */
UI_OPTIONS_MAX: 64,
/** Max `CollaborationConfig.goal` length. A goal is a brief, not a spec. */
COLLABORATION_GOAL_MAX: 8192,
/** Max distinct roles in one collaboration. */
COLLABORATION_ROLES_MAX: 16,
/**
* Max children a single role may fan out to (`CollaborationRole.count`).
* A schema-level backstop only — the live-worker cap (P3) is what actually
* governs concurrency at run time.
*/
COLLABORATION_ROLE_COUNT_MAX: 8,
/** Max device push-token length (`push.register`). Expo tokens are ~40 chars. */
PUSH_TOKEN_MAX: 512,
} as const;
// =============================================================================
// Session metadata
// =============================================================================
// Active-turn status is split into two sub-states so clients can show what
// the agent is actually doing: `thinking` (reasoning / generating text) vs
// `tool_running` (a tool is executing — clients surface the tool name).
export type SessionStatus =
| "idle"
| "thinking"
| "tool_running"
| "waiting_approval"
| "error";
/** True when the session is mid-turn (either reasoning or running a tool). */
export function isActiveStatus(s: SessionStatus): boolean {
return s === "thinking" || s === "tool_running";
}
/**
* Execution mode — controls tool approval and autonomous budgeting.
*
* - `guarded` (default): Read/Grep/Glob/memory are auto-approved; Write/Edit/Bash/Agent
* still ask. The name says it plainly — it AUTO-runs the safe reads but GUARDS the
* mutations. (≈ Claude Code's default mode.) Formerly named `auto-allow`.
* - `interactive`: every tool call asks for approval, including reads.
* - `autonomous`: every tool auto-approved until the turn budget (`maxTurns`) is exhausted;
* session then reverts to `guarded`. (≈ Claude Code's bypass-permissions mode.)
*/
export type SessionMode = "interactive" | "guarded" | "autonomous";
export interface SessionInfo {
id: string;
name: string;
workdir: string;
status: SessionStatus;
createdBy: string;
createdAt: string;
attachedClients: number;
/**
* Session role. "conductor" marks the per-tenant conductor session (the
* fleet supervisor — one per account/project); "worker" marks a disposable
* dispatch-spawned worker. Absent = normal session.
*/
role?: "conductor" | "worker";
/** Id of the provider backing this session (e.g. "claude", "gemini"). */
providerId?: string;
/** Current execution mode (default "interactive"). */
mode?: SessionMode;
/** Active SDLC pipeline phase id when this session backs a phase. Absent = not a pipeline session. */
phase?: string;
/** Methodology pack/profile driving this session's phase. */
profile?: string;
/** Remaining turns budget for autonomous mode (undefined = unbounded, 0 = exhausted). */
turnsRemaining?: number;
/** Files pinned to the session — prepended to every turn's prompt. */
pinnedFiles?: string[];
/** SPIFFE/WIMSE URI of the primary session agent (falls back to anonymous:session:<id>). */
agentUri?: string;
/** Active sub-agents for the identity chain display. */
subagents?: Subagent[];
/**
* Background tasks the session's harness is running OUTSIDE any turn —
* work the model deferred past its own turn ("I'll report when the agents
* land"). Provider-agnostic: `kind` is the harness's own vocabulary
* ("shell", "subagent", …) and is display-only. Absent/empty = none, which
* is also what daemons that predate this field report.
*/
backgroundTasks?: Array<{
id: string;
kind: string;
description: string;
status: string;
}>;
/** Cumulative token + cost usage since the session started. */
usage?: SessionUsage;
/**
* Rotation telemetry — how many times the underlying Claude Code session
* has been rolled over to avoid context compaction. Only populated when
* auto-rotation is active or the user has manually rotated.
*/
rotation?: {
count: number;
/** Unix ms of last rotation, or null if never rotated. */
lastRotatedAt: number | null;
/** Backing Claude Code session id (opaque to UI, useful for debugging). */
claudeCodeSessionId?: string;
};
/**
* Number of user messages buffered in the streamInput queue, waiting for
* the SDK consumer to pick them up. > 0 means: user has sent faster than
* Claude can process — useful signal for mid-turn queueing UX.
*/
queuedMessages?: number;
/**
* Resolved full model id currently in use for this session. When unset,
* the SDK / Claude Code default applies. Frontends typically display
* the matching alias + label from the model catalog.
*/
model?: string;
/** Fallback model id used on 429/529 capacity errors. */
fallbackModel?: string;
/**
* Lineage for a session created via `session.fork`. Absent = not a fork.
* Frontends surface it as a chip ("⑃ forked from <name> · turn <atTurn>")
* that links back to the parent. Recorded at fork time and persisted, so
* it survives restarts (and a later parent rename/deletion — `name` is a
* snapshot).
*/
forkedFrom?: {
/** Parent session id — focus it when the chip is clicked. */
sessionId: string;
/** Parent's name at fork time (snapshot; parent may rename/vanish). */
name: string;
/** Conversation rounds (user turns) carried over from the parent — the
* point the branch was taken. */
atTurn: number;
};
/**
* Git worktree this session's workdir is (when isolated). Present when the
* session runs in a dedicated worktree — a fork isolated from its parent, or
* a session bound to an existing worktree. Absent = the session shares its
* workdir with no git isolation.
*/
worktree?: SessionWorktree;
/**
* Collaboration this session orchestrates, when it was created with the
* Collaborative toggle. Absent = a normal session. Persisted, so it
* survives a daemon restart the way `role`/`providerId` already do.
*/
collaboration?: CollaborationConfig;
/**
* Set on a role-CHILD of a collaborative session: which collaboration it
* belongs to and which role it plays. Absent = not a collaboration child.
*
* The mirror of `collaboration` (set on the parent), so a client can group
* a fleet without inferring it from names. `ordinal` distinguishes the
* members of a fanned-out role (`review` ×3 → ordinals 1..3).
*/
collaborationRole?: {
/** Session id of the orchestrating parent. */
parentSessionId: string;
/** Role name from the parent's config (already lowercased). */
roleName: string;
/** 1-based index within this role's fan-out. */
ordinal: number;
/** Whether this child's identity carries write authority. */
write: boolean;
};
}
/** A git worktree backing a session's workdir (see SessionInfo.worktree). */
export interface SessionWorktree {
/** Absolute path of the worktree directory (the session's workdir). */
path: string;
/** Branch checked out in the worktree (e.g. "codeoid/fix-login-a1b2c3d4"). */
branch: string;
/**
* True when codeoid created this worktree (fork isolation) and therefore
* owns its cleanup on destroy. False when the session was bound to a
* worktree the user already had — codeoid never removes those.
*/
createdByCodeoid: boolean;
}
/**
* Cumulative usage totals for a session. Aggregated from each SDK `result`
* message (one per turn). Frontends render this as a "$X · Yk in / Zk out"
* counter so the user sees what they're spending in near-realtime.
*
* Persistent: the daemon records one `TurnUsage` row per turn to SQLite so
* totals survive daemon restarts and can be queried after the fact.
*/
export interface SessionUsage {
/** Input tokens consumed across all turns. */
inputTokens: number;
/** Output tokens generated across all turns. */
outputTokens: number;
/** Tokens read from the prompt cache (cheap). */
cacheReadTokens: number;
/** Tokens written to the prompt cache (a premium on cache-misses). */
cacheCreationTokens: number;
/** Total cost in USD across all turns, as reported by the SDK. */
totalCostUsd: number;
/** Number of turns (round-trips) included in these totals. */
numTurns: number;
/** Wall-clock duration of agent work (sum of per-turn `duration_ms`). */
durationMs: number;
/** Most recent turns (newest first) — lightweight trend signal for UIs. */
recentTurns?: TurnUsage[];
/**
* Max PRIMARY-AGENT context size ever seen on a single turn — bloat canary.
* Computed as max(input + cache_read + cache_creation) across the primary
* agent's per-call usages within each turn (subagent calls excluded). This
* is the size the model actually processed; NOT a cumulative or billable
* figure. Capped at the model's context window on historical fallback.
*/
peakInputTokens?: number;
/**
* Most recent turn's PRIMARY-AGENT context size.
* = input + cache_read + cache_creation on the biggest primary call of the
* turn (subagents excluded). Matches Claude Code's canonical ctx-occupancy
* formula (`calculateContextPercentages`). Used as the numerator for the
* StatusBar's ctx%/window display — NOT billable input.
*/
lastTurnInputTokens?: number;
/** Most recent turn's output tokens. */
lastTurnOutputTokens?: number;
/** Most recent turn's cost (USD). */
lastTurnCostUsd?: number;
/** Most recent turn's cache-read ratio (cache_read / total_input). */
lastTurnCacheHitRate?: number;
/**
* Resolved model's context window in tokens — the denominator for
* ctx-occupancy displays. Derived from `SessionInfo.model` via the
* daemon's per-model catalog (`contextWindowForModel`). Switching
* models mid-session updates this on the next info_update broadcast.
*
* Optional for back-compat with daemons that pre-date this field;
* frontends should fall back to a conservative constant (200k) or
* skip the percentage when unset.
*/
contextWindow?: number;
}
/**
* Per-turn usage record — one row per SDK `result` event.
*
* Kept small + serializable so it fits cleanly in SessionInfo broadcasts.
* `totalInputTokens`, `billableInputTokens` and `cacheHitRate` are derived
* fields we compute once on write rather than re-computing in every
* frontend — keeps the StatusBar render cheap.
*
* Important Anthropic semantics (easy to get wrong):
* - `inputTokens` = NEW (uncached) input tokens only
* - `cacheReadTokens` = tokens served from prompt cache (billed 0.1x)
* - `cacheCreationTokens` = tokens written to cache (billed 1.25x)
* - Actual context size Claude processed = input + cacheRead + cacheCreation
*/
export interface TurnUsage {
/** 1-indexed turn number within the session. */
turnNumber: number;
/** Unix ms when the turn settled. */
createdAt: number;
/** New (uncached) input tokens for this turn. Does NOT include cache tokens. */
inputTokens: number;
/** Output tokens from the assistant. */
outputTokens: number;
/** Cache-read tokens (billed at ~10% of full input). */
cacheReadTokens: number;
/** Cache-write tokens (billed at ~125% of full input). */
cacheCreationTokens: number;
/** Total cost for the turn in USD, as reported by the SDK. */
totalCostUsd: number;
/** Wall-clock duration in ms (agent work, not network). */
durationMs: number;
/** Stop reason ("end_turn", "max_tokens", "tool_use", "error", …) if known. */
stopReason?: string;
/** Derived: total context size = inputTokens + cacheReadTokens + cacheCreationTokens. */
totalInputTokens: number;
/** Derived: full-price input = inputTokens + cacheCreationTokens (cache reads are ~free). */
billableInputTokens: number;
/** Derived: cacheReadTokens / totalInputTokens. 0-1. */
cacheHitRate: number;
/**
* Max single-call context size on the primary agent during this turn —
* `max(input + cache_read + cache_creation)` across the SDK's streamed
* per-call usage. Authoritative for "% of window" because
* `totalInputTokens` SUMS across the multiple internal Messages-API
* calls a tool-using turn makes, overstating single-shot context size.
*
* Optional for back-compat: rows persisted before the daemon began
* tracking this leave it `undefined`. Frontends fall back to
* `min(totalInputTokens, contextWindow)` (legacy behaviour) for those.
*/
primaryMaxCallInputTokens?: number;
}
// ── Usage analytics ───────────────────────────────────────────────────────────
export interface DailyUsageBucket {
day: string;
costUsd: number;
inputTokens: number;
outputTokens: number;
numTurns: number;
numSessions: number;
}
export interface LifetimeUsageTotals {
costUsd: number;
inputTokens: number;
outputTokens: number;
numTurns: number;
numSessions: number;
}
export interface Subagent {
/** SDK-side agent id (opaque handle). */
agentId: string;
/** ZeroID WIMSE URI if registered, else undefined. */
wimseUri?: string;
/** Subagent type label (e.g. "general-purpose", "code-reviewer", "Explorer"). */
agentType: string;
/** Unix ms when the sub-agent started. */
spawnedAt: number;
/** True while the sub-agent is running; false after SubagentStop. */
active: boolean;
}
// =============================================================================
// Identity — WHO produced a message. On every message, always.
// =============================================================================
export type IdentityType = "human" | "agent" | "subagent" | "system";
export interface MessageIdentity {
/** ZeroID WIMSE URI (e.g. spiffe://zeroid.dev/personal/dev/agent/codeoid-session-abc) */
sub: string;
/** Human-readable display name */
name?: string;
/** What kind of entity produced this */
type: IdentityType;
}
/** System identity — used for daemon-generated messages */
export const SYSTEM_IDENTITY: MessageIdentity = {
sub: "system:codeoid",
name: "Codeoid",
type: "system",
};
// =============================================================================
// Message roles
// =============================================================================
/**
* Every message has a role. Simple frontends render based on role alone.
* Extensible — add new roles without breaking existing frontends.
*/
export type MessageRole =
| "user" // Human sent a prompt
| "assistant" // Agent's text response
| "thinking" // Agent's reasoning / extended thinking
| "tool_call" // Agent invoked a tool
| "tool_result" // Tool execution output
| "system" // Errors, retries, warnings
| "info"; // Informational (identity changes, session events)
// =============================================================================
// Content parts — rich, structured content within a message.
//
// Frontends that support rich rendering use parts[].
// Simple frontends (Telegram) fall back to the `content` string.
// Discriminated on `kind` — ignore unknown kinds gracefully.
// =============================================================================
export type ContentPart =
| TextPart
| CodePart
| FileRefPart
| DiffPart
| TreePart
| ButtonPart
| ProgressPart
| ImagePart
| AnchorPart
| TablePart;
/** Markdown or plain text */
export interface TextPart {
kind: "text";
text: string;
/** If true, text contains markdown. Default: true for assistant role. */
markdown?: boolean;
}
/** Fenced code block with optional language */
export interface CodePart {
kind: "code";
code: string;
language?: string;
/** Optional file path this code belongs to */
filePath?: string;
}
/** Reference to a file (clickable in rich frontends) */
export interface FileRefPart {
kind: "file_ref";
path: string;
/** Optional line range */
lines?: [start: number, end: number];
/** Change summary if this is a modified file */
change?: { added: number; removed: number };
}
/** File diff summary */
export interface DiffPart {
kind: "diff";
path: string;
added: number;
removed: number;
/** Original file URI (for multi-diff views) */
originalPath?: string;
}
/** File tree node */
export interface TreeNode {
label: string;
type: "file" | "directory";
path?: string;
children?: TreeNode[];
}
export interface TreePart {
kind: "tree";
label: string;
children: TreeNode[];
}
/** Clickable button / action */
export interface ButtonPart {
kind: "button";
label: string;
/** Action identifier — frontends handle based on this */
action: string;
/** Additional data for the action */
data?: Record<string, unknown>;
/** Visual style hint */
style?: "primary" | "secondary" | "danger";
}
/** Progress indicator */
export interface ProgressPart {
kind: "progress";
message: string;
/** 0-100 if deterministic, undefined if indeterminate */
percent?: number;
/** Elapsed time in milliseconds */
elapsedMs?: number;
}
/** Inline image */
export interface ImagePart {
kind: "image";
url: string;
alt?: string;
}
/** Hyperlink / anchor */
export interface AnchorPart {
kind: "anchor";
uri: string;
title: string;
}
/** Structured table (for tabular data without markdown) */
export interface TablePart {
kind: "table";
headers: string[];
rows: string[][];
}
// =============================================================================
// Tool invocation state machine
//
// Tool calls are NOT single events — they have a lifecycle.
// Each state transition is sent as a delta update referencing the tool's toolId.
//
// Lifecycle:
// streaming → waiting_confirmation → executing → completed
// → cancelled
//
// Inspired by VS Code's IChatToolInvocation.StateKind.
// =============================================================================
export type ToolPhase =
| "streaming" // LM is still generating the tool call input
| "waiting_confirmation" // Awaiting user approval
| "executing" // Tool is running
| "completed" // Tool finished (success or error)
| "cancelled"; // User denied or interrupted
export type ToolState =
| ToolStreamingState
| ToolWaitingConfirmationState
| ToolExecutingState
| ToolCompletedState
| ToolCancelledState;
export interface ToolStreamingState {
phase: "streaming";
/** Partial input as the LM generates it */
partialInput?: unknown;
}
/**
* What a collaboration has spent so far, rolled up across its orchestrator and
* every live role-child (docs/collaborative-session-design.md §11 P3).
*
* Attached to a send-class dispatch approval so the owner sees the goal's
* running total on the button they are about to press. Cost shown anywhere else
* is trivia; cost shown at the moment of authorizing more work is a control.
*
* Optional on the wire and computed per request. A client that doesn't know the
* field ignores it; a daemon that fails to compute it omits it — the roll-up
* must never be able to block a dispatch, since an approval path wedged by a
* cost display is strictly worse than no cost display.
*/
export interface CollaborationCost {
/** The goal (orchestrator) session id these totals cover. */
goalSessionId: string;
/** Live role-children included in the roll-up. */
children: number;
/** Summed `SessionUsage.totalCostUsd` across orchestrator + children. */
totalCostUsd: number;
inputTokens: number;
outputTokens: number;
/** Summed turns — how much agent work the goal has already consumed. */
numTurns: number;
}
export interface ToolWaitingConfirmationState {
phase: "waiting_confirmation";
/** Complete tool input */
input: unknown;
/** Human-readable description of what the tool will do */
description: string;
/** Unique ID for this confirmation — client responds with this */
approvalId: string;
/**
* Present only for a send-class fleet dispatch from a collaborative session:
* what the goal has cost so far. Absent everywhere else, and absent if the
* roll-up could not be computed.
*/
collaborationCost?: CollaborationCost;
}
export interface ToolExecutingState {
phase: "executing";
/** Progress message from the tool */
progress?: string;
/** Elapsed time in milliseconds */
elapsedMs?: number;
}
export interface ToolCompletedState {
phase: "completed";
success: boolean;
/** Tool output (may be truncated for large outputs) */
output?: string;
/** Elapsed time in milliseconds */
elapsedMs?: number;
/** How the tool was confirmed */
confirmedBy?: "user" | "auto" | "setting";
}
export interface ToolCancelledState {
phase: "cancelled";
reason: "denied" | "interrupted" | "timeout";
/** Optional explanation */
message?: string;
}
/** Tool call metadata on a session message */
export interface ToolInfo {
/** Unique ID for this tool invocation — correlate updates via this */
toolId: string;
/** Tool name (e.g. "Bash", "Read", "Edit") */
name: string;
/** Current state */
state: ToolState;
/**
* The original tool input as provided by the model. Lives on
* `ToolInfo` (not just on `WaitingConfirmation`) so it survives
* phase transitions — clients that want to render Edit-as-diff in
* the completed phase need it after approval, and we don't want to
* pay a round-trip to fetch it back.
*/
input?: unknown;
}
// =============================================================================
// Session messages — the core of the protocol.
// =============================================================================
/**
* A complete session message. Self-contained, serializable, auditable.
*
* Every message carries:
* - `role` — what kind of message (user, assistant, tool_call, etc.)
* - `content` — string fallback for simple frontends
* - `parts` — rich content for capable frontends
* - `identity` — who produced this message
* - `tool` — tool lifecycle (only for role=tool_call)
* - `messageId` — unique, for delta updates and cross-references
*/
export interface SessionMessage {
type: "session.message";
sessionId: string;
/** Unique message ID — used by deltas to reference this message */
messageId: string;
role: MessageRole;
/** Plain text content — always present, usable by any frontend */
content: string;
/** Rich content parts — optional, for frontends that support them */
parts?: ContentPart[];
/** Who produced this message */
identity: MessageIdentity;
/** Tool invocation metadata (only when role=tool_call) */
tool?: ToolInfo;
/** Extensible metadata — frontends ignore unknown keys */
metadata?: Record<string, unknown>;
timestamp: string;
/**
* Session sequence cursor (`replay.resume` capability): the session's
* monotonic mutation counter at the time this frame was produced. Clients
* track `max(seq)` per session and pass it back on `session.attach.resume`
* to receive an incremental tail instead of a full scrollback replay.
* Absent on daemons that predate resume, and on messages the daemon no
* longer holds in its replay buffer.
*/
seq?: number;
}
/**
* Incremental update to an existing message.
*
* For streaming: the assistant's response arrives token by token.
* For tool lifecycle: tool state transitions (executing → completed).
*
* Frontends apply deltas to the message with matching messageId.
* If a frontend doesn't have the message (late attach), it can ignore deltas
* and rely on the scrollback replay to get the complete state.
*/
export interface SessionMessageDelta {
type: "session.message.delta";
sessionId: string;
/** References the original SessionMessage.messageId */
messageId: string;
/** Append to the content string */
contentAppend?: string;
/** Append new content parts */
partsAppend?: ContentPart[];
/** Replace content parts at a specific index */
partsUpdate?: { index: number; part: ContentPart }[];
/** Update tool state (state machine transition) */
toolStateUpdate?: ToolState;
timestamp: string;
/** Session sequence cursor — see `SessionMessage.seq`. */
seq?: number;
}
// =============================================================================
// Client → Daemon messages
// =============================================================================
/**
* The authentication handshake — the FIRST frame a client sends on a new
* WebSocket connection, before any `ClientMessage`. Not part of the
* `ClientMessage` union: it carries no request `id` (the reply is `auth.ok`
* or a socket close), and no other message is accepted until it succeeds.
*
* `protocolVersion` and `capabilities` make version/feature negotiation
* bidirectional: the daemon already advertises its version on `auth.ok`;
* these let the client declare what IT speaks, so the daemon can tailor
* behaviour per connection (e.g. skip the plain-`content` fallback for a
* `parts`-capable client). Both optional — clients that predate them are
* treated as legacy (no capabilities, unknown version).
*/
export interface AuthMsg {
type: "auth";
/** ZeroID-issued JWT. */
token: string;
/** The client's compiled-in `PROTOCOL_VERSION`. */
protocolVersion?: number;
/** Capability identifiers the client supports — see `CAPABILITIES`. */
capabilities?: string[];
/** Free-form client name/version for diagnostics (e.g. "codeoid-web/0.1.3"). */
client?: string;
}
/** Liveness heartbeat — daemon replies with `response.ok`. */
export interface PingMsg extends BaseClientMsg {
type: "ping";
}
export interface UsageDailyMsg extends BaseClientMsg {
type: "usage.daily";
days?: number;
}
export type ClientMessage =
| PingMsg
| SessionCreateMsg
| SessionListMsg
| SessionAttachMsg
| SessionDetachMsg
| SessionSendMsg
| SessionInterruptMsg
| SessionApproveMsg
| SessionUiResponseMsg
| SessionPartActionMsg
| SessionCommandsMsg
| SessionDestroyMsg
| SessionSetModeMsg
| SessionPinMsg
| SessionUnpinMsg
| SessionRotateMsg
| SessionSearchMsg
| SessionSetModelMsg
| SessionSetProviderMsg
| SessionForkMsg
| ScrollbackPageMsg
| SessionRenameMsg
| FsListMsg
| FsReadMsg
| FsBrowseDirMsg
| ClaudeConfigMsg
| BlackboardIndexMsg
| BlackboardReadMsg
| CollaborationPanelsMsg
| ModelsListMsg
| SessionExportMsg
| SessionImportMsg
| SettingsSchemaMsg
| SettingsGetMsg
| SettingsSetMsg
| UsageDailyMsg
| PipelineCreateMsg
| PipelineListMsg
| PipelineGetMsg
| PipelineAdvanceMsg
| PipelineAnswerMsg
| PipelineAbortMsg
| PipelineReviseMsg
| PipelinePackListMsg
| PipelineRegistryAddMsg
| PipelineRegistryRefreshMsg
| PipelinePackInstallMsg
| PipelinePackRemoveMsg
| PipelinePackTrustMsg
| PipelinePackSelectMsg
| PushRegisterMsg
| PushUnregisterMsg;
interface BaseClientMsg {
/** Request ID for correlating responses */
id: string;
}
/**
* One role in a collaborative session — a `{backend, model}` binding chosen
* per purpose (docs/collaborative-session-design.md §3).
*
* `name` is deliberately a free-form string, not an enum: "a role is data,
* not an enum". The five defaults (orchestrator / search / reasoning /
* architecture / review) are a starting profile, so adding
* "security-reviewer" or "test-author" stays a config change, never a code
* change.
*/
export interface CollaborationRole {
/** Role name, unique within the collaboration. */
name: string;
/**
* Backend this role's children run on. Must be an id the daemon
* advertised in `AuthOkMsg.providers`; an unregistered id is rejected with
* `invalid_request` rather than silently falling back — the same
* fail-closed rule as `SessionCreateMsg.providerId`.
*/
providerId: string;
/** Model within that backend. Absent = that backend's own default. */
model?: string;
/**
* How many children to fan out for this role. >1 is what makes a review
* panel a panel (§7). Absent = 1.
*/
count?: number;
/** What this role is for; surfaced in the child's brief. */
purpose?: string;
/**
* Whether this role's children may modify the workspace.
*
* **Absent = false**, and that default is the point. §3 gives `review` and
* `search` no repo write, and §6 wants a reviewer that *provably* cannot
* write rather than one asked not to — so write authority is opt-in per
* role and is enforced by the child's leaf identity holding no
* `tools:write` scope at all, not by a line in a prompt.
*
* Maps onto the existing dispatch worker shapes: `true` → "ship",
* `false` → "scout".
*/
write?: boolean;
/**
* Blackboard artifact kinds this role may READ — `spec`, `research`, `adr`,
* `task-list`, `diff`, `findings`, or `extra/<key>`
* (docs/collaborative-session-design.md §4).
*
* Absent = the default profile for this role name from §3. A role name with
* no profile and no declaration reads NOTHING — fail-closed in both
* directions, because the design's standing rule is that an unenforced field
* is false security.
*
* This is what makes reviewer independence structural rather than polite:
* `review` reads `diff`+`spec` and NOT `research` (the implementer's
* reasoning by proxy) or `findings` (its peers' opinions).
*/
reads?: string[];
/**
* Blackboard artifact kinds this role may WRITE. Absent = the §3 default
* profile for this role name; an unprofiled role that declares nothing
* writes nothing.
*
* A role writing a multi-writer kind (`findings`) writes into its OWN slot,
* chosen by the daemon — so one reviewer can never overwrite another's.
*/
writes?: string[];
}
/** The role name that must be present exactly once in a collaboration, and
* which drives dispatch for the goal. */
export const ORCHESTRATOR_ROLE = "orchestrator";
/**
* Collaborative-session config: one goal worked by several role-children on
* possibly different backends. Set on `session.create` behind the
* Collaborative toggle, which compiles it to an ephemeral one-goal pack
* (§9) — pack vocabulary stays hidden on this path.
*/
export interface CollaborationConfig {
/** The single goal this collaboration works. */
goal: string;
/**
* Role→backend bindings. Exactly one role must be named "orchestrator";
* in v1 it must sit on the claude backend, the only one that mounts the
* fleet MCP server (non-Claude orchestrators tracked in #245).
*/
roles: CollaborationRole[];
}
export interface SessionCreateMsg extends BaseClientMsg {