Skip to main content

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