diff --git a/crates/codeoid-protocol/src/client.rs b/crates/codeoid-protocol/src/client.rs index 780a4fe..c06b1bd 100644 --- a/crates/codeoid-protocol/src/client.rs +++ b/crates/codeoid-protocol/src/client.rs @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize}; use serde_json; -use crate::session::SessionMode; +use crate::session::{CollaborationConfig, SessionMode}; /// Tagged union of every message a client can send the daemon. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -30,6 +30,13 @@ pub enum ClientMessage { /// daemon fail-closes on unknown ids. Absent = daemon default. #[serde(default, skip_serializing_if = "Option::is_none")] provider_id: Option, + /// Make this a collaborative session: one goal worked by several + /// role-children on their own backends. Absent = a normal session. + /// The daemon validates the semantics (provider registered, exactly + /// one orchestrator, claude-only orchestrator in v1) and answers + /// with a specific `invalid_request` error. + #[serde(default, skip_serializing_if = "Option::is_none")] + collaboration: Option, }, #[serde(rename = "session.list", rename_all = "camelCase")] diff --git a/crates/codeoid-protocol/src/lib.rs b/crates/codeoid-protocol/src/lib.rs index 63d4f11..4b3cfdf 100644 --- a/crates/codeoid-protocol/src/lib.rs +++ b/crates/codeoid-protocol/src/lib.rs @@ -52,8 +52,8 @@ pub use message::{ ContentPart, IdentityType, MessageIdentity, MessageRole, SessionMessage, SessionMessageDelta, }; pub use session::{ - ForkedFrom, SessionInfo, SessionMode, SessionStatus, SessionUsage, SessionWorktree, Subagent, - TurnUsage, + CollaborationConfig, CollaborationRole, ForkedFrom, SessionInfo, SessionMode, SessionStatus, + SessionUsage, SessionWorktree, Subagent, TurnUsage, }; pub use tool::{CancelReason, ConfirmedBy, ToolInfo, ToolPhase, ToolState}; diff --git a/crates/codeoid-protocol/src/session.rs b/crates/codeoid-protocol/src/session.rs index 3cdd526..953f5da 100644 --- a/crates/codeoid-protocol/src/session.rs +++ b/crates/codeoid-protocol/src/session.rs @@ -94,6 +94,13 @@ pub struct SessionInfo { /// "⎇ " tag in the session title. #[serde(default, skip_serializing_if = "Option::is_none")] pub worktree: Option, + + /// Collaboration this session orchestrates — goal + role→backend + /// bindings — when it was created with the Collaborative toggle. + /// Absent = a normal session. Persisted daemon-side, so it survives a + /// restart the way `role`/`provider_id` already do. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collaboration: Option, } /// Where a forked session came from — the parent id, the parent's name at @@ -118,6 +125,45 @@ pub struct SessionWorktree { pub created_by_codeoid: bool, } +/// One role in a collaborative session — a `{backend, model}` binding chosen +/// per purpose. +/// +/// `name` is a free-form string, not an enum: the role taxonomy is data, so +/// the daemon can add "security-reviewer" as a config change and this crate +/// keeps parsing it without a release. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CollaborationRole { + pub name: String, + /// Backend this role's children run on. The daemon fail-closes on an id + /// it does not have registered. + pub provider_id: String, + /// Model within that backend. `None` = the backend's own default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// How many children to fan out for this role. `None` = 1; >1 is what + /// makes a review panel a panel. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub count: Option, + /// What this role is for; surfaced in the child's brief. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub purpose: Option, +} + +/// Collaborative-session config: one goal worked by several role-children on +/// possibly different backends. Sent on `session.create` and echoed back on +/// [`SessionInfo`]. +/// +/// Exactly one role must be named `orchestrator`, and in v1 it must sit on +/// the claude backend. The daemon enforces both and answers with a specific +/// error, so this crate carries only the shape. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CollaborationConfig { + pub goal: String, + pub roles: Vec, +} + /// Rotation telemetry — how many times the backing Claude Code session has /// been rolled over to avoid context compaction. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/codeoid-protocol/tests/wire_format.rs b/crates/codeoid-protocol/tests/wire_format.rs index 5556a83..65055cd 100644 --- a/crates/codeoid-protocol/tests/wire_format.rs +++ b/crates/codeoid-protocol/tests/wire_format.rs @@ -118,6 +118,25 @@ fn sample_session_info() -> SessionInfo { branch: "codeoid/fix-a1b2".into(), created_by_codeoid: true, }), + collaboration: Some(codeoid_protocol::CollaborationConfig { + goal: "Add rate limiting to the public API".into(), + roles: vec![ + codeoid_protocol::CollaborationRole { + name: "orchestrator".into(), + provider_id: "claude".into(), + model: None, + count: None, + purpose: None, + }, + codeoid_protocol::CollaborationRole { + name: "review".into(), + provider_id: "gemini".into(), + model: Some("gemini-2.5-pro".into()), + count: Some(3), + purpose: Some("independent critique".into()), + }, + ], + }), } } @@ -179,6 +198,7 @@ fn client_messages_are_camel_case_on_wire() { name: "n".into(), workdir: "/".into(), provider_id: Some("pi".into()), + collaboration: None, }, ), ("SessionList", ClientMessage::SessionList { id: "1".into() }), diff --git a/crates/codeoid-tui/src/app.rs b/crates/codeoid-tui/src/app.rs index c9fe641..0597124 100644 --- a/crates/codeoid-tui/src/app.rs +++ b/crates/codeoid-tui/src/app.rs @@ -2447,6 +2447,9 @@ fn session_create_message( name, workdir, provider_id, + // The TUI has no collaborative-create surface yet (that lands with + // the extended create dialog); omitted means a normal session. + collaboration: None, } } @@ -2581,6 +2584,7 @@ mod tests { provider_id: None, forked_from: None, worktree: None, + collaboration: None, }); state } diff --git a/crates/codeoid-tui/src/state/mod.rs b/crates/codeoid-tui/src/state/mod.rs index 49f1b0a..d702508 100644 --- a/crates/codeoid-tui/src/state/mod.rs +++ b/crates/codeoid-tui/src/state/mod.rs @@ -958,6 +958,7 @@ mod tests { provider_id: None, forked_from: None, worktree: None, + collaboration: None, } } diff --git a/crates/codeoid-tui/src/state/sessions.rs b/crates/codeoid-tui/src/state/sessions.rs index e7ad9a5..50b8f6c 100644 --- a/crates/codeoid-tui/src/state/sessions.rs +++ b/crates/codeoid-tui/src/state/sessions.rs @@ -130,6 +130,7 @@ mod tests { provider_id: None, forked_from: None, worktree: None, + collaboration: None, } } diff --git a/crates/codeoid-tui/src/ui/approval.rs b/crates/codeoid-tui/src/ui/approval.rs index 102113b..9619d59 100644 --- a/crates/codeoid-tui/src/ui/approval.rs +++ b/crates/codeoid-tui/src/ui/approval.rs @@ -292,6 +292,7 @@ mod tests { provider_id: None, forked_from: None, worktree: None, + collaboration: None, }); let mut m = msg( MessageRole::ToolCall, diff --git a/crates/codeoid-tui/src/ui/scrollback.rs b/crates/codeoid-tui/src/ui/scrollback.rs index 87a21ff..9368ed3 100644 --- a/crates/codeoid-tui/src/ui/scrollback.rs +++ b/crates/codeoid-tui/src/ui/scrollback.rs @@ -643,6 +643,7 @@ mod tests { provider_id: None, forked_from: None, worktree: None, + collaboration: None, } } diff --git a/crates/codeoid-tui/src/ui/worker.rs b/crates/codeoid-tui/src/ui/worker.rs index 7b7760b..7998ce5 100644 --- a/crates/codeoid-tui/src/ui/worker.rs +++ b/crates/codeoid-tui/src/ui/worker.rs @@ -373,6 +373,7 @@ mod tests { provider_id: None, forked_from: None, worktree: None, + collaboration: None, }); state.provider_commands.insert( "s1".into(),