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}