Skip to main content

forge_agent/
client.rs

1use std::collections::HashMap;
2
3use forge_primitives as types;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
7pub struct SessionLaunchSettings {
8    #[serde(default, skip_serializing_if = "Option::is_none")]
9    pub language: Option<String>,
10    #[serde(default, skip_serializing_if = "Option::is_none")]
11    pub settings: Option<serde_json::Value>,
12    #[serde(default, skip_serializing_if = "Option::is_none")]
13    pub agent_progress_summaries: Option<bool>,
14    /// Charter appended to the spawned `claude` subprocess's system
15    /// prompt via `--append-system-prompt`. Set for worker spawns (the
16    /// LLM-supplied inline persona, via `handle_spawn_worker`) and for
17    /// lead spawns (the lead charter, via `apply_lead_charter`); `None`
18    /// only when the caller supplies none.
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub charter: Option<String>,
21    /// Lead-session delegation preamble: when `Some`, appended to the
22    /// system prompt after the forge MCP trust block so a Lead session
23    /// knows how to spawn and drive workers. Built by the workspace for
24    /// Lead spawns only; `None` for workers.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub delegation_preamble: Option<String>,
27    /// Spawn-path-specific extra CLI args to thread through to the
28    /// `claude` subprocess via `OptionsBuilder::extra_arg`. Each pair
29    /// becomes `--<flag> <value>` (or `--<flag>` when the value is
30    /// `None`). Workers in git-repo projects populate
31    /// `("worktree", Some(label))` so claude forks a worktree at
32    /// `<repo>/.claude/worktrees/<label>/`; lead/resume paths leave
33    /// this empty.
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub extra_args: Vec<(String, Option<String>)>,
36    /// Boot-wave fresh-start routing flag (`--new`). When `true`, the
37    /// project-target spawn arms skip resuming the catalog lead session
38    /// and start a fresh one, and the lead cascades it to its workers.
39    /// Pure workspace-side routing - `#[serde(skip)]` keeps it out of
40    /// every serialized form, so it never reaches the `settings` JSON
41    /// sent to `claude`. Defaults `false`; only the boot dispatch sets it.
42    #[serde(skip)]
43    pub force_new: bool,
44    /// The first turn a lead receives when this spawn carries a stored
45    /// id to resume. The boot wave sets it so a restarted lead continues
46    /// without a human prompt; the spawn delivers it only then, so a
47    /// first boot or `--new` sends nothing. A resume the CLI refuses
48    /// still takes the note, whose wording does not depend on history.
49    /// Pure workspace-side routing - `#[serde(skip)]` keeps it out of
50    /// every serialized form.
51    #[serde(skip)]
52    pub resume_kick: Option<String>,
53    /// Per-spawn env overrides layered over the account env this
54    /// session spawns under. A present key replaces, an absent key
55    /// keeps, and a key mapped to an empty string removes the variable.
56    /// Empty by default, which is exactly the previous behaviour: this
57    /// is how per-spawn env reaches a respawned child without mutating
58    /// the previous generation's Agent.
59    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
60    pub env_overrides: HashMap<String, String>,
61}
62
63impl SessionLaunchSettings {
64    /// The `permissions.defaultMode` keys inside `settings`, shared by
65    /// the workspace's account stamp and the readers here so a rename
66    /// cannot split the seam. The `settings` JSON is forge-internal -
67    /// the CLI is driven by argv - so one const per key is the whole
68    /// contract.
69    pub const PERMISSIONS_KEY: &str = "permissions";
70    pub const PERMISSIONS_DEFAULT_MODE_KEY: &str = "defaultMode";
71}
72
73/// The class of a spawn failure, when the SDK could name one.
74///
75/// A consumer that would otherwise search the rendered message for a
76/// word can read this instead: `Error::CwdNotFound` renders the path it
77/// could not enter, and for a worker that path runs through
78/// `.claude/worktrees/<label>`, so the word "worktree" appears in a
79/// failure that has nothing to do with creating one.
80// No `Default`: `Unclassified` is the value that hands classification
81// back to the message heuristics, so a site that omits the kind by
82// accident should not compile into the behaviour this exists to replace.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum SpawnFailureKind {
85    /// The bridge could not attribute the failure - a child that exited,
86    /// a closed stream, a refusal. Message heuristics stay in charge.
87    Unclassified,
88    /// `forge_sdk::Error::CwdNotFound`: the subprocess had no working
89    /// directory to start in.
90    WorkingDirMissing,
91}
92
93#[derive(Debug, Clone)]
94pub enum AgentEvent {
95    Connected {
96        session_id: String,
97        cwd: String,
98        current_model: types::CurrentModel,
99        available_models: Vec<types::AvailableModel>,
100        mode: Option<types::ModeState>,
101        history_updates: Option<Vec<types::Message>>,
102        /// Compactions the resumed transcript records, seeding the
103        /// per-session count. `0` for a fresh session.
104        compaction_count: u32,
105    },
106    /// A spawn failure the bridge could name, carried beside the message
107    /// so a consumer reads the variant rather than searching rendered
108    /// prose for a word that a path can also contain.
109    ConnectionFailed {
110        message: String,
111        kind: SpawnFailureKind,
112    },
113    /// A user-facing turn never reached the CLI (or was never
114    /// acknowledged): a view committed to `Thinking` before the
115    /// dispatch, so without this event the spinner never tears down.
116    TurnError {
117        session_id: String,
118        message: String,
119        /// The class of the failure, derived from `message` where the
120        /// event is built so no reader has to search the prose again.
121        class: forge_primitives::TurnErrorClass,
122    },
123    PermissionRequest {
124        session_id: String,
125        request: types::PermissionRequest,
126    },
127    QuestionRequest {
128        session_id: String,
129        request: types::QuestionRequest,
130    },
131    /// The CLI answered a `cancel_async_message`. `cancelled: false` means the
132    /// prompt was already dequeued for execution - it is on its way to the
133    /// model, and a reader has to say so rather than pretend it was dropped.
134    PromptCancelResolved {
135        uuid: String,
136        cancelled: bool,
137    },
138    McpOperationError {
139        session_id: String,
140        error: types::McpOperationError,
141    },
142    /// The CLI refused (or failed) a `set_permission_mode` control
143    /// request. `message` carries the underlying error text so the
144    /// consumer can roll back the optimistic mode change and surface
145    /// the reason.
146    SetModeFailed {
147        session_id: String,
148        mode: forge_primitives::PermissionMode,
149        message: String,
150    },
151    /// The CLI refused (or failed) a `set_model` control request.
152    /// `message` carries the underlying error text so the consumer can
153    /// roll back the optimistic model change and surface the reason.
154    SetModelFailed {
155        session_id: String,
156        model: String,
157        message: String,
158    },
159    ContextUsage {
160        session_id: String,
161        /// The share the CLI itself reports, against its effective window.
162        percentage: Option<u8>,
163        /// Raw model context-window size in tokens (e.g. 200_000 for
164        /// Sonnet's default cap, 1_000_000 for the 1M-context variant).
165        /// `None` when the upstream probe hasn't reported it yet.
166        /// Sourced from `ContextUsageResponse.raw_max_tokens`.
167        max_tokens: Option<u64>,
168        /// The tokens the model actually read on its last request.
169        total_tokens: Option<u64>,
170        /// The effective window after any lowered auto-compact window,
171        /// which is the denominator a fill trigger has to use.
172        effective_max_tokens: Option<u64>,
173    },
174    McpSnapshot {
175        session_id: String,
176        servers: Vec<forge_primitives::McpServerStatus>,
177        error: Option<String>,
178    },
179    /// Raw `forge_primitives::Message` envelope from the underlying
180    /// SDK Client, forwarded to the consumer (e.g. a view's app)
181    /// for per-variant dispatch and state mutation.
182    SdkMessage {
183        session_id: String,
184        msg: forge_primitives::Message,
185    },
186    /// Observation of CLI runtime state captured from a hook input
187    /// payload as it passes through the SDK's hook-callback dispatch.
188    /// Hooks fire on every tool use, prompt submit, etc., so this is
189    /// a high-fidelity signal compared to the lower-frequency
190    /// `system/status` events. Fields are `Option` because the CLI
191    /// only populates the relevant subset on each hook event (e.g.
192    /// `agent_id` / `agent_type` are absent for main-agent tool calls).
193    HookObservation {
194        /// Session id the hook fired in.
195        session_id: String,
196        /// `tool_use_id` when the hook event is tool-lifecycle scoped.
197        /// `None` for events that aren't bound to a specific tool call
198        /// (`UserPromptSubmit`, `Stop`, etc.).
199        tool_use_id: Option<String>,
200        /// Permission mode active at the moment the hook fired. Wire
201        /// value as a string (e.g. `"acceptEdits"`, `"plan"`); the
202        /// consumer types it with `forge_primitives::PermissionMode`.
203        permission_mode: Option<String>,
204        /// Effort level active at the moment the hook fired (CLI
205        /// 2.1.133+, absent for older CLIs). Wire value as a string
206        /// (`"low"` / `"medium"` / `"high"` / `"xhigh"` / `"max"`);
207        /// the consumer maps to `forge_primitives::EffortLevel`.
208        effort: Option<String>,
209        /// Sub-agent identifier when the hook fired inside a
210        /// `Task`-spawned worker. Matches the `agent_id` from the
211        /// subagent's `SubagentStart` / `SubagentStop` hooks.
212        agent_id: Option<String>,
213        /// Sub-agent type name (e.g. `"general-purpose"`,
214        /// `"code-reviewer"`).
215        agent_type: Option<String>,
216    },
217}
218
219#[cfg(test)]
220mod tests {
221    use super::SessionLaunchSettings;
222
223    #[test]
224    fn session_launch_settings_serializes_agent_progress_summaries() {
225        let settings = SessionLaunchSettings {
226            settings: Some(serde_json::json!({ "model": "haiku" })),
227            agent_progress_summaries: Some(true),
228            ..SessionLaunchSettings::default()
229        };
230
231        let json = serde_json::to_value(&settings).expect("serialize");
232        assert_eq!(
233            json,
234            serde_json::json!({
235                "settings": { "model": "haiku" },
236                "agent_progress_summaries": true
237            })
238        );
239    }
240}