escouade_plugin_protocol/engine.rs
1//! The contract of a harness in process (INT-9, docs/PLUGINS.md §4.5 and §4.6): what the core and the plugin of a
2//! harness say to each other. The core launches the harness itself, from the description the plugin gives
3//! (`engine/launch`), after it checked it (the fence of private projects, read-only, the token of Escouade's server
4//! never among the arguments, the files written in the agent's own folder only). What the harness says comes to the
5//! plugin in batches of raw lines (`agent/frames`), which it translates into normalized items and effects (`Item`)
6//! that the core applies to the agent. What the plugin has to write to the harness comes back in its answers
7//! (`writes`), and the core writes it on the harness's input. The PTY of a terminal view never goes through the
8//! plugin: only its launch (`terminal/launch`) and its hooks (`terminal/hook`), both ways.
9//!
10//! Every type tolerates what it does not know: a field added later is left aside, an item of an unknown `op` is
11//! `Item::Unknown`, which the core ignores.
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16/// The methods of the contract, all called by the core.
17pub mod method {
18 /// Stateless: the description of a harness's launch for an agent (`LaunchParams` → `Launch`).
19 pub const LAUNCH: &str = "engine/launch";
20 /// Stateless: the launch of a one-shot question (`OneShotParams` → `OneShotLaunch`).
21 pub const ONE_SHOT: &str = "engine/oneShot";
22 /// Stateless: the answer of a one-shot question, from what its process wrote (`OneShotOutput` → `OneShotText`).
23 pub const ONE_SHOT_READ: &str = "engine/oneShotRead";
24 /// An agent given to the plugin, before its launch and again after a restart (`Attach`).
25 pub const ATTACH: &str = "agent/attach";
26 /// The agent's process is gone: its state may be dropped (a notification, `AgentRef`).
27 pub const DETACH: &str = "agent/detach";
28 /// What the harness wrote, in a batch of raw lines (`Frames` → `Items`).
29 pub const FRAMES: &str = "agent/frames";
30 /// A message to send (`SendMessage` → `Writes`).
31 pub const SEND_MESSAGE: &str = "agent/sendMessage";
32 /// Stop the running turn (`Interrupt` → `Writes`).
33 pub const INTERRUPT: &str = "agent/interrupt";
34 /// A change of model, effort or mode (`SetValue` → `Writes`).
35 pub const SET_MODEL: &str = "agent/setModel";
36 pub const SET_EFFORT: &str = "agent/setEffort";
37 pub const SET_MODE: &str = "agent/setMode";
38 /// The answer to a permission or a question the harness asked (`AnswerPermission`, `AnswerQuestion` → `Writes`).
39 pub const ANSWER_PERMISSION: &str = "agent/answerPermission";
40 pub const ANSWER_QUESTION: &str = "agent/answerQuestion";
41 /// The quota of the agent's account, as its process reads it (`AgentRef` → `Usage`).
42 pub const READ_USAGE: &str = "agent/readUsage";
43 /// The launch of the harness's own interface in a terminal (`TerminalParams` → `Launch`).
44 pub const TERMINAL_LAUNCH: &str = "terminal/launch";
45 /// One of its hooks, with what the core answers it (`Hook` → `HookAnswer`).
46 pub const TERMINAL_HOOK: &str = "terminal/hook";
47 /// The lines the core read of what the harness's own interface writes, and its notes (`ViewParams` → `Items`).
48 pub const VIEW_TRANSLATE: &str = "view/translate";
49 /// A harness shared by agents (`Launch::shared`): the agent's session opened on it (`Open` → `Opened`).
50 pub const OPEN: &str = "agent/open";
51 /// The agent stops using its shared harness (`Close`, a call or a notification): gently, its turn finished first and
52 /// `Item::Closed` said once it is; or at once, its turn stopped.
53 pub const CLOSE: &str = "agent/close";
54 /// The agent was the last in its folder: its shared harness lets that folder go (`AgentRef`).
55 pub const LET_GO: &str = "agent/letGo";
56 /// Compact its context (`Compact` → `Compacted`), as `Engine::compaction` says it can.
57 pub const COMPACT: &str = "agent/compact";
58 /// Give its session a title (`SetValue`).
59 pub const RENAME: &str = "agent/rename";
60 /// A request in the harness's own protocol, its answer: for the tests, and what has no method yet (`Control`).
61 pub const CONTROL: &str = "agent/control";
62 /// From the plugin, without being asked (a notification, `Pushed`): what its harness said by a way only the plugin
63 /// reads (the event stream of a shared server).
64 pub const ITEMS: &str = "agent/items";
65 /// Its program ended by itself, for a harness that declares `relaunches` (`Gone` → `Items`): `Item::Relaunch` goes
66 /// on with another, `Item::Stop` says why not, nothing ends the agent's process.
67 pub const PROGRAM_GONE: &str = "agent/programGone";
68 /// Stop one of its background tasks (`Task` → `Asked`).
69 pub const STOP_TASK: &str = "agent/stopTask";
70 /// What a background task wrote so far (`Task` → `Asked`).
71 pub const TASK_OUTPUT: &str = "agent/taskOutput";
72 /// Link its session to its vendor's site, or end the link (`RemoteLink` → `Asked`).
73 pub const REMOTE: &str = "agent/remote";
74}
75
76/// How many bytes of lines a batch of `agent/frames` carries at most. A batch is what was read while the last one
77/// was with the plugin: none waits for more lines, a lone line goes at once.
78pub const BATCH_BYTES: usize = 64 * 1024;
79
80/// Where a conversation starts from.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
82#[serde(tag = "kind", rename_all = "camelCase")]
83pub enum StartAt {
84 #[default]
85 New,
86 Resume {
87 session: String,
88 },
89 Fork {
90 of: String,
91 #[serde(default)]
92 at: Option<String>,
93 },
94}
95
96/// Escouade's MCP server given to the agent. The plugin may put the token in a variable or a file of the agent's own,
97/// never among the arguments: the core refuses such a launch.
98#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct Grant {
100 pub url: String,
101 pub token: String,
102}
103
104impl std::fmt::Debug for Grant {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 f.debug_struct("Grant")
107 .field("url", &self.url)
108 .field("token", &"…")
109 .finish()
110 }
111}
112
113/// What `engine/launch` is told.
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
115#[serde(rename_all = "camelCase")]
116pub struct LaunchParams {
117 /// The key of the agent's process, as `agent/*` names it.
118 pub agent: String,
119 pub cwd: String,
120 pub model: String,
121 #[serde(default)]
122 pub effort: String,
123 pub mode: String,
124 #[serde(default)]
125 pub append_prompt: Option<String>,
126 /// What was said in the harness's own interface since the agent last ran (« Vue Cursor », a relay of the core's):
127 /// it goes before the next message.
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub relay: Option<String>,
130 /// Its account goes by its key (`HarnessDecl::key_var` given), not by the machine's sign-in: what a refusal says.
131 #[serde(default)]
132 pub by_key: bool,
133 #[serde(default)]
134 pub start: StartAt,
135 #[serde(default)]
136 pub read_dirs: Vec<String>,
137 /// Escouade's server, None when refused.
138 #[serde(default)]
139 pub escouade: Option<Grant>,
140 /// The paths refused to its tools (« Projet privé »), as its program spells them: each must be in the launch,
141 /// and `Launch::fence_applied` true, or the core does not launch it.
142 #[serde(default)]
143 pub fence: Vec<String>,
144 /// A discussion of the Docs tab: it changes no file and runs no command. `Launch::read_only` must say it holds.
145 #[serde(default)]
146 pub read_only: bool,
147 /// The private file the core made for Escouade's server (`Engine::grant_file`), which a plugin's launch may name.
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub escouade_file: Option<String>,
150 /// Its program, as the user knows it.
151 #[serde(default)]
152 pub program: String,
153 /// The folder of the agent's own, where the files of the launch may be written; None: no file may be.
154 #[serde(default)]
155 pub home: Option<String>,
156 /// For a plugin that serves the harnesses other plugins describe (`dev.escouade.acp`, PLG-6): the harness as its
157 /// manifest declares it (`HarnessDecl`, its `acp` included). None for a plugin's own harness.
158 #[serde(default)]
159 pub described: Option<HarnessDecl>,
160 /// The language of the app (`fr`, `en`), for the lines the plugin writes in the conversation.
161 #[serde(default)]
162 pub lang: String,
163 /// Whether its model takes an effort, as its account's harness told it; None: the plugin's own rule.
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub takes_effort: Option<bool>,
166 /// A settings file the core wrote for it (`Engine::fence_settings`): it holds the fence, and a launch that names
167 /// it needs not spell the paths (`fence`).
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub settings: Option<String>,
170 /// The file the core wrote to keep Escouade's server out of it (`Engine::refusal`), when it is refused.
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub refusal_file: Option<String>,
173}
174
175/// A variable of a launch.
176#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct EnvVar {
178 pub name: String,
179 pub value: String,
180}
181
182impl std::fmt::Debug for EnvVar {
183 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184 write!(f, "{}=…", self.name)
185 }
186}
187
188/// A file the core writes before the launch, in the agent's own folder.
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190pub struct FileToWrite {
191 pub path: String,
192 pub text: String,
193}
194
195/// The description of a launch (`engine/launch`, `terminal/launch`): never its program, which the core found itself
196/// (`locate`), and runs in its Job object, under WSL when need be.
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
198#[serde(rename_all = "camelCase")]
199pub struct Launch {
200 #[serde(default)]
201 pub args: Vec<String>,
202 #[serde(default)]
203 pub env: Vec<EnvVar>,
204 /// The variables of the app's own environment it must not get (`MSYSTEM` for Cursor).
205 #[serde(default)]
206 pub remove_env: Vec<String>,
207 #[serde(default)]
208 pub files: Vec<FileToWrite>,
209 /// The fence of `LaunchParams::fence` is in the launch (its arguments, variables or files).
210 #[serde(default)]
211 pub fence_applied: bool,
212 /// The harness is held read-only by this launch.
213 #[serde(default)]
214 pub read_only: bool,
215 /// The fence of `LaunchParams::fence` is held by the plugin itself, on the reads and writes the harness makes
216 /// through it (ACP's `fs/read_text_file`, `fs/write_text_file` and the permissions it asks), not by the launch:
217 /// the core then takes the launch without the paths in it (PLG-6).
218 #[serde(default)]
219 pub fence_by_client: bool,
220 /// The first lines written on its input, once it started.
221 #[serde(default)]
222 pub writes: Vec<String>,
223 /// One process for several agents (`opencode serve`, 0,7 Go): the core runs it for those of the same program and
224 /// variables, and does not start it again for the next; the plugin speaks to it by a way of its own.
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub shared: Option<SharedLaunch>,
227 /// For a terminal view: the trust bands its interface asks, shown before it opens.
228 #[serde(default)]
229 pub trust: Vec<String>,
230}
231
232/// How a process shared by agents starts and is shared. It runs in a folder of its own, none of the agents': a worktree
233/// removed would find it held. It lives from its first agent to its last, in the core's Job object.
234#[derive(Clone, PartialEq, Serialize, Deserialize, Default)]
235#[serde(rename_all = "camelCase")]
236pub struct SharedLaunch {
237 /// What the line that says it is ready holds: the line goes to the plugin (`SharedRef::line`).
238 pub ready: String,
239 /// The template of the line where the server says its address (`http://{host}:{port}`): the port the core reads
240 /// there is the one `local:shared` lets the plugin reach (`{host}` is `127.0.0.1` and nothing else, ESC-332).
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub listens: Option<String>,
243 /// A folder has one agent per process: another agent in that folder gets a process of its own.
244 #[serde(default)]
245 pub one_agent_a_folder: bool,
246 /// What the plugin wants back with each agent on that process (its password): kept by the core, never read.
247 #[serde(default)]
248 pub keep: Value,
249}
250
251impl std::fmt::Debug for SharedLaunch {
252 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253 f.debug_struct("SharedLaunch")
254 .field("ready", &self.ready)
255 .field("listens", &self.listens)
256 .field("one_agent_a_folder", &self.one_agent_a_folder)
257 .field("keep", &"…")
258 .finish()
259 }
260}
261
262/// The shared process an agent is on: enough for a plugin that started again to find it.
263#[derive(Clone, PartialEq, Serialize, Deserialize, Default)]
264pub struct SharedRef {
265 /// Which process, the same for each of its agents.
266 pub key: String,
267 /// The line that said it is ready.
268 pub line: String,
269 /// `SharedLaunch::keep`, as given by the launch that started it.
270 #[serde(default)]
271 pub keep: Value,
272}
273
274impl std::fmt::Debug for SharedRef {
275 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276 f.debug_struct("SharedRef")
277 .field("key", &self.key)
278 .field("line", &self.line)
279 .field("keep", &"…")
280 .finish()
281 }
282}
283
284/// An agent given to the plugin: what it cannot know by itself, given again after each restart and each change.
285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
286#[serde(rename_all = "camelCase")]
287pub struct Attach {
288 pub agent: String,
289 pub cwd: String,
290 #[serde(default)]
291 pub session: Option<String>,
292 #[serde(default)]
293 pub model: String,
294 #[serde(default)]
295 pub effort: String,
296 #[serde(default)]
297 pub mode: String,
298 /// The requests of the harness waiting for the user's answer, by their ids.
299 #[serde(default)]
300 pub pending: Vec<String>,
301 /// As `LaunchParams::described`.
302 #[serde(default)]
303 pub described: Option<HarnessDecl>,
304 /// As `LaunchParams::lang`.
305 #[serde(default)]
306 pub lang: String,
307 /// The shared process it is on (`Launch::shared`), once it runs.
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub shared: Option<SharedRef>,
310 /// Its folders the plugin declares (`permissions.files`), by root (`agent`, `agentRepo`, `agentHome`, `temp`), resolved
311 /// by the core, for `fs/*`.
312 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
313 pub roots: std::collections::BTreeMap<String, String>,
314 /// As `LaunchParams::fence`, `read_only` and `append_prompt`, for a plugin that holds them itself, or starts again:
315 /// on a session of its own (`Launch::shared`), in rules it writes.
316 #[serde(default, skip_serializing_if = "Vec::is_empty")]
317 pub fence: Vec<String>,
318 #[serde(default)]
319 pub read_only: bool,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub append_prompt: Option<String>,
322 /// A turn of its runs, as far as the core knows: one that ended while the plugin was down is told so.
323 #[serde(default)]
324 pub running: bool,
325 /// As `LaunchParams::home`, `read_dirs` and `by_key`, for a plugin that starts again and writes in that folder (its
326 /// rules, a harness that `relaunches`).
327 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub home: Option<String>,
329 #[serde(default, skip_serializing_if = "Vec::is_empty")]
330 pub read_dirs: Vec<String>,
331 #[serde(default)]
332 pub by_key: bool,
333}
334
335/// What `agent/open` is told: the agent's session, on the shared process of its `agent/attach`.
336#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
337pub struct Open {
338 pub agent: String,
339 #[serde(default)]
340 pub start: StartAt,
341 /// Escouade's server, None when refused.
342 #[serde(default)]
343 pub escouade: Option<Grant>,
344}
345
346/// What `agent/open` found.
347#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
348pub struct Opened {
349 /// How its harness's interface joins it in a terminal, what is secret in a variable.
350 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub attach: Option<Launch>,
352 /// The models of its account, as `ToldModel`s of the core.
353 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub catalog: Option<Value>,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358pub struct Close {
359 pub agent: String,
360 /// Its turn stopped first, not waited for.
361 #[serde(default)]
362 pub now: bool,
363}
364
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366pub struct Compact {
367 pub agent: String,
368 #[serde(default)]
369 pub instructions: String,
370}
371
372/// `EngineProcess::compact`'s answer: it is over; false, it runs as a turn the harness ends itself.
373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
374pub struct Compacted {
375 #[serde(default)]
376 pub over: bool,
377 /// What the core writes to the harness to ask it: `Item::Compacted` then says the end.
378 #[serde(default, skip_serializing_if = "Vec::is_empty")]
379 pub writes: Vec<String>,
380 /// Written, it runs as a turn the harness ends itself (Codex): no `Item::Compacted` to wait for.
381 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
382 pub turn: bool,
383}
384
385#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
386#[serde(rename_all = "camelCase")]
387pub struct Control {
388 pub agent: String,
389 pub request: Value,
390 /// How long the answer may take, in milliseconds.
391 #[serde(default)]
392 pub within_ms: u64,
393}
394
395/// `method::ITEMS`.
396#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
397pub struct Pushed {
398 pub agent: String,
399 pub items: Vec<Item>,
400}
401
402#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
403pub struct AgentRef {
404 pub agent: String,
405}
406
407/// What `agent/interrupt` is told (a plugin may read it as an `AgentRef`).
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
409#[serde(rename_all = "camelCase")]
410pub struct Interrupt {
411 pub agent: String,
412 /// The messages queued after the turn go too.
413 #[serde(default)]
414 pub cancel_queued: bool,
415}
416
417/// `method::PROGRAM_GONE`: how it ended, and what it said last on its error output.
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419pub struct Gone {
420 pub agent: String,
421 #[serde(default)]
422 pub code: Option<i32>,
423 #[serde(default)]
424 pub said: String,
425}
426
427/// A batch of what the harness wrote: each line as it read it, never read again by the core.
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
429pub struct Frames {
430 pub agent: String,
431 pub frames: Vec<String>,
432}
433
434/// What a batch made. The answer to any call that answers `Writes` may be this instead: its items applied, in their
435/// order (a message that starts a program again: `Item::Relaunch`, then its `Item::Write`).
436#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
437pub struct Items {
438 #[serde(default)]
439 pub items: Vec<Item>,
440}
441
442/// What the plugin writes to the harness, line by line.
443#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
444pub struct Writes {
445 #[serde(default)]
446 pub writes: Vec<String>,
447}
448
449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
450pub struct SendMessage {
451 pub agent: String,
452 pub uuid: String,
453 pub text: String,
454 /// The message as its harness takes it, when the core made it (ACP's `prompt` blocks: its images and its text
455 /// files, INT-10.1); None: `text` alone.
456 #[serde(default, skip_serializing_if = "Option::is_none")]
457 pub content: Option<serde_json::Value>,
458}
459
460#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
461pub struct SetValue {
462 pub agent: String,
463 pub value: String,
464}
465
466#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
467#[serde(rename_all = "camelCase")]
468pub struct AnswerPermission {
469 pub agent: String,
470 pub request: String,
471 pub allow: bool,
472 /// Allowed for the rest of the session.
473 #[serde(default)]
474 pub always: bool,
475 /// The tool's input, maybe changed, when allowed.
476 #[serde(default)]
477 pub input: Value,
478 /// What the agent is told, when refused.
479 #[serde(default)]
480 pub message: Option<String>,
481 /// A plan approved: the mode its work goes on in (the one the agent was in before Plan).
482 #[serde(default)]
483 pub resume: Option<String>,
484 /// The call it answers (`Item::Ask::call`).
485 #[serde(default, skip_serializing_if = "String::is_empty")]
486 pub call: String,
487 /// « Toujours »: the rules of `Item::Ask::suggestions` it goes by.
488 #[serde(default, skip_serializing_if = "Value::is_null")]
489 pub rules: Value,
490}
491
492#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
493pub struct AnswerQuestion {
494 pub agent: String,
495 pub request: String,
496 pub call: String,
497 /// The answers, by question.
498 pub answers: Value,
499 /// The questions they answer, as the card has them, for a harness that wants them in its order.
500 #[serde(default)]
501 pub questions: Value,
502 /// The call's input with its answers, for a harness that takes it whole.
503 #[serde(default, skip_serializing_if = "Value::is_null")]
504 pub input: Value,
505}
506
507/// The quota its process read (`usage::parse_windows` reads its windows); null when none.
508#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
509pub struct Usage {
510 #[serde(default)]
511 pub usage: Value,
512}
513
514/// The answer of `agent/readUsage` when the quota is to be asked of the harness: `writes` go to its input, and
515/// `Item::Usage` brings what it said.
516#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
517pub struct UsageAsked {
518 #[serde(default)]
519 pub writes: Vec<String>,
520}
521
522/// The answer of a call whose end is the harness's to tell (`agent/interrupt`, `agent/setModel`, `agent/control`…): `writes` go
523/// to its input, and `Item::Answer` under `ask` brings what it said. An answer without `ask` is over as it is.
524#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
525pub struct Asked {
526 #[serde(default)]
527 pub writes: Vec<String>,
528 #[serde(default)]
529 pub ask: String,
530}
531
532/// One of its background tasks.
533#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
534pub struct Task {
535 pub agent: String,
536 pub task: String,
537}
538
539/// `method::REMOTE`: its session linked (`name` on the vendor's site, kept when it ends if `keep`, joining the session
540/// `reattach`), or unlinked.
541#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
542#[serde(rename_all = "camelCase")]
543pub struct RemoteLink {
544 pub agent: String,
545 pub enabled: bool,
546 #[serde(default)]
547 pub name: String,
548 #[serde(default)]
549 pub keep: bool,
550 #[serde(default, skip_serializing_if = "Option::is_none")]
551 pub reattach: Option<String>,
552}
553
554/// What a model cost in the turn that ends (`Item::Result`).
555#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
556pub struct ModelTurn {
557 pub model: String,
558 #[serde(default)]
559 pub input: u64,
560 #[serde(default)]
561 pub output: u64,
562 #[serde(default)]
563 pub cache: u64,
564 #[serde(default)]
565 pub cost: f64,
566}
567
568/// The tokens and cost of a turn.
569#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
570pub struct TurnUsage {
571 #[serde(default)]
572 pub input: u64,
573 #[serde(default)]
574 pub output: u64,
575 #[serde(default)]
576 pub cache: u64,
577 #[serde(default)]
578 pub cost: f64,
579}
580
581/// A normalized item or an effect, which the core applies to the agent: the same as the conversation's (`user`,
582/// `text`, `thinking`, `tool`, `question`, `permission`, `turn`, `notice`) and the effects (`Effects` of
583/// `agent.rs`).
584#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
585#[serde(tag = "op", rename_all = "camelCase")]
586pub enum Item {
587 /// A turn begins, in the session `session` (the agent's from now on).
588 Start {
589 #[serde(default)]
590 session: Option<String>,
591 },
592 /// The agent's session, opened (new, resumed or copied) before any turn: the agent's from now on, its status left
593 /// as it is (INT-10.6: a copy of Mistral Vibe's has its own session as soon as its process starts).
594 Session { session: String },
595 /// Its text; a subagent's under `parent`. With `id`, the whole of the text streamed under that id (`Delta`).
596 #[serde(rename_all = "camelCase")]
597 Text {
598 text: String,
599 #[serde(default)]
600 parent: Option<String>,
601 #[serde(default)]
602 id: Option<String>,
603 },
604 Thinking {
605 text: String,
606 #[serde(default)]
607 parent: Option<String>,
608 #[serde(default)]
609 id: Option<String>,
610 },
611 /// A piece of a text (or a reasoning) as it is written, under `id`: the first one makes its item, the others add to
612 /// it; `Text` (`Thinking`) of the same id ends it.
613 Delta {
614 id: String,
615 text: String,
616 #[serde(default)]
617 thinking: bool,
618 #[serde(default)]
619 parent: Option<String>,
620 },
621 /// The model a subagent runs on, under its call.
622 Subagent { call: String, model: String },
623 /// A tool called, named as Escouade names them (Claude Code's names: `Bash`, `Edit`, `Task`…).
624 Tool {
625 id: String,
626 name: String,
627 #[serde(default)]
628 input: Value,
629 #[serde(default)]
630 parent: Option<String>,
631 /// Not a row of the plan: its task list comes by `Todos`.
632 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
633 unplanned: bool,
634 },
635 /// What is known later of a call: its input (an edit's file), or the name it turns out to have (`Write` for a
636 /// file added).
637 ToolInput {
638 id: String,
639 #[serde(default)]
640 name: Option<String>,
641 #[serde(default)]
642 input: Value,
643 },
644 /// Its result; `result` the structured one Claude Code gives (`{ filePath, structuredPatch }` of an edit,
645 /// `{ stdout, stderr }` of a command), which shows its diff.
646 ToolDone {
647 id: String,
648 #[serde(default)]
649 output: String,
650 #[serde(default)]
651 error: bool,
652 #[serde(default)]
653 result: Value,
654 },
655 /// A file it changed (the effect `files_changed`).
656 Touched { path: String },
657 /// The approval of the call `call`, asked as the request `request`: the call's tool and input unless `tool`
658 /// says another (`ExitPlanMode` and its `plan`), what the card says of it (`description`, `reason`), the
659 /// write it would make (`diff`, shown on the card), and whether « Pour cet agent » is offered.
660 #[serde(rename_all = "camelCase")]
661 Permission {
662 request: String,
663 call: String,
664 #[serde(default)]
665 tool: Option<String>,
666 #[serde(default)]
667 input: Option<Value>,
668 #[serde(default)]
669 description: Option<String>,
670 #[serde(default)]
671 reason: Option<String>,
672 #[serde(default)]
673 diff: Option<Diff>,
674 #[serde(default)]
675 can_always: bool,
676 /// The call of the subagent that asks it.
677 #[serde(default)]
678 parent: Option<String>,
679 /// The hunks of a patch the harness gave as they are, in place of `diff` (`hunks_of`).
680 #[serde(default, skip_serializing_if = "Option::is_none")]
681 hunks: Option<Value>,
682 /// « Always » is the harness's, for its session: not worded as « Pour cet agent ».
683 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
684 always_by_harness: bool,
685 /// The turn stays open on this card, what was streaming closed (a plan to approve at the end of a turn).
686 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
687 ends_stream: bool,
688 },
689 /// Questions to the user (each `{question, header, multiSelect, options: [{label, description}]}`).
690 Question {
691 request: String,
692 call: String,
693 questions: Value,
694 /// The call of the subagent that asks them.
695 #[serde(default, skip_serializing_if = "Option::is_none")]
696 parent: Option<String>,
697 },
698 /// A request answered in the harness's own interface: its card says so and goes. `decision` of a permission
699 /// (`allow`, `always`, `deny`), `answers` of questions (for each in its order, the labels chosen), else cancelled.
700 Answered {
701 request: String,
702 #[serde(default, skip_serializing_if = "Option::is_none")]
703 decision: Option<String>,
704 #[serde(default, skip_serializing_if = "Option::is_none")]
705 answers: Option<Value>,
706 },
707 /// The user's message typed in the harness's own interface: shown as they typed it.
708 User { id: String, text: String },
709 /// What the user typed in the harness's own interface (or Escouade pasted there), read in its view: their message, under
710 /// `id` when it has one, and a turn starts. `prompt`: the id of its prompt, when the interface writes one message
711 /// twice (a command, as typed then as `<command-name>`).
712 Typed {
713 #[serde(default)]
714 id: Option<String>,
715 text: String,
716 #[serde(default)]
717 images: u32,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
719 prompt: Option<String>,
720 },
721 /// The view's file was written since its interface started: the interface is up.
722 Spoke,
723 /// Something of its session is written: its absence now would be a loss.
724 Wrote,
725 /// The interface waits for the user, for this.
726 Waits { body: String },
727 /// The same, for the call of this tool, which the core words.
728 WaitsOn {
729 tool: String,
730 #[serde(default)]
731 input: Value,
732 },
733 /// What the interface is about to ask, which it waits for once a hook says so.
734 Asks {
735 tool: String,
736 #[serde(default)]
737 input: Value,
738 },
739 /// The agent works: a turn runs from now on, if none did.
740 Runs,
741 /// What it waited for is answered: it works again.
742 Goes,
743 /// The turn of its harness under way (the end the core makes after a hook is only that turn's).
744 ViewTurn {
745 #[serde(default)]
746 id: Option<String>,
747 },
748 /// The view's turn is over: the core closes it, and gives the note `end` back with what it knows of it.
749 ViewEnd {
750 #[serde(default)]
751 interrupted: bool,
752 /// Its length, in ms.
753 #[serde(default, skip_serializing_if = "Option::is_none")]
754 duration: Option<u64>,
755 #[serde(default, skip_serializing_if = "Option::is_none")]
756 error: Option<String>,
757 },
758 /// The usage of a message of the view, as its API gave it, which the core prices at the end of the turn.
759 ViewUsage {
760 id: String,
761 model: String,
762 #[serde(default)]
763 usage: Value,
764 },
765 /// What went wrong in the view's turn, for its end.
766 ViewError { error: String },
767 /// A tool of the view whose run is not told, shown as `status`: again under the same id, its status changes.
768 Row {
769 #[serde(default)]
770 id: Option<String>,
771 name: String,
772 #[serde(default)]
773 input: Value,
774 status: String,
775 },
776 /// The interface tells how the link of its session to claude.ai went: at `url`, or, with none, that it did not open.
777 Bridged {
778 #[serde(default)]
779 url: Option<String>,
780 #[serde(default)]
781 said: String,
782 },
783 /// A sign that the interface's session is linked to claude.ai.
784 Linked,
785 /// The model the harness took in place of the agent's (one its account does not offer).
786 Model { model: String },
787 /// What the agent's context holds (`tokens`), and the window of its model.
788 Context {
789 #[serde(default, skip_serializing_if = "Option::is_none")]
790 tokens: Option<u64>,
791 #[serde(default, skip_serializing_if = "Option::is_none")]
792 window: Option<u64>,
793 },
794 /// The task list of the agent, as Claude Code's `TodoWrite` gives it (`[{ content, status, activeForm }]`),
795 /// for its plan.
796 Todos { todos: Value },
797 /// The harness changed its mode by itself (out of its mode Plan): Escouade's mode it is in now.
798 Mode { mode: String },
799 /// The turn is over.
800 Turn {
801 #[serde(default)]
802 interrupted: bool,
803 /// Stopped by the usage limit, which resets then (ms), if told: `null` for a limit whose reset is not told,
804 /// left out for none.
805 #[serde(
806 default,
807 deserialize_with = "present",
808 skip_serializing_if = "Option::is_none"
809 )]
810 limited: Option<Option<i64>>,
811 #[serde(default)]
812 usage: TurnUsage,
813 /// It ended in error, and why (a model refused, a key refused).
814 #[serde(default)]
815 error: Option<String>,
816 /// The last entry of its session: where a copy forks it.
817 #[serde(default, skip_serializing_if = "Option::is_none")]
818 entry: Option<String>,
819 },
820 /// A line of the conversation from Escouade or the harness, not the model (`info`, `warning`, `error`).
821 Notice {
822 text: String,
823 #[serde(default = "info")]
824 level: String,
825 },
826 /// A line for the harness's input, written by the core in the order of the items.
827 Write { line: String },
828 /// The approval of a call, its card written whole: the tool asked, what it says of it, the hunks of the write it
829 /// would make (Claude Code's `structuredPatch`), how far « Toujours » goes (`alwaysScope`, left out for a plan).
830 #[serde(rename_all = "camelCase")]
831 PermissionCard {
832 request: String,
833 call: String,
834 tool: String,
835 #[serde(default)]
836 input: Value,
837 #[serde(default)]
838 description: Option<String>,
839 #[serde(default)]
840 reason: Option<String>,
841 #[serde(default)]
842 hunks: Value,
843 #[serde(default)]
844 can_always: bool,
845 #[serde(default)]
846 always_scope: Option<String>,
847 #[serde(default)]
848 parent: Option<String>,
849 },
850 /// Questions asked by a subagent (`parent`), or a form (`form`: what it is for, `{ message, source }`).
851 QuestionCard {
852 request: String,
853 call: String,
854 questions: Value,
855 #[serde(default)]
856 form: Value,
857 #[serde(default)]
858 parent: Option<String>,
859 },
860 /// The account's quota as a call told it: its meters (`usage::parse_windows`'s).
861 Quota {
862 #[serde(default)]
863 meters: Value,
864 },
865 /// What `agent/readUsage` asked of the harness (`UsageAsked`), as `Usage::usage`.
866 Usage {
867 #[serde(default)]
868 usage: Value,
869 },
870 /// The turn ending next stopped at a limit it waits for: told in its turn, the agent at rest and not in error.
871 LimitRest,
872 /// A harness that declares `opens`: its session is open and its first message may go; `models` are the account's
873 /// (`ToldModel`s), for the menu.
874 Ready {
875 #[serde(default)]
876 models: Value,
877 },
878 /// The end of what `agent/compact` asked, and why it failed.
879 Compacted {
880 #[serde(default)]
881 error: Option<String>,
882 },
883 /// The harness is of no use: the core stops it, and `reason` is what its end says (its protocol, no sign-in).
884 Stop { reason: String },
885 /// The last entry of its session a copy may start after, when no `Turn` tells it (a plan to approve).
886 Entry {
887 #[serde(default)]
888 entry: Option<String>,
889 },
890 /// What the agent is doing, shown on its row, when the item that follows does not tell it: a command that only
891 /// reads a file is a `Read` (`tool`, `input`), a reasoning begun says so before it has a word (`block`: `thinking`).
892 Activity {
893 #[serde(default)]
894 tool: Option<String>,
895 #[serde(default)]
896 input: Value,
897 #[serde(default)]
898 block: Option<String>,
899 },
900 /// How long the turn that ends took, as its harness counted it (ms), told just before its `Turn`; left out, the core
901 /// counts it.
902 Took { ms: i64 },
903 /// A harness that `relaunches`: its program stopped, and another started from this description, checked as the
904 /// first one was; the lines written after it go to the new one.
905 Relaunch { launch: Box<Launch> },
906 /// A harness that `relaunches`: its program stopped, the agent kept; `Relaunch` starts the next one.
907 Pause,
908 /// A file written now, in the agent's own folder alone, checked as those of a launch: rules read while it runs.
909 File { path: String, text: String },
910 /// Asked to stop gently (`method::CLOSE`), its turn is over: the agent has left its shared process.
911 Closed,
912 /// The conversation it runs (a harness that says so at each start): another than the agent's starts anew, its plan
913 /// forgotten; `notice` is what the agent's conversation says when that happens to a process that had one.
914 Conversation {
915 session: String,
916 #[serde(default)]
917 notice: String,
918 },
919 /// A message of the model begins, under `id`: what it reads (`usage`, as the API tells it) counts in the running
920 /// turn until `Item::Result`; `main`: of the agent itself, not of a subagent.
921 Message {
922 id: String,
923 #[serde(default)]
924 model: String,
925 #[serde(default)]
926 usage: Value,
927 #[serde(default)]
928 main: bool,
929 },
930 /// What the message `id` wrote so far, in tokens.
931 MessageOutput { id: String, output: u64 },
932 /// What the harness says of its background tasks, for the plan (`task_started`, `task_progress`…).
933 Tasks { frame: Value },
934 /// A line of the conversation that comes from neither the user nor the model (a task that ended, a message of
935 /// another agent): `source` says which.
936 Event {
937 #[serde(default)]
938 id: Option<String>,
939 source: String,
940 text: String,
941 #[serde(default, skip_serializing_if = "Option::is_none")]
942 from: Option<String>,
943 },
944 /// What a local command printed (`output`, `error`).
945 Output {
946 #[serde(default)]
947 id: Option<String>,
948 level: String,
949 text: String,
950 },
951 /// The whole call of a tool, once its message is whole: `Tool` began it if it was streamed, its row is then told
952 /// its input; the plan hears of it either way.
953 Call {
954 id: String,
955 name: String,
956 #[serde(default)]
957 input: Value,
958 #[serde(default)]
959 parent: Option<String>,
960 },
961 /// Its result, whatever the row says by now (`ToolDone` leaves a row that ended as it is).
962 ToolResult {
963 id: String,
964 #[serde(default)]
965 output: String,
966 #[serde(default)]
967 error: bool,
968 #[serde(default)]
969 result: Value,
970 },
971 /// A user's message the harness says back: the echo of one sent by the core, or one sent another way. `origin` is
972 /// who it comes from when no person wrote it (`task-notification`, `peer`…), `from` the agent that sent it.
973 Replayed {
974 #[serde(default)]
975 id: Option<String>,
976 #[serde(default)]
977 text: String,
978 #[serde(default, skip_serializing_if = "Option::is_none")]
979 origin: Option<String>,
980 #[serde(default, skip_serializing_if = "Option::is_none")]
981 from: Option<String>,
982 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
983 synthetic: bool,
984 #[serde(default)]
985 images: u32,
986 #[serde(default, skip_serializing_if = "Vec::is_empty")]
987 files: Vec<String>,
988 },
989 /// The harness asks leave for a call, or asks the user (`tool` `AskUserQuestion`): `suggestions` are the rules
990 /// « Toujours » would keep, given back in `AnswerPermission::rules`; `task` the subagent that asks. The core answers
991 /// by itself a read of a file the user joined.
992 #[serde(rename_all = "camelCase")]
993 Ask {
994 request: String,
995 call: String,
996 tool: String,
997 #[serde(default)]
998 input: Value,
999 #[serde(default)]
1000 title: Option<String>,
1001 #[serde(default)]
1002 description: Option<String>,
1003 #[serde(default)]
1004 reason: Option<String>,
1005 #[serde(default)]
1006 suggestions: Value,
1007 #[serde(default)]
1008 no_always: bool,
1009 #[serde(default)]
1010 default_no: bool,
1011 #[serde(default)]
1012 task: Option<String>,
1013 },
1014 /// The end of a turn the harness counts itself: what each model cost, the context `window` of the one that ran,
1015 /// whether it failed and why. `limited`: stopped by the usage limit, which resets at `resets` (ms) if told;
1016 /// `sign_in`: by a sign-in of its account that could not be refreshed; `failure` is what a failure says, `reply`
1017 /// what a good end says when the model wrote nothing.
1018 #[serde(rename_all = "camelCase")]
1019 Result {
1020 #[serde(default)]
1021 id: Option<String>,
1022 #[serde(default)]
1023 failed: bool,
1024 #[serde(default)]
1025 error: Option<String>,
1026 #[serde(default)]
1027 failure: String,
1028 #[serde(default)]
1029 reply: String,
1030 #[serde(default)]
1031 limited: bool,
1032 #[serde(default)]
1033 resets: Option<i64>,
1034 #[serde(default)]
1035 sign_in: bool,
1036 #[serde(default)]
1037 duration: Value,
1038 #[serde(default)]
1039 window: Option<u64>,
1040 #[serde(default)]
1041 cost: f64,
1042 #[serde(default)]
1043 tokens: u64,
1044 #[serde(default)]
1045 models: Vec<ModelTurn>,
1046 },
1047 /// A usage limit was met: `scoped` to the model it runs on, not to its account.
1048 Limit {
1049 #[serde(default)]
1050 scoped: bool,
1051 },
1052 /// Whether its account answers as extra usage.
1053 Overage { on: bool },
1054 /// The state of its link to its vendor's site (`ready`, `connected`…).
1055 Remote {
1056 #[serde(default)]
1057 state: Option<String>,
1058 },
1059 /// What its harness said when it started: its commands and its models (as it tells them), which `Ready` follows.
1060 Told {
1061 #[serde(default)]
1062 commands: Value,
1063 #[serde(default)]
1064 models: Value,
1065 },
1066 /// What the harness said of the call `ask` (`Asked`): its answer, or why it refused.
1067 Answer {
1068 ask: String,
1069 #[serde(default)]
1070 answer: Value,
1071 #[serde(default, skip_serializing_if = "Option::is_none")]
1072 error: Option<String>,
1073 },
1074 /// What the harness wrote that is none of its protocol, kept for what its end says.
1075 Said { line: String },
1076 /// An item of a later version of the contract: left aside.
1077 #[serde(other)]
1078 Unknown,
1079}
1080
1081fn info() -> String {
1082 "info".into()
1083}
1084
1085/// A field that is there, `null` included (`Some(None)`); left out, it is `None` (`default`).
1086fn present<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<Option<i64>>, D::Error> {
1087 Ok(Some(Option::<i64>::deserialize(d)?))
1088}
1089
1090/// The hunks of a unified diff, as Claude Code's `structuredPatch` has them.
1091pub fn hunks_of(diff: &str) -> Value {
1092 let mut hunks: Vec<Value> = Vec::new();
1093 for line in diff.lines() {
1094 if let Some(head) = line.strip_prefix("@@ ") {
1095 let num = |sign: char| {
1096 head.split_whitespace()
1097 .find_map(|w| w.strip_prefix(sign))
1098 .and_then(|w| w.split(',').next())
1099 .and_then(|n| n.parse::<u64>().ok())
1100 .unwrap_or(0)
1101 };
1102 hunks.push(
1103 serde_json::json!({ "oldStart": num('-'), "newStart": num('+'), "lines": [] }),
1104 );
1105 continue;
1106 }
1107 if line.starts_with("+++") || line.starts_with("---") {
1108 continue;
1109 }
1110 if hunks.is_empty() {
1111 if !(line.starts_with('+') || line.starts_with('-') || line.starts_with(' ')) {
1112 continue;
1113 }
1114 hunks.push(serde_json::json!({ "oldStart": 1, "newStart": 1, "lines": [] }));
1115 }
1116 if let Some(lines) = hunks.last_mut().and_then(|h| h["lines"].as_array_mut()) {
1117 lines.push(serde_json::json!(line));
1118 }
1119 }
1120 Value::Array(hunks)
1121}
1122
1123/// A write a permission would make: its file, before (None for a file added) and after.
1124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1125#[serde(rename_all = "camelCase")]
1126pub struct Diff {
1127 pub path: String,
1128 #[serde(default)]
1129 pub old_text: Option<String>,
1130 #[serde(default)]
1131 pub new_text: String,
1132}
1133
1134/// What `engine/oneShot` is told: one question, no session, no settings of the user's, no MCP server.
1135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1136#[serde(rename_all = "camelCase")]
1137pub struct OneShotParams {
1138 /// `light` (a short text, no tool) or `reading` (after reading its folder, tools that only read).
1139 pub weight: String,
1140 pub cwd: String,
1141 pub system: String,
1142 pub prompt: String,
1143 #[serde(default)]
1144 pub model: String,
1145}
1146
1147/// How the core runs it: the arguments, the variables, and what goes on its input.
1148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1149#[serde(rename_all = "camelCase")]
1150pub struct OneShotLaunch {
1151 #[serde(default)]
1152 pub args: Vec<String>,
1153 #[serde(default)]
1154 pub env: Vec<EnvVar>,
1155 #[serde(default)]
1156 pub remove_env: Vec<String>,
1157 #[serde(default)]
1158 pub input: Option<String>,
1159}
1160
1161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1162pub struct OneShotOutput {
1163 pub stdout: String,
1164 pub stderr: String,
1165 #[serde(default)]
1166 pub code: Option<i32>,
1167}
1168
1169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1170pub struct OneShotText {
1171 pub text: String,
1172}
1173
1174/// What `terminal/launch` is told: the agent's interface in a terminal, its hooks pointed at Escouade.
1175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1176#[serde(rename_all = "camelCase")]
1177pub struct TerminalParams {
1178 pub agent: String,
1179 pub cwd: String,
1180 #[serde(default)]
1181 pub session: Option<String>,
1182 #[serde(default)]
1183 pub model: String,
1184 #[serde(default)]
1185 pub mode: String,
1186 #[serde(default)]
1187 pub home: Option<String>,
1188 /// Where its hooks post (`http://127.0.0.1:<port>/hook`), the variable that holds their token, and
1189 /// `escouade-hook` for a harness that runs its hooks as commands.
1190 pub hook_url: String,
1191 pub hook_token_var: String,
1192 #[serde(default)]
1193 pub hook_command: Option<String>,
1194 #[serde(default)]
1195 pub escouade: Option<Grant>,
1196 #[serde(default)]
1197 pub fence: Vec<String>,
1198 #[serde(default)]
1199 pub read_only: bool,
1200}
1201
1202/// One of its hooks, and what the core has to answer it: the hook goes both ways (what Escouade adds for the model, a call it refuses).
1203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1204pub struct Hook {
1205 pub agent: String,
1206 pub hook: Value,
1207 #[serde(default)]
1208 pub reply: Option<HookReply>,
1209}
1210
1211/// What the core answers a hook, which the plugin puts in its harness's form.
1212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1213pub struct HookReply {
1214 /// Context added for the model.
1215 #[serde(default)]
1216 pub context: Option<String>,
1217 /// The call refused, and why.
1218 #[serde(default)]
1219 pub deny: Option<String>,
1220}
1221
1222/// What a hook made: items for the agent, and the hook's answer as its harness reads it (None: nothing).
1223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1224pub struct HookAnswer {
1225 #[serde(default)]
1226 pub items: Vec<Item>,
1227 #[serde(default)]
1228 pub answer: Option<Value>,
1229}
1230
1231/// What the conversation holds of an id, for a view that starts on it.
1232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1233pub struct ViewKnown {
1234 pub id: String,
1235 /// A call's tool, and how it stands (`running`, `ok`…): none for what is no call.
1236 #[serde(default, skip_serializing_if = "Option::is_none")]
1237 pub name: Option<String>,
1238 #[serde(default, skip_serializing_if = "Option::is_none")]
1239 pub status: Option<String>,
1240}
1241
1242/// `view/translate`: the terminal view of an agent. The core reads what the harness's own interface writes (its transcript,
1243/// its rollout, its journal) and hands it on here a line at a time, what it holds of the agent beside it; the plugin answers
1244/// the items. The core keeps the terminal, the file and the agent's state; the plugin, what the lines mean.
1245///
1246/// A line is what the file holds, or a note of the core, an object with a field `escouade`:
1247/// - `{"escouade":"end","error":…,"durationMs":…,"models":…}`: the running turn is over, for a reason the core knows (its
1248/// interface gone, a hook, what `Item::ViewEnd` asked): the plugin answers its `Item::Turn`;
1249/// - `{"escouade":"hook","hook":…}`: a hook of the interface that says what the file does not;
1250/// - `{"escouade":"frame","frame":…}`: a message of the harness, read as a process's (a recorded stream).
1251#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1252#[serde(rename_all = "camelCase")]
1253pub struct ViewParams {
1254 /// The view, by a key of its own: another one is another conversation, which starts from nothing.
1255 pub agent: String,
1256 /// The harness, for a plugin that has several.
1257 #[serde(default)]
1258 pub harness: String,
1259 /// The first lines of this view: `known` is given, and `gone`, the view it replaces, may be dropped.
1260 #[serde(default)]
1261 pub fresh: bool,
1262 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1263 pub known: Vec<ViewKnown>,
1264 #[serde(default, skip_serializing_if = "Option::is_none")]
1265 pub gone: Option<String>,
1266 pub cwd: String,
1267 #[serde(default)]
1268 pub mode: String,
1269 #[serde(default)]
1270 pub model: String,
1271 /// The language of the app.
1272 #[serde(default)]
1273 pub lang: String,
1274 /// The model the agent last ran on.
1275 #[serde(default)]
1276 pub main_model: String,
1277 #[serde(default)]
1278 pub session: Option<String>,
1279 /// A turn runs.
1280 #[serde(default)]
1281 pub active: bool,
1282 /// The user stopped it.
1283 #[serde(default)]
1284 pub interrupted: bool,
1285 /// The agent is a copy, its session not its own yet.
1286 #[serde(default)]
1287 pub copy: bool,
1288 /// A discussion of the Docs tab: it writes nothing.
1289 #[serde(default)]
1290 pub discussion: bool,
1291 /// The message of the turn under way, as the interface took it.
1292 #[serde(default)]
1293 pub prompt: Option<String>,
1294 pub lines: Vec<String>,
1295}
1296
1297/// A harness as a manifest declares it (`contributes.harnesses[]`): all the core needs without starting the plugin
1298/// (the welcome screen, the menus, the accounts, the memory).
1299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1300#[serde(rename_all = "camelCase")]
1301pub struct HarnessDecl {
1302 pub id: String,
1303 pub name: String,
1304 #[serde(default)]
1305 pub speaker: Option<String>,
1306 /// Its program: a path (`{escouade}/…` for one of the app's), or names looked for on the PATH.
1307 #[serde(default)]
1308 pub locate: Locate,
1309 #[serde(default = "version_args")]
1310 pub version_args: Vec<String>,
1311 #[serde(default)]
1312 pub tested: TestedDecl,
1313 #[serde(default)]
1314 pub caps: CapsDecl,
1315 #[serde(default)]
1316 pub models: Vec<ModelDecl>,
1317 #[serde(default)]
1318 pub efforts: Vec<String>,
1319 /// The variable that gives each account its configuration folder.
1320 #[serde(default)]
1321 pub account_var: Option<String>,
1322 /// The variable that gives an account added by its key that key (`CURSOR_API_KEY`): the agent then goes by it, not
1323 /// by the machine's sign-in (`LaunchParams::by_key`).
1324 #[serde(default)]
1325 pub key_var: Option<String>,
1326 /// It does not end when its input is closed (Cursor CLI's `agent acp`): it is killed that many milliseconds after.
1327 #[serde(default)]
1328 pub close_grace_ms: Option<u64>,
1329 /// What frames its messages on its input and output: lines when left out, `contentLength` for `Content-Length`
1330 /// headers (Copilot CLI's server).
1331 #[serde(default)]
1332 pub framing: Option<String>,
1333 /// A line written on its input just before it is closed (Copilot CLI's `runtime.shutdown`, which saves its session).
1334 #[serde(default)]
1335 pub close_line: Option<String>,
1336 /// Its plugin opens the session over the protocol once it runs: the agent is ready at `Item::Ready`, not at its
1337 /// launch, and `Item::Stop` is its refusal.
1338 #[serde(default)]
1339 pub opens: bool,
1340 /// One agent has several programs in a row (`Item::Relaunch`, `Item::Pause`): it takes its settings at its launch alone,
1341 /// or has no way to stop a turn. Its end by itself is told to its plugin (`method::PROGRAM_GONE`).
1342 #[serde(default)]
1343 pub relaunches: bool,
1344 /// The lines of its error output that hold one of these are not kept (what it logs of the account).
1345 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1346 pub stderr_leaves_out: Vec<String>,
1347 /// Where it keeps what it remembers of a project, offered once to the shared memory: a folder of files, with
1348 /// `{configDir}` and `{projectKey}` (the project's path, every character but a letter or a digit a dash).
1349 #[serde(default)]
1350 pub memory: Option<MemoryDecl>,
1351 /// A harness that speaks ACP, described: how the plugin `dev.escouade.acp` drives it (PLG-6). A plugin that only
1352 /// describes such a harness runs no program of its own.
1353 #[serde(default)]
1354 pub acp: Option<AcpDecl>,
1355 /// How to install it, shown when it is not found.
1356 #[serde(default)]
1357 pub install: Option<InstallDecl>,
1358 /// The command that updates it.
1359 #[serde(default)]
1360 pub update_command: Option<String>,
1361 /// Who writes the texts it drafts (« rédigé par … »), its name when left out.
1362 #[serde(default)]
1363 pub writer: Option<String>,
1364}
1365
1366/// A text in the languages of the app: one for all, or by language (`{ "fr": "…", "en": "…" }`, English when the
1367/// language is missing).
1368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1369#[serde(untagged)]
1370pub enum Text {
1371 One(String),
1372 ByLang(std::collections::BTreeMap<String, String>),
1373}
1374
1375impl Text {
1376 /// In `lang`, else English, else any.
1377 pub fn get(&self, lang: &str) -> String {
1378 match self {
1379 Text::One(t) => t.clone(),
1380 Text::ByLang(m) => m
1381 .get(lang)
1382 .or_else(|| m.get("en"))
1383 .or_else(|| m.values().next())
1384 .cloned()
1385 .unwrap_or_default(),
1386 }
1387 }
1388}
1389
1390/// A mode of Escouade a harness takes (`default`, `plan`, `acceptEdits`, `bypassPermissions`), and what it means
1391/// there, as the menu says it. Written as its id alone, or `{ "id", "meaning" }`.
1392#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1393pub struct ModeDecl {
1394 pub id: String,
1395 #[serde(default, skip_serializing_if = "Option::is_none")]
1396 pub meaning: Option<Text>,
1397}
1398
1399impl<'de> Deserialize<'de> for ModeDecl {
1400 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1401 #[derive(Deserialize)]
1402 #[serde(untagged)]
1403 enum Form {
1404 Id(String),
1405 Full {
1406 id: String,
1407 #[serde(default)]
1408 meaning: Option<Text>,
1409 },
1410 }
1411 Ok(match Form::deserialize(d)? {
1412 Form::Id(id) => ModeDecl { id, meaning: None },
1413 Form::Full { id, meaning } => ModeDecl { id, meaning },
1414 })
1415 }
1416}
1417
1418impl From<&str> for ModeDecl {
1419 fn from(id: &str) -> Self {
1420 ModeDecl {
1421 id: id.into(),
1422 meaning: None,
1423 }
1424 }
1425}
1426
1427/// Its installers.
1428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1429pub struct InstallDecl {
1430 #[serde(default)]
1431 pub windows: String,
1432 #[serde(default)]
1433 pub unix: String,
1434}
1435
1436/// How an ACP agent is driven (ACP v1: JSON-RPC 2.0, one message a line on its input and output).
1437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1438#[serde(rename_all = "camelCase")]
1439pub struct AcpDecl {
1440 /// Its arguments (`["acp"]`).
1441 #[serde(default)]
1442 pub args: Vec<String>,
1443 /// Variables of its own (never a secret: a manifest is read by all).
1444 #[serde(default)]
1445 pub env: std::collections::BTreeMap<String, String>,
1446 /// Variables of the app's environment it must not get.
1447 #[serde(default)]
1448 pub remove_env: Vec<String>,
1449 /// It reads and writes files through the client (`fs/read_text_file`, `fs/write_text_file`): the fence of
1450 /// private projects is then held there. False: it reads and writes by itself, and no private project is open to it.
1451 #[serde(default = "yes")]
1452 pub fs: bool,
1453 /// With `fs` false, the fence of a private project held on its permissions alone, as Cursor CLI's in the core was
1454 /// (ESC-140): a permission that names a path of the fence is refused at once; what it reads and writes by itself
1455 /// is not seen, which its modes' meanings say. Only a plugin Escouade gives is believed (`fenceByClient`).
1456 #[serde(default)]
1457 pub fence_on_permissions: bool,
1458 /// The ACP mode (`session/set_mode`) of each mode of Escouade; a mode left out sets none.
1459 #[serde(default)]
1460 pub modes: std::collections::BTreeMap<String, String>,
1461 /// Its ACP mode that changes nothing (`ask`): with it, it holds a read-only launch (a discussion of the Docs tab).
1462 #[serde(default)]
1463 pub read_only_mode: Option<String>,
1464 /// The id of the configuration option that picks the model (`session/set_config_option`); left out, the model
1465 /// goes by `session/set_model`.
1466 #[serde(default)]
1467 pub model_option: Option<String>,
1468 /// The extensions of the protocol it speaks beside ACP (`cursor`: `cursor/ask_question`, `cursor/create_plan`,
1469 /// `cursor/update_todos`, its subagents).
1470 #[serde(default)]
1471 pub extensions: Vec<String>,
1472 /// It has no tool to ask the user: its first message tells it to end its turn on a block
1473 /// `<escouade_question>`, shown as a question's card, the answers sent as the next message (as for Gemini CLI).
1474 #[serde(default)]
1475 pub questions_in_text: bool,
1476 /// The names of its tools that run a subagent (by their title or `rawInput.tool`): shown as Claude Code's `Task`,
1477 /// a row of the plan.
1478 #[serde(default)]
1479 pub subagent_tools: Vec<String>,
1480 /// Its tools of the kind `other` under the name Claude Code gives them (`{ "shell": "Bash", "write": "Write" }`),
1481 /// by their own name: `_meta.<agent>.toolCall.toolName`, else their title before « · ».
1482 #[serde(default)]
1483 pub tools: std::collections::BTreeMap<String, String>,
1484}
1485
1486fn yes() -> bool {
1487 true
1488}
1489
1490fn version_args() -> Vec<String> {
1491 vec!["--version".into()]
1492}
1493
1494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1495pub struct Locate {
1496 #[serde(default)]
1497 pub program: Option<String>,
1498 #[serde(default)]
1499 pub names: Vec<String>,
1500}
1501
1502#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1503pub struct TestedDecl {
1504 pub oldest: String,
1505 pub newest: String,
1506}
1507
1508impl Default for TestedDecl {
1509 fn default() -> Self {
1510 Self {
1511 oldest: "0".into(),
1512 newest: "0".into(),
1513 }
1514 }
1515}
1516
1517/// What it can do; what is left out, it cannot.
1518#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1519#[serde(rename_all = "camelCase")]
1520pub struct CapsDecl {
1521 #[serde(default)]
1522 pub modes: Vec<ModeDecl>,
1523 #[serde(default)]
1524 pub effort: bool,
1525 #[serde(default)]
1526 pub images: bool,
1527 #[serde(default)]
1528 pub pdf: bool,
1529 #[serde(default)]
1530 pub slash_commands: bool,
1531 #[serde(default)]
1532 pub terminal_view: bool,
1533 /// It holds a read-only launch (a discussion of the Docs tab): a security guarantee the core checks on the
1534 /// description of each launch.
1535 #[serde(default)]
1536 pub read_only: bool,
1537 /// Its accounts go by an API key of their own.
1538 #[serde(default)]
1539 pub accounts_by_key: bool,
1540}
1541
1542#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1543pub struct ModelDecl {
1544 pub id: String,
1545 #[serde(default)]
1546 pub label: Option<String>,
1547 #[serde(default)]
1548 pub effort: bool,
1549}
1550
1551#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1552pub struct MemoryDecl {
1553 pub dir: String,
1554}
1555
1556#[cfg(test)]
1557mod tests {
1558 use super::*;
1559 use serde_json::json;
1560
1561 #[test]
1562 fn an_item_of_an_unknown_op_is_left_aside_and_the_known_ones_read() {
1563 let items: Items = serde_json::from_value(json!({ "items": [
1564 { "op": "text", "text": "Bonjour" },
1565 { "op": "later", "what": 1 },
1566 { "op": "turn", "usage": { "input": 3 } },
1567 ] }))
1568 .unwrap();
1569 assert_eq!(
1570 items.items[0],
1571 Item::Text {
1572 text: "Bonjour".into(),
1573 parent: None,
1574 id: None,
1575 }
1576 );
1577 assert_eq!(items.items[1], Item::Unknown);
1578 assert!(matches!(items.items[2], Item::Turn { usage, .. } if usage.input == 3));
1579 }
1580
1581 #[test]
1582 fn a_grant_and_a_variable_never_print_their_secret() {
1583 let g = Grant {
1584 url: "http://127.0.0.1:1/mcp".into(),
1585 token: "secret".into(),
1586 };
1587 let v = EnvVar {
1588 name: "TOKEN".into(),
1589 value: "secret".into(),
1590 };
1591 assert!(!format!("{g:?}{v:?}").contains("secret"));
1592 }
1593
1594 #[test]
1595 fn a_declaration_takes_its_defaults() {
1596 let d: HarnessDecl = serde_json::from_value(json!({ "id": "x", "name": "X" })).unwrap();
1597 assert_eq!(d.version_args, ["--version"]);
1598 assert_eq!(d.tested.oldest, "0");
1599 assert!(!d.caps.read_only && d.models.is_empty());
1600 assert!(d.acp.is_none() && d.install.is_none());
1601 }
1602
1603 #[test]
1604 fn an_acp_harness_reads_with_its_modes_by_id_or_with_their_meaning() {
1605 let d: HarnessDecl = serde_json::from_value(json!({
1606 "id": "goose", "name": "Goose",
1607 "caps": { "modes": ["default", { "id": "bypassPermissions", "meaning": { "fr": "Tout", "en": "All" } }] },
1608 "acp": { "args": ["acp"], "modes": { "bypassPermissions": "auto" } },
1609 "install": { "unix": "curl …" },
1610 }))
1611 .unwrap();
1612 assert_eq!(d.caps.modes[0], ModeDecl::from("default"));
1613 assert_eq!(d.caps.modes[1].meaning.as_ref().unwrap().get("fr"), "Tout");
1614 assert_eq!(d.caps.modes[1].meaning.as_ref().unwrap().get("de"), "All");
1615 let acp = d.acp.unwrap();
1616 assert!(acp.fs, "the files go through the client unless it says not");
1617 assert_eq!(acp.args, ["acp"]);
1618 assert_eq!(acp.modes["bypassPermissions"], "auto");
1619 // Items of the contract it adds read too, and the old ones still.
1620 let items: Items = serde_json::from_value(json!({ "items": [
1621 { "op": "toolInput", "id": "c", "input": { "file_path": "a" } },
1622 { "op": "permission", "request": "r", "call": "c", "canAlways": true, "diff": { "path": "a", "newText": "x" } },
1623 { "op": "permission", "request": "r2", "call": "c2" },
1624 { "op": "todos", "todos": [] },
1625 { "op": "turn", "error": "refusé" },
1626 ] }))
1627 .unwrap();
1628 assert!(
1629 matches!(&items.items[1], Item::Permission { can_always: true, diff: Some(d), .. } if d.old_text.is_none())
1630 );
1631 assert!(matches!(&items.items[4], Item::Turn { error: Some(e), .. } if e == "refusé"));
1632 // A limit without its reset goes and comes back as one, none as none.
1633 for limited in [None, Some(None), Some(Some(5))] {
1634 let turn = Item::Turn {
1635 interrupted: false,
1636 limited,
1637 usage: TurnUsage::default(),
1638 error: None,
1639 entry: None,
1640 };
1641 let back: Item = serde_json::from_value(serde_json::to_value(&turn).unwrap()).unwrap();
1642 assert_eq!(back, turn);
1643 }
1644 }
1645}