@team-agent/installer 0.5.112 ā 0.5.114
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +252 -84
- package/Cargo.toml +3 -1
- package/crates/team-agent/Cargo.toml +3 -0
- package/crates/team-agent/src/cli/adapters.rs +119 -50
- package/crates/team-agent/src/cli/diagnose.rs +36 -13
- package/crates/team-agent/src/cli/emit.rs +173 -109
- package/crates/team-agent/src/cli/leader.rs +16 -0
- package/crates/team-agent/src/cli/leader_prompt.rs +261 -0
- package/crates/team-agent/src/cli/leaders.rs +32 -32
- package/crates/team-agent/src/cli/mod.rs +158 -14
- package/crates/team-agent/src/cli/models.rs +68 -18
- package/crates/team-agent/src/cli/named_address.rs +17 -16
- package/crates/team-agent/src/cli/route.rs +4 -4
- package/crates/team-agent/src/cli/send/presentation.rs +19 -18
- package/crates/team-agent/src/cli/send/resolve.rs +11 -11
- package/crates/team-agent/src/cli/send.rs +4 -4
- package/crates/team-agent/src/cli/spec.rs +35 -29
- package/crates/team-agent/src/cli/status.rs +7 -7
- package/crates/team-agent/src/cli/status_port/brief.rs +268 -101
- package/crates/team-agent/src/cli/tests/base.rs +94 -6
- package/crates/team-agent/src/cli/tests/divergence.rs +3 -2
- package/crates/team-agent/src/cli/tests/lane_c.rs +1 -1
- package/crates/team-agent/src/cli/tests/named_address.rs +3 -3
- package/crates/team-agent/src/cli/tests/status_send.rs +9 -7
- package/crates/team-agent/src/cli/triage.rs +53 -2
- package/crates/team-agent/src/cli/types.rs +8 -8
- package/crates/team-agent/src/compiler/tests.rs +12 -0
- package/crates/team-agent/src/compiler.rs +65 -26
- package/crates/team-agent/src/contract_runtime/backend.rs +646 -0
- package/crates/team-agent/src/contract_runtime/config.rs +309 -0
- package/crates/team-agent/src/contract_runtime/discovery.rs +237 -0
- package/crates/team-agent/src/contract_runtime/forward.rs +229 -0
- package/crates/team-agent/src/contract_runtime/framework.rs +261 -0
- package/crates/team-agent/src/contract_runtime/mcp.rs +396 -0
- package/crates/team-agent/src/contract_runtime/mod.rs +14 -0
- package/crates/team-agent/src/contract_runtime/prompt.rs +216 -0
- package/crates/team-agent/src/contract_runtime/pump.rs +207 -0
- package/crates/team-agent/src/contract_runtime/registry.rs +30 -0
- package/crates/team-agent/src/coordinator/tick.rs +26 -8
- package/crates/team-agent/src/leader/mod.rs +1 -0
- package/crates/team-agent/src/leader/prompt.rs +906 -0
- package/crates/team-agent/src/leader/start.rs +265 -16
- package/crates/team-agent/src/leader/tests/identity.rs +21 -0
- package/crates/team-agent/src/leader/tests/mod.rs +2 -0
- package/crates/team-agent/src/leader/tests/prompt_contract.rs +268 -0
- package/crates/team-agent/src/leader/types.rs +6 -0
- package/crates/team-agent/src/lib.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/identity.rs +172 -13
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +760 -365
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +38 -9
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +17 -0
- package/crates/team-agent/src/lifecycle/launch.rs +39 -28
- package/crates/team-agent/src/lifecycle/profile_launch.rs +58 -46
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +97 -52
- package/crates/team-agent/src/lifecycle/restart.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/stale_team_saveconflict_contract.rs +3 -2
- package/crates/team-agent/src/lifecycle/types.rs +4 -1
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +37 -27
- package/crates/team-agent/src/main.rs +6 -0
- package/crates/team-agent/src/messaging/delivery.rs +50 -1
- package/crates/team-agent/src/messaging/send.rs +10 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +131 -0
- package/crates/team-agent/src/model/spec.rs +24 -12
- package/crates/team-agent/src/provider/adapter.rs +8 -59
- package/crates/team-agent/src/provider/adapters/claude.rs +6 -14
- package/crates/team-agent/src/provider/adapters/codex.rs +8 -18
- package/crates/team-agent/src/provider/adapters/copilot.rs +5 -22
- package/crates/team-agent/src/provider/adapters/cursor_agent.rs +8 -45
- package/crates/team-agent/src/provider/adapters/grok.rs +7 -31
- package/crates/team-agent/src/provider/adapters/pi.rs +7 -18
- package/crates/team-agent/src/provider/command_helpers.rs +45 -0
- package/crates/team-agent/src/provider/mod.rs +1 -0
- package/crates/team-agent/src/provider/model_catalog.rs +21 -28
- package/crates/team-agent/src/provider/tests/adapter.rs +70 -0
- package/crates/team-agent/src/provider/tests/command_helpers.rs +55 -0
- package/crates/team-agent/src/state/persist.rs +73 -0
- package/package.json +4 -4
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
//! Host-global supplemental text, read only by new Leader launch plans.
|
|
2
|
+
//! Native argv adaptation is pure; no Worker path reads this configuration.
|
|
3
|
+
|
|
4
|
+
use crate::provider::{wire::provider_wire, Provider};
|
|
5
|
+
use serde::Serialize;
|
|
6
|
+
use serde_json::{json, Value};
|
|
7
|
+
use std::fmt;
|
|
8
|
+
use std::fs::{self, File, OpenOptions};
|
|
9
|
+
use std::io::{self, Read, Write};
|
|
10
|
+
use std::path::{Path, PathBuf};
|
|
11
|
+
use std::time::{Duration, Instant};
|
|
12
|
+
|
|
13
|
+
#[derive(Debug, Clone, Serialize)]
|
|
14
|
+
pub struct PromptError {
|
|
15
|
+
pub reason: &'static str,
|
|
16
|
+
pub error: String,
|
|
17
|
+
pub config_path: Option<PathBuf>,
|
|
18
|
+
pub io_path: Option<PathBuf>,
|
|
19
|
+
pub io_kind: Option<String>,
|
|
20
|
+
pub provider: Option<&'static str>,
|
|
21
|
+
pub carrier: Option<&'static str>,
|
|
22
|
+
pub exit_code: Option<i32>,
|
|
23
|
+
pub action: &'static str,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
impl PromptError {
|
|
27
|
+
pub(crate) fn new(reason: &'static str, error: &str, path: Option<&Path>) -> Self {
|
|
28
|
+
Self {
|
|
29
|
+
reason, error: error.to_string(), config_path: path.map(Path::to_path_buf),
|
|
30
|
+
io_path: None, io_kind: None, provider: None, carrier: None, exit_code: None,
|
|
31
|
+
action: "Inspect the reported path; use team-agent leader-prompt --help to set, append or clear the text",
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
pub(crate) fn io(reason: &'static str, path: &Path, error: io::Error) -> Self {
|
|
35
|
+
let mut result = Self::new(reason, "Leader prompt I/O failed", Some(path));
|
|
36
|
+
result.io_path = Some(path.to_path_buf());
|
|
37
|
+
result.io_kind = Some(format!("{:?}", error.kind()));
|
|
38
|
+
result
|
|
39
|
+
}
|
|
40
|
+
pub(crate) fn at_config(mut self, path: &Path) -> Self {
|
|
41
|
+
if self.io_path.is_none() && self.config_path.as_deref() != Some(path) {
|
|
42
|
+
self.io_path = self.config_path.clone();
|
|
43
|
+
}
|
|
44
|
+
self.config_path = Some(path.to_path_buf());
|
|
45
|
+
self
|
|
46
|
+
}
|
|
47
|
+
pub(crate) fn for_provider(mut self, provider: Provider) -> Self {
|
|
48
|
+
self.provider = Some(provider_wire(provider));
|
|
49
|
+
self.carrier = Some(carrier(provider));
|
|
50
|
+
self
|
|
51
|
+
}
|
|
52
|
+
pub(crate) fn report(&self) -> Value {
|
|
53
|
+
json!({"ok": false, "reason": self.reason, "error": self.error,
|
|
54
|
+
"config_path": self.config_path, "io_path": self.io_path,
|
|
55
|
+
"io_kind": self.io_kind, "provider": self.provider, "carrier": self.carrier,
|
|
56
|
+
"exit_code": self.exit_code, "action": self.action})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
impl fmt::Display for PromptError {
|
|
60
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
61
|
+
write!(f, "{}: {}; provider={:?}; carrier={:?}; config={:?}; io_path={:?}; io_kind={:?}; exit_code={:?}; action: {}",
|
|
62
|
+
self.reason, self.error, self.provider, self.carrier, self.config_path, self.io_path, self.io_kind, self.exit_code, self.action)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
impl std::error::Error for PromptError {}
|
|
66
|
+
|
|
67
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
68
|
+
pub struct PromptMetadata {
|
|
69
|
+
pub config_path: PathBuf,
|
|
70
|
+
pub carrier: &'static str,
|
|
71
|
+
pub bytes: usize,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pub(crate) struct StoredPrompt {
|
|
75
|
+
pub path: PathBuf,
|
|
76
|
+
pub text: String,
|
|
77
|
+
}
|
|
78
|
+
impl StoredPrompt {
|
|
79
|
+
pub(crate) fn metadata(&self, provider: Provider) -> PromptMetadata {
|
|
80
|
+
PromptMetadata {
|
|
81
|
+
config_path: self.path.clone(),
|
|
82
|
+
bytes: self.text.len(),
|
|
83
|
+
carrier: carrier(provider),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
fn carrier(provider: Provider) -> &'static str {
|
|
89
|
+
match provider {
|
|
90
|
+
Provider::Pi | Provider::Claude | Provider::ClaudeCode => "--append-system-prompt",
|
|
91
|
+
Provider::Codex => "developer_instructions",
|
|
92
|
+
Provider::Grok => "--rules",
|
|
93
|
+
_ => "unsupported",
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fn path_from_home(home: Option<std::ffi::OsString>) -> Result<PathBuf, PromptError> {
|
|
98
|
+
home.map(PathBuf::from)
|
|
99
|
+
.filter(|path| path.is_absolute())
|
|
100
|
+
.map(|path| path.join(".team-agent/leader-prompt.txt"))
|
|
101
|
+
.ok_or_else(|| {
|
|
102
|
+
PromptError::new(
|
|
103
|
+
"leader_prompt_home_unavailable",
|
|
104
|
+
"An absolute host HOME is required",
|
|
105
|
+
None,
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
pub(crate) fn config_path() -> Result<PathBuf, PromptError> {
|
|
110
|
+
let home = std::env::var_os("HOME");
|
|
111
|
+
#[cfg(windows)]
|
|
112
|
+
let home = home
|
|
113
|
+
.filter(|raw| Path::new(raw).is_absolute())
|
|
114
|
+
.or_else(|| std::env::var_os("USERPROFILE"));
|
|
115
|
+
path_from_home(home)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fn regular_target(path: &Path, reason: &'static str) -> Result<bool, PromptError> {
|
|
119
|
+
match fs::symlink_metadata(path) {
|
|
120
|
+
Ok(metadata) if metadata.file_type().is_file() => Ok(true),
|
|
121
|
+
Ok(_) => Err(PromptError::new(
|
|
122
|
+
reason,
|
|
123
|
+
"Expected a regular file, not a symlink or special file",
|
|
124
|
+
Some(path),
|
|
125
|
+
)),
|
|
126
|
+
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
|
|
127
|
+
Err(error) => Err(PromptError::io(reason, path, error)),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
fn private_options() -> OpenOptions {
|
|
131
|
+
let mut options = OpenOptions::new();
|
|
132
|
+
#[cfg(unix)]
|
|
133
|
+
{
|
|
134
|
+
use std::os::unix::fs::OpenOptionsExt;
|
|
135
|
+
options
|
|
136
|
+
.mode(0o600)
|
|
137
|
+
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
|
|
138
|
+
}
|
|
139
|
+
options
|
|
140
|
+
}
|
|
141
|
+
pub(crate) fn validate_input(text: &str, path: &Path) -> Result<(), PromptError> {
|
|
142
|
+
if text.is_empty() || text.contains('\0') {
|
|
143
|
+
return Err(PromptError::new(
|
|
144
|
+
"leader_prompt_invalid_input",
|
|
145
|
+
"Text must be nonempty UTF-8 without NUL; use clear to remove it",
|
|
146
|
+
Some(path),
|
|
147
|
+
));
|
|
148
|
+
}
|
|
149
|
+
Ok(())
|
|
150
|
+
}
|
|
151
|
+
pub(crate) fn read(path: &Path) -> Result<Option<String>, PromptError> {
|
|
152
|
+
if !regular_target(path, "leader_prompt_unreadable")? {
|
|
153
|
+
return Ok(None);
|
|
154
|
+
}
|
|
155
|
+
let mut file = match private_options().read(true).open(path) {
|
|
156
|
+
Ok(file) => file,
|
|
157
|
+
Err(error)
|
|
158
|
+
if error.kind() == io::ErrorKind::NotFound
|
|
159
|
+
&& !regular_target(path, "leader_prompt_unreadable")? =>
|
|
160
|
+
{
|
|
161
|
+
return Ok(None)
|
|
162
|
+
}
|
|
163
|
+
Err(error) => return Err(PromptError::io("leader_prompt_unreadable", path, error)),
|
|
164
|
+
};
|
|
165
|
+
if !file
|
|
166
|
+
.metadata()
|
|
167
|
+
.map_err(|error| PromptError::io("leader_prompt_unreadable", path, error))?
|
|
168
|
+
.is_file()
|
|
169
|
+
{
|
|
170
|
+
return Err(PromptError::new(
|
|
171
|
+
"leader_prompt_unreadable",
|
|
172
|
+
"Expected a regular prompt file",
|
|
173
|
+
Some(path),
|
|
174
|
+
));
|
|
175
|
+
}
|
|
176
|
+
let mut text = String::new();
|
|
177
|
+
file.read_to_string(&mut text)
|
|
178
|
+
.map_err(|error| PromptError::io("leader_prompt_unreadable", path, error))?;
|
|
179
|
+
if text.contains('\0') {
|
|
180
|
+
return Err(PromptError::new(
|
|
181
|
+
"leader_prompt_unreadable",
|
|
182
|
+
"Prompt file contains NUL; set or clear it",
|
|
183
|
+
Some(path),
|
|
184
|
+
));
|
|
185
|
+
}
|
|
186
|
+
Ok((!text.is_empty()).then_some(text))
|
|
187
|
+
}
|
|
188
|
+
/// Missing HOME retains historical launch behavior. A determined, bad file does not.
|
|
189
|
+
pub(crate) fn load() -> Result<Option<StoredPrompt>, PromptError> {
|
|
190
|
+
let path = match config_path() {
|
|
191
|
+
Ok(path) => path,
|
|
192
|
+
Err(_) => return Ok(None),
|
|
193
|
+
};
|
|
194
|
+
read(&path).map(|text| text.map(|text| StoredPrompt { path, text }))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
pub(crate) enum Mutation {
|
|
198
|
+
Set(String),
|
|
199
|
+
Append(String),
|
|
200
|
+
Clear,
|
|
201
|
+
}
|
|
202
|
+
struct Lock(File);
|
|
203
|
+
impl Drop for Lock {
|
|
204
|
+
fn drop(&mut self) {
|
|
205
|
+
let _ = crate::platform::file_lock::unlock(&self.0);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
fn acquire_lock(path: &Path) -> Result<Lock, PromptError> {
|
|
209
|
+
let parent = path.parent().ok_or_else(|| {
|
|
210
|
+
PromptError::new(
|
|
211
|
+
"leader_prompt_write_failed",
|
|
212
|
+
"Configuration has no parent directory",
|
|
213
|
+
Some(path),
|
|
214
|
+
)
|
|
215
|
+
})?;
|
|
216
|
+
let mut builder = fs::DirBuilder::new();
|
|
217
|
+
#[cfg(unix)]
|
|
218
|
+
{
|
|
219
|
+
use std::os::unix::fs::DirBuilderExt;
|
|
220
|
+
builder.mode(0o700);
|
|
221
|
+
}
|
|
222
|
+
match builder.create(parent) {
|
|
223
|
+
Ok(()) => {}
|
|
224
|
+
Err(error) if error.kind() == io::ErrorKind::AlreadyExists && parent.is_dir() => {}
|
|
225
|
+
Err(error) => {
|
|
226
|
+
return Err(PromptError::io("leader_prompt_write_failed", parent, error).at_config(path))
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
let lock_path = parent.join("leader-prompt.lock");
|
|
230
|
+
regular_target(&lock_path, "leader_prompt_write_failed")
|
|
231
|
+
.map_err(|error| error.at_config(path))?;
|
|
232
|
+
let file = private_options()
|
|
233
|
+
.read(true)
|
|
234
|
+
.write(true)
|
|
235
|
+
.create(true)
|
|
236
|
+
.truncate(false)
|
|
237
|
+
.open(&lock_path)
|
|
238
|
+
.map_err(|error| {
|
|
239
|
+
PromptError::io("leader_prompt_write_failed", &lock_path, error).at_config(path)
|
|
240
|
+
})?;
|
|
241
|
+
if !file
|
|
242
|
+
.metadata()
|
|
243
|
+
.map_err(|error| {
|
|
244
|
+
PromptError::io("leader_prompt_write_failed", &lock_path, error).at_config(path)
|
|
245
|
+
})?
|
|
246
|
+
.is_file()
|
|
247
|
+
{
|
|
248
|
+
return Err(PromptError::new(
|
|
249
|
+
"leader_prompt_write_failed",
|
|
250
|
+
"Expected a regular lock file",
|
|
251
|
+
Some(&lock_path),
|
|
252
|
+
)
|
|
253
|
+
.at_config(path));
|
|
254
|
+
}
|
|
255
|
+
let started = Instant::now();
|
|
256
|
+
loop {
|
|
257
|
+
match crate::platform::file_lock::try_lock_once_nonblocking(&file) {
|
|
258
|
+
Ok(true) => return Ok(Lock(file)),
|
|
259
|
+
Ok(false) if started.elapsed() < Duration::from_secs(5) => {
|
|
260
|
+
std::thread::sleep(Duration::from_millis(25))
|
|
261
|
+
}
|
|
262
|
+
Ok(false) => {
|
|
263
|
+
return Err(PromptError::new(
|
|
264
|
+
"leader_prompt_lock_timeout",
|
|
265
|
+
"Leader prompt write lock timed out",
|
|
266
|
+
Some(path),
|
|
267
|
+
))
|
|
268
|
+
}
|
|
269
|
+
Err(error) => {
|
|
270
|
+
return Err(
|
|
271
|
+
PromptError::io("leader_prompt_write_failed", &lock_path, error)
|
|
272
|
+
.at_config(path),
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
fn write_atomic(path: &Path, text: &str) -> Result<(), PromptError> {
|
|
279
|
+
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
280
|
+
let nanos = std::time::SystemTime::now()
|
|
281
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
282
|
+
.map_or(0, |time| time.as_nanos());
|
|
283
|
+
let id = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
284
|
+
let temp = path.with_file_name(format!(
|
|
285
|
+
".leader-prompt-{}-{nanos}-{id}.tmp",
|
|
286
|
+
std::process::id()
|
|
287
|
+
));
|
|
288
|
+
let mut file = private_options()
|
|
289
|
+
.write(true)
|
|
290
|
+
.create_new(true)
|
|
291
|
+
.open(&temp)
|
|
292
|
+
.map_err(|error| {
|
|
293
|
+
PromptError::io("leader_prompt_write_failed", &temp, error).at_config(path)
|
|
294
|
+
})?;
|
|
295
|
+
let result = (|| {
|
|
296
|
+
file.write_all(text.as_bytes())
|
|
297
|
+
.and_then(|_| file.flush())
|
|
298
|
+
.and_then(|_| file.sync_all())
|
|
299
|
+
.map_err(|error| {
|
|
300
|
+
PromptError::io("leader_prompt_write_failed", &temp, error).at_config(path)
|
|
301
|
+
})?;
|
|
302
|
+
drop(file);
|
|
303
|
+
// Rename replaces this directory entry; it never follows a target symlink.
|
|
304
|
+
regular_target(path, "leader_prompt_write_failed")?;
|
|
305
|
+
fs::rename(&temp, path)
|
|
306
|
+
.map_err(|error| PromptError::io("leader_prompt_write_failed", path, error))
|
|
307
|
+
})();
|
|
308
|
+
if result.is_err() {
|
|
309
|
+
let _ = fs::remove_file(&temp);
|
|
310
|
+
}
|
|
311
|
+
result
|
|
312
|
+
}
|
|
313
|
+
pub(crate) fn concatenate(old: &str, input: &str) -> String {
|
|
314
|
+
if old.is_empty() {
|
|
315
|
+
input.to_string()
|
|
316
|
+
} else {
|
|
317
|
+
format!("{old}\n\n{input}")
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
pub(crate) fn mutate(path: &Path, mutation: Mutation) -> Result<bool, PromptError> {
|
|
321
|
+
if let Mutation::Set(text) | Mutation::Append(text) = &mutation {
|
|
322
|
+
validate_input(text, path)?;
|
|
323
|
+
}
|
|
324
|
+
let present = regular_target(path, "leader_prompt_write_failed")?;
|
|
325
|
+
if !present && matches!(mutation, Mutation::Clear) {
|
|
326
|
+
return Ok(false);
|
|
327
|
+
}
|
|
328
|
+
// A bad append source does not create lock apparatus; set/clear can repair bytes.
|
|
329
|
+
if matches!(mutation, Mutation::Append(_)) {
|
|
330
|
+
read(path)?;
|
|
331
|
+
}
|
|
332
|
+
let _lock = acquire_lock(path)?;
|
|
333
|
+
regular_target(path, "leader_prompt_write_failed")?;
|
|
334
|
+
match mutation {
|
|
335
|
+
Mutation::Set(text) => write_atomic(path, &text)?,
|
|
336
|
+
Mutation::Append(text) => write_atomic(
|
|
337
|
+
path,
|
|
338
|
+
&concatenate(read(path)?.as_deref().unwrap_or(""), &text),
|
|
339
|
+
)?,
|
|
340
|
+
Mutation::Clear => match fs::remove_file(path) {
|
|
341
|
+
Ok(()) => return Ok(false),
|
|
342
|
+
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
|
|
343
|
+
Err(error) => return Err(PromptError::io("leader_prompt_write_failed", path, error)),
|
|
344
|
+
},
|
|
345
|
+
}
|
|
346
|
+
Ok(true)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
fn conflict(provider: Provider, path: &Path) -> PromptError {
|
|
350
|
+
let mut error = PromptError::new(
|
|
351
|
+
"leader_prompt_conflict",
|
|
352
|
+
"Ambiguous or unsupported native supplemental prompt argument",
|
|
353
|
+
Some(path),
|
|
354
|
+
)
|
|
355
|
+
.for_provider(provider);
|
|
356
|
+
error.action = "Move the supplemental text into team-agent leader-prompt and remove conflicting native arguments";
|
|
357
|
+
error
|
|
358
|
+
}
|
|
359
|
+
/// Returns the value token and whether the flag/value share that token.
|
|
360
|
+
fn inline_slot(
|
|
361
|
+
argv: &[String],
|
|
362
|
+
flag: &str,
|
|
363
|
+
provider: Provider,
|
|
364
|
+
path: &Path,
|
|
365
|
+
) -> Result<Option<(usize, bool)>, PromptError> {
|
|
366
|
+
let end = argv
|
|
367
|
+
.iter()
|
|
368
|
+
.position(|arg| arg == "--")
|
|
369
|
+
.unwrap_or(argv.len());
|
|
370
|
+
let mut slot = None;
|
|
371
|
+
let mut i = 1;
|
|
372
|
+
while i < end {
|
|
373
|
+
let token = &argv[i];
|
|
374
|
+
// Claude's core prompt fields own the following literal token. It is
|
|
375
|
+
// not a sibling flag even when the text begins with our append flag.
|
|
376
|
+
if matches!(provider, Provider::Claude | Provider::ClaudeCode)
|
|
377
|
+
&& matches!(token.as_str(), "--system-prompt" | "--system-prompt-file")
|
|
378
|
+
{
|
|
379
|
+
if i + 1 >= end {
|
|
380
|
+
return Err(conflict(provider, path));
|
|
381
|
+
}
|
|
382
|
+
i += 2;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
let candidate = if token == flag {
|
|
386
|
+
if i + 1 >= end || argv[i + 1].starts_with(flag) {
|
|
387
|
+
return Err(conflict(provider, path));
|
|
388
|
+
}
|
|
389
|
+
i += 1;
|
|
390
|
+
Some((i, false))
|
|
391
|
+
} else if token.starts_with(&format!("{flag}=")) {
|
|
392
|
+
Some((i, true))
|
|
393
|
+
} else if token.starts_with(&format!("{flag}-")) {
|
|
394
|
+
return Err(conflict(provider, path));
|
|
395
|
+
} else {
|
|
396
|
+
None
|
|
397
|
+
};
|
|
398
|
+
if let Some(candidate) = candidate {
|
|
399
|
+
if slot.replace(candidate).is_some() {
|
|
400
|
+
return Err(conflict(provider, path));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
i += 1;
|
|
404
|
+
}
|
|
405
|
+
Ok(slot)
|
|
406
|
+
}
|
|
407
|
+
/// Validate routed Pi options before materializing any MCP files. The existing
|
|
408
|
+
/// Leader contract stays first, so arbitrary supplemental text is not a file carrier.
|
|
409
|
+
pub(crate) fn compose_pi_contract(
|
|
410
|
+
contract: &str,
|
|
411
|
+
routed: &[String],
|
|
412
|
+
text: &str,
|
|
413
|
+
path: &Path,
|
|
414
|
+
) -> Result<String, PromptError> {
|
|
415
|
+
let mut preview = routed.to_vec();
|
|
416
|
+
preview.extend(["--append-system-prompt".to_string(), contract.to_string()]);
|
|
417
|
+
let index = preview.len() - 1;
|
|
418
|
+
if inline_slot(&preview, "--append-system-prompt", Provider::Pi, path)? != Some((index, false))
|
|
419
|
+
{
|
|
420
|
+
return Err(conflict(Provider::Pi, path));
|
|
421
|
+
}
|
|
422
|
+
inject(Provider::Pi, &mut preview, text, path)?;
|
|
423
|
+
Ok(preview[index].clone())
|
|
424
|
+
}
|
|
425
|
+
pub(crate) fn inject(
|
|
426
|
+
provider: Provider,
|
|
427
|
+
argv: &mut Vec<String>,
|
|
428
|
+
text: &str,
|
|
429
|
+
path: &Path,
|
|
430
|
+
) -> Result<(), PromptError> {
|
|
431
|
+
validate_input(text, path).map_err(|error| error.for_provider(provider))?;
|
|
432
|
+
if provider == Provider::Codex {
|
|
433
|
+
return inject_codex(argv, text, path);
|
|
434
|
+
}
|
|
435
|
+
let flag = match provider {
|
|
436
|
+
Provider::Pi | Provider::Claude | Provider::ClaudeCode => "--append-system-prompt",
|
|
437
|
+
Provider::Grok => "--rules",
|
|
438
|
+
_ => {
|
|
439
|
+
let mut error = PromptError::new(
|
|
440
|
+
"leader_prompt_unsupported",
|
|
441
|
+
"This Leader provider has no admitted process-local append carrier",
|
|
442
|
+
Some(path),
|
|
443
|
+
)
|
|
444
|
+
.for_provider(provider);
|
|
445
|
+
error.action = "Use pi, claude, codex or grok, or clear leader-prompt; Cursor requires native private-plugin isolation evidence";
|
|
446
|
+
return Err(error);
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
if let Some((index, joined)) = inline_slot(argv, flag, provider, path)? {
|
|
450
|
+
let old = if joined {
|
|
451
|
+
&argv[index][flag.len() + 1..]
|
|
452
|
+
} else {
|
|
453
|
+
&argv[index]
|
|
454
|
+
};
|
|
455
|
+
let combined = concatenate(old, text);
|
|
456
|
+
argv[index] = if joined {
|
|
457
|
+
format!("{flag}={combined}")
|
|
458
|
+
} else {
|
|
459
|
+
combined
|
|
460
|
+
};
|
|
461
|
+
} else if argv.is_empty() {
|
|
462
|
+
return Err(conflict(provider, path));
|
|
463
|
+
} else {
|
|
464
|
+
argv.splice(1..1, [flag.to_string(), text.to_string()]);
|
|
465
|
+
}
|
|
466
|
+
Ok(())
|
|
467
|
+
}
|
|
468
|
+
fn inject_codex(argv: &mut Vec<String>, text: &str, path: &Path) -> Result<(), PromptError> {
|
|
469
|
+
let provider = Provider::Codex;
|
|
470
|
+
let end = argv
|
|
471
|
+
.iter()
|
|
472
|
+
.position(|arg| arg == "--")
|
|
473
|
+
.unwrap_or(argv.len());
|
|
474
|
+
let mut slot = None;
|
|
475
|
+
let mut i = 1;
|
|
476
|
+
while i < end {
|
|
477
|
+
let token = &argv[i];
|
|
478
|
+
let (index, value, joined) = if matches!(token.as_str(), "-c" | "--config") {
|
|
479
|
+
if i + 1 >= end {
|
|
480
|
+
return Err(conflict(provider, path));
|
|
481
|
+
}
|
|
482
|
+
i += 1;
|
|
483
|
+
(i, argv[i].as_str(), false)
|
|
484
|
+
} else if let Some(value) = token.strip_prefix("--config=") {
|
|
485
|
+
(i, value, true)
|
|
486
|
+
} else if token.strip_prefix("-c").is_some_and(|value| {
|
|
487
|
+
let key = value
|
|
488
|
+
.trim_start_matches('=')
|
|
489
|
+
.split('=')
|
|
490
|
+
.next()
|
|
491
|
+
.unwrap_or("")
|
|
492
|
+
.trim();
|
|
493
|
+
matches!(
|
|
494
|
+
key,
|
|
495
|
+
"developer_instructions"
|
|
496
|
+
| "\"developer_instructions\""
|
|
497
|
+
| "'developer_instructions'"
|
|
498
|
+
)
|
|
499
|
+
}) {
|
|
500
|
+
return Err(conflict(provider, path));
|
|
501
|
+
} else {
|
|
502
|
+
i += 1;
|
|
503
|
+
continue;
|
|
504
|
+
};
|
|
505
|
+
let (key, rhs) = value.split_once('=').unwrap_or((value, ""));
|
|
506
|
+
if matches!(
|
|
507
|
+
key.trim(),
|
|
508
|
+
"\"developer_instructions\"" | "'developer_instructions'"
|
|
509
|
+
) {
|
|
510
|
+
return Err(conflict(provider, path));
|
|
511
|
+
}
|
|
512
|
+
if key.trim() == "developer_instructions" {
|
|
513
|
+
let old: String = serde_json::from_str(rhs).map_err(|_| conflict(provider, path))?;
|
|
514
|
+
if old.contains('\0') || slot.replace((index, joined, old)).is_some() {
|
|
515
|
+
return Err(conflict(provider, path));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
i += 1;
|
|
519
|
+
}
|
|
520
|
+
let (old, index) = match slot {
|
|
521
|
+
Some((index, joined, old)) => (old, Some((index, joined))),
|
|
522
|
+
None => (String::new(), None),
|
|
523
|
+
};
|
|
524
|
+
let combined = concatenate(&old, text);
|
|
525
|
+
let encoded = serde_json::to_string(&combined).map_err(|_| conflict(provider, path))?;
|
|
526
|
+
let value = format!("developer_instructions={encoded}");
|
|
527
|
+
if let Some((index, joined)) = index {
|
|
528
|
+
argv[index] = if joined {
|
|
529
|
+
format!("--config={value}")
|
|
530
|
+
} else {
|
|
531
|
+
value
|
|
532
|
+
};
|
|
533
|
+
} else if argv.is_empty() {
|
|
534
|
+
return Err(conflict(provider, path));
|
|
535
|
+
} else {
|
|
536
|
+
argv.splice(1..1, ["-c".to_string(), value]);
|
|
537
|
+
}
|
|
538
|
+
Ok(())
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
#[cfg(test)]
|
|
542
|
+
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
543
|
+
mod tests {
|
|
544
|
+
use super::*;
|
|
545
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
546
|
+
static ID: AtomicU64 = AtomicU64::new(0);
|
|
547
|
+
struct Fixture(PathBuf);
|
|
548
|
+
impl Fixture {
|
|
549
|
+
fn new() -> Self {
|
|
550
|
+
let path = std::env::temp_dir().join(format!(
|
|
551
|
+
"ta-leader-prompt-{}-{}",
|
|
552
|
+
std::process::id(),
|
|
553
|
+
ID.fetch_add(1, Ordering::Relaxed)
|
|
554
|
+
));
|
|
555
|
+
fs::create_dir(&path).unwrap();
|
|
556
|
+
Self(path)
|
|
557
|
+
}
|
|
558
|
+
fn path(&self) -> PathBuf {
|
|
559
|
+
self.0.join(".team-agent/leader-prompt.txt")
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
impl Drop for Fixture {
|
|
563
|
+
fn drop(&mut self) {
|
|
564
|
+
let _ = fs::remove_dir_all(&self.0);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
fn args(tokens: &[&str]) -> Vec<String> {
|
|
568
|
+
tokens.iter().map(|text| text.to_string()).collect()
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
#[test]
|
|
572
|
+
fn leader_prompt_storage_bytes_clear_and_repair() {
|
|
573
|
+
let f = Fixture::new();
|
|
574
|
+
let path = f.path();
|
|
575
|
+
assert!(!mutate(&path, Mutation::Clear).unwrap());
|
|
576
|
+
assert!(!path.parent().unwrap().exists());
|
|
577
|
+
let text = " rulesš\r\nquote\"\\\t--help\n";
|
|
578
|
+
mutate(&path, Mutation::Set(text.into())).unwrap();
|
|
579
|
+
assert_eq!(read(&path).unwrap().as_deref(), Some(text));
|
|
580
|
+
mutate(&path, Mutation::Append(" \n ".into())).unwrap();
|
|
581
|
+
assert_eq!(
|
|
582
|
+
fs::read(&path).unwrap(),
|
|
583
|
+
format!("{text}\n\n \n ").as_bytes()
|
|
584
|
+
);
|
|
585
|
+
let old = fs::read(&path).unwrap();
|
|
586
|
+
for input in ["", "bad\0text"] {
|
|
587
|
+
assert!(mutate(&path, Mutation::Set(input.into())).is_err());
|
|
588
|
+
assert_eq!(fs::read(&path).unwrap(), old);
|
|
589
|
+
}
|
|
590
|
+
fs::write(&path, [0xff]).unwrap();
|
|
591
|
+
assert_eq!(read(&path).unwrap_err().reason, "leader_prompt_unreadable");
|
|
592
|
+
assert!(mutate(&path, Mutation::Append("new".into())).is_err());
|
|
593
|
+
assert_eq!(fs::read(&path).unwrap(), [0xff]);
|
|
594
|
+
mutate(&path, Mutation::Set("repaired".into())).unwrap();
|
|
595
|
+
fs::write(&path, b"nul\0").unwrap();
|
|
596
|
+
assert!(read(&path).is_err());
|
|
597
|
+
mutate(&path, Mutation::Clear).unwrap();
|
|
598
|
+
fs::write(&path, []).unwrap();
|
|
599
|
+
assert!(read(&path).unwrap().is_none());
|
|
600
|
+
}
|
|
601
|
+
#[test]
|
|
602
|
+
fn leader_prompt_concurrent_appends_do_not_lose_updates() {
|
|
603
|
+
let f = Fixture::new();
|
|
604
|
+
let path = f.path();
|
|
605
|
+
let threads: Vec<_> = (0..12)
|
|
606
|
+
.map(|id| {
|
|
607
|
+
let path = path.clone();
|
|
608
|
+
std::thread::spawn(move || {
|
|
609
|
+
mutate(&path, Mutation::Append(format!("entry-{id}"))).unwrap()
|
|
610
|
+
})
|
|
611
|
+
})
|
|
612
|
+
.collect();
|
|
613
|
+
for thread in threads {
|
|
614
|
+
thread.join().unwrap();
|
|
615
|
+
}
|
|
616
|
+
let text = read(&path).unwrap().unwrap();
|
|
617
|
+
let entries: std::collections::BTreeSet<String> =
|
|
618
|
+
text.split("\n\n").map(str::to_string).collect();
|
|
619
|
+
assert_eq!(entries, (0..12).map(|id| format!("entry-{id}")).collect());
|
|
620
|
+
assert_eq!(text.split("\n\n").count(), 12);
|
|
621
|
+
}
|
|
622
|
+
#[test]
|
|
623
|
+
fn leader_prompt_temp_io_error_identifies_the_actual_operation_path() {
|
|
624
|
+
let f = Fixture::new();
|
|
625
|
+
let path = f.0.join("missing-parent/leader-prompt.txt");
|
|
626
|
+
let error = write_atomic(&path, "new").unwrap_err();
|
|
627
|
+
assert_eq!(error.config_path.as_deref(), Some(path.as_path()));
|
|
628
|
+
assert_eq!(error.io_kind.as_deref(), Some("NotFound"));
|
|
629
|
+
let actual = error.io_path.unwrap();
|
|
630
|
+
assert_eq!(actual.parent(), path.parent());
|
|
631
|
+
assert!(actual
|
|
632
|
+
.file_name()
|
|
633
|
+
.unwrap()
|
|
634
|
+
.to_string_lossy()
|
|
635
|
+
.starts_with(".leader-prompt-"));
|
|
636
|
+
assert_ne!(actual, path);
|
|
637
|
+
}
|
|
638
|
+
#[test]
|
|
639
|
+
fn leader_prompt_bounded_lock_timeout_preserves_body_and_foreign_temp() {
|
|
640
|
+
let f = Fixture::new();
|
|
641
|
+
let path = f.path();
|
|
642
|
+
mutate(&path, Mutation::Set("old".into())).unwrap();
|
|
643
|
+
let foreign = path.parent().unwrap().join(".leader-prompt-foreign.tmp");
|
|
644
|
+
fs::write(&foreign, "foreign").unwrap();
|
|
645
|
+
let _held = acquire_lock(&path).unwrap();
|
|
646
|
+
let started = Instant::now();
|
|
647
|
+
assert_eq!(
|
|
648
|
+
mutate(&path, Mutation::Append("new".into()))
|
|
649
|
+
.unwrap_err()
|
|
650
|
+
.reason,
|
|
651
|
+
"leader_prompt_lock_timeout"
|
|
652
|
+
);
|
|
653
|
+
assert!(started.elapsed() >= Duration::from_secs(5));
|
|
654
|
+
assert!(started.elapsed() < Duration::from_secs(10));
|
|
655
|
+
assert_eq!(fs::read_to_string(path).unwrap(), "old");
|
|
656
|
+
assert_eq!(fs::read_to_string(foreign).unwrap(), "foreign");
|
|
657
|
+
}
|
|
658
|
+
#[cfg(unix)]
|
|
659
|
+
#[test]
|
|
660
|
+
fn leader_prompt_private_modes_and_symlink_refusal() {
|
|
661
|
+
use std::os::unix::fs::{symlink, PermissionsExt};
|
|
662
|
+
let f = Fixture::new();
|
|
663
|
+
let path = f.path();
|
|
664
|
+
mutate(&path, Mutation::Set("private".into())).unwrap();
|
|
665
|
+
assert_eq!(
|
|
666
|
+
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
|
667
|
+
0o600
|
|
668
|
+
);
|
|
669
|
+
assert_eq!(
|
|
670
|
+
fs::metadata(path.parent().unwrap())
|
|
671
|
+
.unwrap()
|
|
672
|
+
.permissions()
|
|
673
|
+
.mode()
|
|
674
|
+
& 0o777,
|
|
675
|
+
0o700
|
|
676
|
+
);
|
|
677
|
+
let target = f.0.join("foreign");
|
|
678
|
+
fs::write(&target, "unchanged").unwrap();
|
|
679
|
+
fs::remove_file(&path).unwrap();
|
|
680
|
+
symlink(&target, &path).unwrap();
|
|
681
|
+
assert!(read(&path).is_err());
|
|
682
|
+
for mutation in [
|
|
683
|
+
Mutation::Set("new".into()),
|
|
684
|
+
Mutation::Append("new".into()),
|
|
685
|
+
Mutation::Clear,
|
|
686
|
+
] {
|
|
687
|
+
assert!(mutate(&path, mutation).is_err());
|
|
688
|
+
assert_eq!(fs::read_to_string(&target).unwrap(), "unchanged");
|
|
689
|
+
}
|
|
690
|
+
fs::remove_file(&path).unwrap();
|
|
691
|
+
let lock = path.parent().unwrap().join("leader-prompt.lock");
|
|
692
|
+
fs::remove_file(&lock).unwrap();
|
|
693
|
+
symlink(&target, &lock).unwrap();
|
|
694
|
+
let error = mutate(&path, Mutation::Set("new".into())).unwrap_err();
|
|
695
|
+
assert_eq!(error.config_path.as_deref(), Some(path.as_path()));
|
|
696
|
+
assert_eq!(error.io_path.as_deref(), Some(lock.as_path()));
|
|
697
|
+
assert_eq!(fs::read_to_string(&target).unwrap(), "unchanged");
|
|
698
|
+
fs::remove_file(&lock).unwrap();
|
|
699
|
+
fs::set_permissions(path.parent().unwrap(), fs::Permissions::from_mode(0o755)).unwrap();
|
|
700
|
+
mutate(&path, Mutation::Set("new".into())).unwrap();
|
|
701
|
+
assert_eq!(
|
|
702
|
+
fs::metadata(path.parent().unwrap())
|
|
703
|
+
.unwrap()
|
|
704
|
+
.permissions()
|
|
705
|
+
.mode()
|
|
706
|
+
& 0o777,
|
|
707
|
+
0o755
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
#[test]
|
|
711
|
+
fn leader_prompt_home_requires_absolute_path() {
|
|
712
|
+
for home in [None, Some("".into()), Some("relative".into())] {
|
|
713
|
+
assert!(path_from_home(home).is_err());
|
|
714
|
+
}
|
|
715
|
+
assert!(path_from_home(Some(std::env::temp_dir().into_os_string()))
|
|
716
|
+
.unwrap()
|
|
717
|
+
.is_absolute());
|
|
718
|
+
}
|
|
719
|
+
#[test]
|
|
720
|
+
fn leader_prompt_inline_append_preserves_tokens_and_delimiter_data() {
|
|
721
|
+
let path = Path::new("/home/example/.team-agent/leader-prompt.txt");
|
|
722
|
+
for (provider, flag) in [
|
|
723
|
+
(Provider::Pi, "--append-system-prompt"),
|
|
724
|
+
(Provider::Claude, "--append-system-prompt"),
|
|
725
|
+
(Provider::Grok, "--rules"),
|
|
726
|
+
] {
|
|
727
|
+
let mut argv = args(&[
|
|
728
|
+
"native", "--model", "model", flag, "old", "--", flag, "literal",
|
|
729
|
+
]);
|
|
730
|
+
inject(provider, &mut argv, "newš\n", path).unwrap();
|
|
731
|
+
assert_eq!(
|
|
732
|
+
argv,
|
|
733
|
+
args(&[
|
|
734
|
+
"native",
|
|
735
|
+
"--model",
|
|
736
|
+
"model",
|
|
737
|
+
flag,
|
|
738
|
+
"old\n\nnewš\n",
|
|
739
|
+
"--",
|
|
740
|
+
flag,
|
|
741
|
+
"literal"
|
|
742
|
+
])
|
|
743
|
+
);
|
|
744
|
+
let mut joined = args(&["native", &format!("{flag}=old"), "resume"]);
|
|
745
|
+
inject(provider, &mut joined, "new", path).unwrap();
|
|
746
|
+
assert_eq!(
|
|
747
|
+
joined,
|
|
748
|
+
args(&["native", &format!("{flag}=old\n\nnew"), "resume"])
|
|
749
|
+
);
|
|
750
|
+
for tail in [
|
|
751
|
+
args(&[flag]),
|
|
752
|
+
args(&[flag, flag, "two"]),
|
|
753
|
+
args(&[flag, "one", flag, "two"]),
|
|
754
|
+
args(&[&format!("{flag}-file"), "file"]),
|
|
755
|
+
] {
|
|
756
|
+
let mut bad = args(&["native"]);
|
|
757
|
+
bad.extend(tail);
|
|
758
|
+
let before = bad.clone();
|
|
759
|
+
assert_eq!(
|
|
760
|
+
inject(provider, &mut bad, "new", path).unwrap_err().reason,
|
|
761
|
+
"leader_prompt_conflict"
|
|
762
|
+
);
|
|
763
|
+
assert_eq!(bad, before);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
#[test]
|
|
768
|
+
fn leader_prompt_pi_preserves_contract_and_refuses_route_override() {
|
|
769
|
+
let path = Path::new("/home/example/.team-agent/leader-prompt.txt");
|
|
770
|
+
let contract = "Team Agent Leader contract\nKeep team-local tools.";
|
|
771
|
+
assert_eq!(
|
|
772
|
+
compose_pi_contract(
|
|
773
|
+
contract,
|
|
774
|
+
&args(&["pi", "--verbose"]),
|
|
775
|
+
"/looks/like/file",
|
|
776
|
+
path
|
|
777
|
+
)
|
|
778
|
+
.unwrap(),
|
|
779
|
+
format!("{contract}\n\n/looks/like/file")
|
|
780
|
+
);
|
|
781
|
+
for routed in [
|
|
782
|
+
args(&["pi", "--append-system-prompt", "other"]),
|
|
783
|
+
args(&["pi", "--append-system-prompt-file", "file"]),
|
|
784
|
+
args(&["pi", "--", "literal"]),
|
|
785
|
+
] {
|
|
786
|
+
assert_eq!(
|
|
787
|
+
compose_pi_contract(contract, &routed, "supplement", path)
|
|
788
|
+
.unwrap_err()
|
|
789
|
+
.reason,
|
|
790
|
+
"leader_prompt_conflict"
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
#[test]
|
|
795
|
+
fn leader_prompt_claude_core_prompt_values_are_not_supplemental_sibling_flags() {
|
|
796
|
+
let path = Path::new("/home/example/.team-agent/leader-prompt.txt");
|
|
797
|
+
for provider in [Provider::Claude, Provider::ClaudeCode] {
|
|
798
|
+
for flag in ["--system-prompt", "--system-prompt-file"] {
|
|
799
|
+
for literal in [
|
|
800
|
+
"--append-system-prompt",
|
|
801
|
+
"--append-system-prompt=literal",
|
|
802
|
+
"--append-system-prompt-file",
|
|
803
|
+
] {
|
|
804
|
+
let original = args(&["claude", flag, literal, "Initial task"]);
|
|
805
|
+
let mut argv = original.clone();
|
|
806
|
+
inject(provider, &mut argv, "GLOBAL_309", path).unwrap();
|
|
807
|
+
assert_eq!(
|
|
808
|
+
&argv[..3],
|
|
809
|
+
args(&["claude", "--append-system-prompt", "GLOBAL_309"])
|
|
810
|
+
);
|
|
811
|
+
assert_eq!(&argv[3..], &original[1..]);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
#[test]
|
|
817
|
+
fn leader_prompt_codex_owns_one_slot_and_preserves_resume() {
|
|
818
|
+
let path = Path::new("/home/example/.team-agent/leader-prompt.txt");
|
|
819
|
+
let text = "quote\"\\\r\n\tš";
|
|
820
|
+
for (flag, joined) in [("-c", false), ("--config", false), ("--config", true)] {
|
|
821
|
+
let value = "developer_instructions=\"old\"";
|
|
822
|
+
let mut argv = if joined {
|
|
823
|
+
args(&[
|
|
824
|
+
"codex",
|
|
825
|
+
&format!("{flag}={value}"),
|
|
826
|
+
"resume",
|
|
827
|
+
"--last",
|
|
828
|
+
"--",
|
|
829
|
+
"literal",
|
|
830
|
+
])
|
|
831
|
+
} else {
|
|
832
|
+
args(&["codex", flag, value, "resume", "--last", "--", "literal"])
|
|
833
|
+
};
|
|
834
|
+
let tail = argv[if joined { 2 } else { 3 }..].to_vec();
|
|
835
|
+
inject(Provider::Codex, &mut argv, text, path).unwrap();
|
|
836
|
+
let encoded = argv[if joined { 1 } else { 2 }]
|
|
837
|
+
.split_once("developer_instructions=")
|
|
838
|
+
.unwrap()
|
|
839
|
+
.1;
|
|
840
|
+
assert_eq!(
|
|
841
|
+
serde_json::from_str::<String>(encoded).unwrap(),
|
|
842
|
+
format!("old\n\n{text}")
|
|
843
|
+
);
|
|
844
|
+
assert_eq!(&argv[if joined { 2 } else { 3 }..], tail);
|
|
845
|
+
}
|
|
846
|
+
let mut fresh = args(&[
|
|
847
|
+
"codex",
|
|
848
|
+
"resume",
|
|
849
|
+
"--last",
|
|
850
|
+
"--",
|
|
851
|
+
"developer_instructions='literal'",
|
|
852
|
+
]);
|
|
853
|
+
inject(Provider::Codex, &mut fresh, text, path).unwrap();
|
|
854
|
+
assert_eq!(
|
|
855
|
+
&fresh[3..],
|
|
856
|
+
args(&["resume", "--last", "--", "developer_instructions='literal'"])
|
|
857
|
+
);
|
|
858
|
+
for tokens in [
|
|
859
|
+
args(&["codex", "-c", "developer_instructions='raw'"]),
|
|
860
|
+
args(&[
|
|
861
|
+
"codex",
|
|
862
|
+
"-c",
|
|
863
|
+
"developer_instructions=\"one\"",
|
|
864
|
+
"--config=developer_instructions=\"two\"",
|
|
865
|
+
]),
|
|
866
|
+
args(&["codex", "-cdeveloper_instructions=\"attached\""]),
|
|
867
|
+
args(&["codex", "-c=developer_instructions=\"attached\""]),
|
|
868
|
+
args(&[
|
|
869
|
+
"codex",
|
|
870
|
+
"--config",
|
|
871
|
+
"\"developer_instructions\"=\"quoted key\"",
|
|
872
|
+
]),
|
|
873
|
+
args(&[
|
|
874
|
+
"codex",
|
|
875
|
+
"--config",
|
|
876
|
+
"'developer_instructions'=\"quoted key\"",
|
|
877
|
+
]),
|
|
878
|
+
] {
|
|
879
|
+
let mut argv = tokens.clone();
|
|
880
|
+
assert_eq!(
|
|
881
|
+
inject(Provider::Codex, &mut argv, text, path)
|
|
882
|
+
.unwrap_err()
|
|
883
|
+
.reason,
|
|
884
|
+
"leader_prompt_conflict"
|
|
885
|
+
);
|
|
886
|
+
assert_eq!(argv, tokens);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
#[test]
|
|
890
|
+
fn leader_prompt_unadmitted_providers_fail_without_argv_mutation() {
|
|
891
|
+
let path = Path::new("/home/example/.team-agent/leader-prompt.txt");
|
|
892
|
+
for provider in [
|
|
893
|
+
Provider::CursorAgent,
|
|
894
|
+
Provider::Copilot,
|
|
895
|
+
Provider::GeminiCli,
|
|
896
|
+
Provider::Fake,
|
|
897
|
+
] {
|
|
898
|
+
let mut argv = args(&["native", "--model", "model"]);
|
|
899
|
+
let before = argv.clone();
|
|
900
|
+
let error = inject(provider, &mut argv, "do not echo this sentinel", path).unwrap_err();
|
|
901
|
+
assert_eq!(error.reason, "leader_prompt_unsupported");
|
|
902
|
+
assert!(!error.to_string().contains("sentinel"));
|
|
903
|
+
assert_eq!(argv, before);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|