@team-agent/installer 0.1.0

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.
Files changed (36) hide show
  1. package/README.md +201 -0
  2. package/crates/team-agent-core/Cargo.toml +12 -0
  3. package/crates/team-agent-core/src/lib.rs +287 -0
  4. package/crates/team-agent-core/src/main.rs +152 -0
  5. package/examples/team.spec.yaml +206 -0
  6. package/examples/team_state.md +35 -0
  7. package/npm/install.mjs +266 -0
  8. package/package.json +28 -0
  9. package/pyproject.toml +18 -0
  10. package/schemas/result-envelope.schema.json +76 -0
  11. package/schemas/team.schema.json +241 -0
  12. package/scripts/install.py +88 -0
  13. package/scripts/run_regression_tests.py +79 -0
  14. package/skills/team-agent/SKILL.md +173 -0
  15. package/src/team_agent/__init__.py +3 -0
  16. package/src/team_agent/__main__.py +5 -0
  17. package/src/team_agent/cli.py +857 -0
  18. package/src/team_agent/compiler.py +269 -0
  19. package/src/team_agent/coordinator.py +62 -0
  20. package/src/team_agent/errors.py +10 -0
  21. package/src/team_agent/events.py +37 -0
  22. package/src/team_agent/fake_worker.py +80 -0
  23. package/src/team_agent/mcp_server.py +579 -0
  24. package/src/team_agent/message_store.py +497 -0
  25. package/src/team_agent/paths.py +45 -0
  26. package/src/team_agent/permissions.py +123 -0
  27. package/src/team_agent/profiles.py +882 -0
  28. package/src/team_agent/providers.py +1045 -0
  29. package/src/team_agent/routing.py +84 -0
  30. package/src/team_agent/runtime.py +5213 -0
  31. package/src/team_agent/rust_core.py +156 -0
  32. package/src/team_agent/simple_yaml.py +236 -0
  33. package/src/team_agent/spec.py +308 -0
  34. package/src/team_agent/state.py +112 -0
  35. package/src/team_agent/task_graph.py +80 -0
  36. package/templates/team_state.md +32 -0
package/README.md ADDED
@@ -0,0 +1,201 @@
1
+ # TeamSpec Agent Mode
2
+
3
+ Document-driven CLI runtime for launching and coordinating Codex CLI workers from a leader conversation.
4
+
5
+ The product and engineering boundary is defined in [`docs/team-agent-foundation-and-boundaries.md`](docs/team-agent-foundation-and-boundaries.md). Agents express semantic intent; the runtime owns team startup, delivery, state, diagnostics, secret safety, and the coordinator daemon.
6
+
7
+ ## Install
8
+
9
+ For normal users:
10
+
11
+ ```bash
12
+ npx @team-agent/installer@latest install
13
+ ```
14
+
15
+ The installer probes `TEAM_AGENT_PYTHON`, `python3`, then `python`, copies the runtime to
16
+ `~/.team-agent/runtime/<version>/`, writes wrappers under `~/.local/bin/`, runs
17
+ `team-agent install-skill --target all`, and then runs `team-agent doctor`.
18
+
19
+ Source checkout install, for development:
20
+
21
+ ```bash
22
+ python3 scripts/install.py --prefix "$HOME/.local"
23
+ export PATH="$HOME/.local/bin:$PATH"
24
+ team-agent doctor
25
+ team-agent install-skill --target all
26
+ ```
27
+
28
+ Runtime dependencies:
29
+
30
+ - `tmux` 3.3+
31
+ - Codex CLI command: `codex`
32
+ - Provider auth owned by the provider CLI
33
+
34
+ ## Quick Start
35
+
36
+ Start the user-facing leader from a tmux-managed pane before starting a real team. The short
37
+ entry points are:
38
+
39
+ ```bash
40
+ team-agent codex
41
+ team-agent claude
42
+ ```
43
+
44
+ Arguments after the provider name pass through to the provider CLI:
45
+
46
+ ```bash
47
+ team-agent codex --dangerously-bypass-approvals-and-sandbox
48
+ team-agent claude --dangerously-skip-permissions
49
+ ```
50
+
51
+ If these commands run outside tmux, they create or attach a tmux leader session in the current
52
+ directory. If they run inside tmux, they exec the provider in the current pane. Existing tmux
53
+ layouts, including Finder/Ghostty right-click launchers, are also supported: the requirement is
54
+ that `team-agent quick-start` runs from the leader's current tmux pane so worker-to-leader
55
+ messages have a concrete, verifiable target.
56
+
57
+ Create a directory containing role docs, then run:
58
+
59
+ ```bash
60
+ team-agent quick-start <agents_dir>
61
+ ```
62
+
63
+ The command prepares the current workspace team, starts workers, waits for readiness, and starts the per-workspace `team-agent-coordinator` daemon. When it prints `ready:` and `ready_signal`, startup is complete; do not add sleep/status polling unless diagnosing a failure. Detailed logs stay under the workspace team files.
64
+
65
+ By default, loose role docs are copied into `.team/current/`. To keep multiple teams in one workspace, give each generated team an explicit id, or pass an existing team directory directly:
66
+
67
+ ```bash
68
+ team-agent quick-start ./roles-alpha --team-id alpha
69
+ team-agent quick-start .team/research
70
+ ```
71
+
72
+ `quick-start` is the fresh startup path. If Team Agent finds stored worker context for the same runtime session, it stops before launching blank workers and tells you to either resume with `restart` or explicitly accept a fresh start with `--fresh`:
73
+
74
+ ```bash
75
+ team-agent restart . --team team-alpha
76
+ team-agent quick-start .team/alpha --fresh
77
+ ```
78
+
79
+ Role docs can omit `model` for subscription workers. Team Agent fills defaults from `TEAM.md` `provider_models`, then built-in defaults (`codex: gpt-5.5`, `claude/claude_code: claude-sonnet-4-6`). Put a role-level `model` only when that worker intentionally uses a different model.
80
+
81
+ Send work and inspect state:
82
+
83
+ ```bash
84
+ team-agent send <agent_id> "<message>"
85
+ team-agent send --watch-result <agent_id> "<message>"
86
+ team-agent status
87
+ team-agent status <agent_id>
88
+ team-agent approvals
89
+ team-agent inbox <agent_id>
90
+ team-agent shutdown --keep-logs
91
+ team-agent restart .
92
+ team-agent restart . --team <session_name_or_team_name>
93
+ team-agent start-agent <agent_id> --workspace .
94
+ ```
95
+
96
+ `team-agent send` waits for `visible` delivery by default and exits non-zero if the target terminal does not show the unique token before timeout. Use `--no-wait` only when deliberately accepting injected-but-unverified delivery.
97
+ Use `team-agent send --watch-result ...` for normal task dispatch: the command returns after verified delivery, registers a result watcher, and the coordinator collects the eventual result and notifies the leader.
98
+
99
+ `team-agent status` includes result-store counts plus each worker's `session_id`, capture method, and attribution confidence when available. `team-agent shutdown` makes a final session capture attempt before killing tmux. `team-agent restart <workspace>` resumes workers with stored provider-native session ids; if the workspace has more than one restartable team, it fails closed and lists candidates until you pass `--team`. Workers with missing `session_id` are started fresh and logged as `restart.fresh_spawn`. `team-agent start-agent <agent_id> --workspace .` is the narrow repair path for one missing worker window; it resumes that worker when possible, starts it fresh otherwise, and leaves other workers running.
100
+
101
+ Use `team-agent approvals [agent_id]` to inspect pending provider/MCP approval prompts without copying worker terminal pages into the leader context.
102
+
103
+ ## Role Docs
104
+
105
+ Role docs are Markdown files with YAML front matter:
106
+
107
+ ```yaml
108
+ ---
109
+ name: implementer
110
+ role: Implementation Engineer
111
+ provider: codex
112
+ model: gpt-5.5
113
+ auth_mode: subscription
114
+ profile: codex-default
115
+ tools:
116
+ - fs_read
117
+ - fs_write
118
+ - execute_bash
119
+ - mcp_team
120
+ ---
121
+ ```
122
+
123
+ For quick-start teams, put role docs under `agents/` and keep `TEAM.md` at the team root. A root `TEAM.md` is team configuration, not a worker role, and does not create a leader pane. Quick-start generated files stay under the selected team directory, such as `.team/current/` or `.team/alpha/` (`team.spec.yaml`, `team_state.md`), instead of the workspace root.
124
+
125
+ Team-level switches live in `TEAM.md` front matter:
126
+
127
+ ```yaml
128
+ ---
129
+ name: demo-team
130
+ dangerous_auto_approve: false
131
+ display_backend: ghostty_window
132
+ fast: false
133
+ tick_interval_sec: 2
134
+ push_min_interval_sec: 60
135
+ stuck_timeout_sec: 300
136
+ worker_to_worker: true
137
+ ---
138
+ ```
139
+
140
+ If `display_backend` is omitted, quick-start defaults to `ghostty_window`. Use `display_backend: none` only for headless or CI runs.
141
+ Ghostty display windows use one linked tmux session per worker, so each visible window keeps an independent active tmux window while runtime injection still targets the base worker window.
142
+ When the leader Codex/Claude process itself was started with `--dangerously-bypass-approvals-and-sandbox` or `--dangerously-skip-permissions`, Team Agent treats worker launch, restart, and single-agent repair as already authorized and passes the matching dangerous permission mode through.
143
+
144
+ Secrets must stay in profile files ignored by git. Role docs and manifests carry profile references only. Agents must not read raw profile env files into context; use `team-agent profile show <name> --workspace . --json` or `team-agent profile doctor <name> --workspace . --json` for redacted diagnostics.
145
+
146
+ For third-party compatible APIs, the leader should generate a local fillable profile instead of asking for secrets in chat:
147
+
148
+ ```bash
149
+ team-agent profile init deepseek --auth-mode compatible_api --workspace .
150
+ ```
151
+
152
+ This creates `.team/current/profiles/deepseek.env` and `.team/current/profiles/deepseek.example.env` with blank values:
153
+
154
+ ```env
155
+ AUTH_MODE=compatible_api
156
+ PROFILE_NAME=deepseek
157
+ BASE_URL=
158
+ API_KEY=
159
+ MODEL=
160
+ ```
161
+
162
+ Fill the `.env` file locally, then reference only the profile name from role docs:
163
+
164
+ ```yaml
165
+ provider: claude_code
166
+ auth_mode: compatible_api
167
+ profile: deepseek
168
+ ```
169
+
170
+ For `compatible_api`, `MODEL=` in the profile is a valid model source. If both the role doc and profile define a model, they must match exactly; otherwise preflight fails before startup. Team Agent loads that profile during quick-start, launch, restart, and start-agent. Secret values are written only to per-worker runtime env files under `.team/runtime/provider-env/` and are not printed in CLI output, event logs, role docs, or compiled specs. Compatible API workers inherit the current shell proxy/CA environment by default, because some teams intentionally route third-party APIs through a proxy. Claude compatible API workers use a Team Agent managed `CLAUDE_CONFIG_DIR`, so user-level Claude subscription settings cannot re-inject Anthropic proxy variables into third-party API sessions. That managed config is pre-seeded with non-secret onboarding, theme, and workspace trust state so a fresh isolated Claude worker does not stop at first-run setup. Startup smoke checks expose whether the profile used an ambient proxy or a profile proxy; if the proxy cannot reach `BASE_URL`, quick-start returns a redacted blocker and lets the user choose whether to fix the proxy, set `HTTPS_PROXY=`/`HTTP_PROXY=` in that profile, or set `PROXY_MODE=direct` in that profile to bypass proxy only for that worker. Subscription workers keep the native provider settings and environment.
171
+ When diagnosing profile state, run `team-agent profile show deepseek --workspace . --json`; it reports non-secret values and secret-key presence without printing secret values. Do not inspect `.team/current/profiles/*.env` or `.team/runtime/provider-env/*.env` through an Agent.
172
+
173
+ ## Runtime
174
+
175
+ Worker-to-leader messages are stored in SQLite, rendered as human-readable text, and pushed into the attached leader pane by the runtime/coordinator path. Delivery states distinguish `accepted`, `target_resolved`, `injected`, `visible`, `submitted`, `failed`, and `ambiguous`. Direct leader delivery only reports `submitted` after the rendered token is observed in the pane and the runtime sends `Enter`; `visible` alone is only a pre-submit observation.
176
+
177
+ Agent-facing MCP tools keep context thin: `send_message` takes only `to` and `content`, and `report_result` takes `summary` plus optional status/details. Sender, task id, ack policy, result schema fields, delivery ids, and verification metadata are filled or stored by the runtime. If a provider fails to pass `TEAM_AGENT_ID`, MCP infers the worker from the active task/message state and falls back to an explicit `unknown` sender rather than misrouting the message as leader. Message targets are team-scoped: use `leader`, a teammate agent id, or `*` for all other team members.
178
+
179
+ Final results are stored through `report_result`; that call also attempts an immediate leader notification through the same verified/fallback delivery path. `team-agent collect` remains the authoritative state-update path, and `team-agent inbox` is message history only, not a final-result intake. `team-agent status --json` exposes `results.total`, `results.uncollected`, `results.collected`, `results.invalid`, and `results.by_status`.
180
+
181
+ The coordinator automatically clears known Team Agent control-plane MCP prompts such as `team_orchestrator.report_result` and `team_orchestrator.send_message`. It uses session-scoped approval, verifies the prompt disappeared after submission, retries boundedly when Enter/selection does not take, and records the result in the event log. Non-allowlisted tools stay visible through `team-agent approvals`.
182
+
183
+ Raw worker terminal inspection is blocked in the ordinary black-box workflow. The hidden `team-agent peek` diagnostic refuses to run unless the caller provides `--allow-raw-screen` after explicit user authorization, and still requires exactly one bounded selector: `--head N`, `--tail N`, or `--search TEXT`.
184
+
185
+ Worker-to-worker messages are allowed within the current team. The runtime resolves recipients only from the current leader plus agents in the team spec/runtime state; `*` broadcasts to that set and excludes the sender.
186
+
187
+ The main help intentionally shows only the black-box surface. Low-level commands remain available through:
188
+
189
+ ```bash
190
+ team-agent advanced --help
191
+ ```
192
+
193
+ ## Acceptance
194
+
195
+ ```bash
196
+ PYTHONPATH=src python3 scripts/run_regression_tests.py --iterations 3
197
+ PYTHONPATH=src python3 tests/run_tests.py
198
+ cargo test --manifest-path crates/team-agent-core/Cargo.toml
199
+ ```
200
+
201
+ Real provider smoke requires installed and authenticated provider CLIs. A skipped real provider smoke is not production acceptance.
@@ -0,0 +1,12 @@
1
+ [package]
2
+ name = "team-agent-core"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+
6
+ [lib]
7
+ name = "team_agent_core"
8
+ path = "src/lib.rs"
9
+
10
+ [[bin]]
11
+ name = "team-agent-core"
12
+ path = "src/main.rs"
@@ -0,0 +1,287 @@
1
+ use std::process::Command;
2
+
3
+ #[derive(Debug, Clone, PartialEq, Eq)]
4
+ pub struct Target {
5
+ pub pane_id: String,
6
+ pub session_name: String,
7
+ pub window_index: String,
8
+ pub window_name: String,
9
+ pub pane_index: String,
10
+ pub pane_tty: String,
11
+ pub pane_current_command: String,
12
+ pub pane_active: bool,
13
+ }
14
+
15
+ impl Target {
16
+ pub fn fingerprint(&self) -> String {
17
+ format!(
18
+ "{}|{}|{}|{}",
19
+ self.session_name, self.window_index, self.pane_index, self.pane_tty
20
+ )
21
+ }
22
+ }
23
+
24
+ #[derive(Debug, Clone, PartialEq, Eq)]
25
+ pub enum TargetResolution {
26
+ Resolved(Target),
27
+ Ambiguous(Vec<Target>),
28
+ Missing,
29
+ }
30
+
31
+ #[derive(Debug, Clone, PartialEq, Eq)]
32
+ pub enum DeliveryStatus {
33
+ Accepted,
34
+ TargetResolved,
35
+ Injected,
36
+ Visible,
37
+ Failed,
38
+ Ambiguous,
39
+ }
40
+
41
+ impl DeliveryStatus {
42
+ pub fn as_str(&self) -> &'static str {
43
+ match self {
44
+ DeliveryStatus::Accepted => "accepted",
45
+ DeliveryStatus::TargetResolved => "target_resolved",
46
+ DeliveryStatus::Injected => "injected",
47
+ DeliveryStatus::Visible => "visible",
48
+ DeliveryStatus::Failed => "failed",
49
+ DeliveryStatus::Ambiguous => "ambiguous",
50
+ }
51
+ }
52
+
53
+ pub fn can_transition_to(&self, next: &DeliveryStatus) -> bool {
54
+ matches!(
55
+ (self, next),
56
+ (DeliveryStatus::Accepted, DeliveryStatus::TargetResolved)
57
+ | (DeliveryStatus::Accepted, DeliveryStatus::Failed)
58
+ | (DeliveryStatus::Accepted, DeliveryStatus::Ambiguous)
59
+ | (DeliveryStatus::TargetResolved, DeliveryStatus::Injected)
60
+ | (DeliveryStatus::TargetResolved, DeliveryStatus::Failed)
61
+ | (DeliveryStatus::TargetResolved, DeliveryStatus::Ambiguous)
62
+ | (DeliveryStatus::Injected, DeliveryStatus::Visible)
63
+ | (DeliveryStatus::Injected, DeliveryStatus::Failed)
64
+ | (DeliveryStatus::Visible, DeliveryStatus::Failed)
65
+ )
66
+ }
67
+ }
68
+
69
+ #[derive(Debug, Clone, PartialEq, Eq)]
70
+ pub struct DeliveryError {
71
+ pub code: String,
72
+ pub message: String,
73
+ }
74
+
75
+ pub fn list_tmux_targets() -> Result<Vec<Target>, String> {
76
+ let format = "#{pane_id}\t#{session_name}\t#{window_index}\t#{window_name}\t#{pane_index}\t#{pane_tty}\t#{pane_current_command}\t#{pane_active}";
77
+ let output = Command::new("tmux")
78
+ .args(["list-panes", "-a", "-F", format])
79
+ .output()
80
+ .map_err(|err| format!("tmux list-panes failed: {err}"))?;
81
+ if !output.status.success() {
82
+ return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
83
+ }
84
+ Ok(String::from_utf8_lossy(&output.stdout)
85
+ .lines()
86
+ .filter_map(parse_target_line)
87
+ .collect())
88
+ }
89
+
90
+ pub fn parse_target_line(line: &str) -> Option<Target> {
91
+ let parts: Vec<&str> = line.split('\t').collect();
92
+ if parts.len() != 8 {
93
+ return None;
94
+ }
95
+ Some(Target {
96
+ pane_id: parts[0].to_string(),
97
+ session_name: parts[1].to_string(),
98
+ window_index: parts[2].to_string(),
99
+ window_name: parts[3].to_string(),
100
+ pane_index: parts[4].to_string(),
101
+ pane_tty: parts[5].to_string(),
102
+ pane_current_command: parts[6].to_string(),
103
+ pane_active: parts[7] == "1",
104
+ })
105
+ }
106
+
107
+ pub fn resolve_target(candidates: Vec<Target>, expected_fingerprint: Option<&str>) -> TargetResolution {
108
+ if let Some(fingerprint) = expected_fingerprint {
109
+ let matched: Vec<Target> = candidates
110
+ .iter()
111
+ .cloned()
112
+ .filter(|target| target.fingerprint() == fingerprint)
113
+ .collect();
114
+ if matched.len() == 1 {
115
+ return TargetResolution::Resolved(matched[0].clone());
116
+ }
117
+ if matched.len() > 1 {
118
+ return TargetResolution::Ambiguous(matched);
119
+ }
120
+ }
121
+ let usable: Vec<Target> = candidates
122
+ .into_iter()
123
+ .filter(|target| {
124
+ let cmd = target
125
+ .pane_current_command
126
+ .rsplit('/')
127
+ .next()
128
+ .unwrap_or(&target.pane_current_command);
129
+ matches!(cmd, "codex" | "node" | "nodejs")
130
+ })
131
+ .collect();
132
+ match usable.len() {
133
+ 0 => TargetResolution::Missing,
134
+ 1 => TargetResolution::Resolved(usable[0].clone()),
135
+ _ => TargetResolution::Ambiguous(usable),
136
+ }
137
+ }
138
+
139
+ pub fn render_message(sender: &str, task_id: Option<&str>, content: &str, token: &str) -> String {
140
+ let mut header = format!("Team Agent message from {}", sender);
141
+ if let Some(task) = task_id {
142
+ if !task.is_empty() {
143
+ header.push_str(" for ");
144
+ header.push_str(task);
145
+ }
146
+ }
147
+ format!("{header}:\n\n{content}\n\n[team-agent-token:{token}]")
148
+ }
149
+
150
+ pub fn redact_secrets(input: &str) -> String {
151
+ let mut out = Vec::new();
152
+ for raw in input.split_whitespace() {
153
+ let lower = raw.to_ascii_lowercase();
154
+ let redacted = lower.contains("api_key")
155
+ || lower.contains("apikey")
156
+ || lower.contains("token=")
157
+ || lower.contains("secret")
158
+ || lower == "bearer"
159
+ || raw.starts_with("sk-")
160
+ || looks_base64_secret(raw);
161
+ out.push(if redacted { "[REDACTED]" } else { raw });
162
+ }
163
+ out.join(" ")
164
+ }
165
+
166
+ pub fn looks_base64_secret(value: &str) -> bool {
167
+ value.len() >= 32
168
+ && value
169
+ .chars()
170
+ .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=' | '_' | '-'))
171
+ }
172
+
173
+ #[derive(Debug, Clone, PartialEq, Eq)]
174
+ pub struct Profile {
175
+ pub provider: String,
176
+ pub model: String,
177
+ pub auth_mode: String,
178
+ pub profile: String,
179
+ pub credential_ref: Option<String>,
180
+ }
181
+
182
+ pub fn validate_profile(profile: &Profile) -> Result<(), Vec<String>> {
183
+ let mut errors = Vec::new();
184
+ if !matches!(
185
+ profile.auth_mode.as_str(),
186
+ "subscription" | "official_api" | "compatible_api"
187
+ ) {
188
+ errors.push("auth_mode must be subscription, official_api, or compatible_api".to_string());
189
+ }
190
+ if profile.provider.is_empty() {
191
+ errors.push("provider must not be empty".to_string());
192
+ }
193
+ if profile.model.is_empty() {
194
+ errors.push("model must not be empty".to_string());
195
+ }
196
+ if profile.profile.is_empty() {
197
+ errors.push("profile must not be empty".to_string());
198
+ }
199
+ for value in [
200
+ &profile.provider,
201
+ &profile.model,
202
+ &profile.auth_mode,
203
+ &profile.profile,
204
+ ] {
205
+ if contains_inline_secret(value) {
206
+ errors.push("profile metadata contains a probable inline secret".to_string());
207
+ break;
208
+ }
209
+ }
210
+ if errors.is_empty() {
211
+ Ok(())
212
+ } else {
213
+ Err(errors)
214
+ }
215
+ }
216
+
217
+ pub fn contains_inline_secret(value: &str) -> bool {
218
+ let lower = value.to_ascii_lowercase();
219
+ lower.contains("api_key")
220
+ || lower.contains("apikey")
221
+ || lower.contains("token")
222
+ || lower.contains("secret")
223
+ || value.starts_with("sk-")
224
+ || looks_base64_secret(value)
225
+ }
226
+
227
+ pub fn json_escape(input: &str) -> String {
228
+ let mut out = String::new();
229
+ for ch in input.chars() {
230
+ match ch {
231
+ '"' => out.push_str("\\\""),
232
+ '\\' => out.push_str("\\\\"),
233
+ '\n' => out.push_str("\\n"),
234
+ '\r' => out.push_str("\\r"),
235
+ '\t' => out.push_str("\\t"),
236
+ ch if ch.is_control() => out.push_str(&format!("\\u{:04x}", ch as u32)),
237
+ ch => out.push(ch),
238
+ }
239
+ }
240
+ out
241
+ }
242
+
243
+ #[cfg(test)]
244
+ mod tests {
245
+ use super::*;
246
+
247
+ #[test]
248
+ fn parses_target_and_fingerprint() {
249
+ let target = parse_target_line("%1\ts\t2\tw\t0\t/dev/ttys001\tnode\t1").unwrap();
250
+ assert_eq!(target.pane_id, "%1");
251
+ assert_eq!(target.fingerprint(), "s|2|0|/dev/ttys001");
252
+ }
253
+
254
+ #[test]
255
+ fn delivery_state_transitions_are_explicit() {
256
+ assert!(DeliveryStatus::Accepted.can_transition_to(&DeliveryStatus::TargetResolved));
257
+ assert!(DeliveryStatus::TargetResolved.can_transition_to(&DeliveryStatus::Injected));
258
+ assert!(DeliveryStatus::Injected.can_transition_to(&DeliveryStatus::Visible));
259
+ assert!(!DeliveryStatus::Accepted.can_transition_to(&DeliveryStatus::Visible));
260
+ }
261
+
262
+ #[test]
263
+ fn renders_human_readable_message() {
264
+ let rendered = render_message("worker", Some("task_a"), "done", "msg_1");
265
+ assert!(rendered.contains("Team Agent message from worker for task_a:"));
266
+ assert!(rendered.contains("[team-agent-token:msg_1]"));
267
+ }
268
+
269
+ #[test]
270
+ fn redacts_common_secret_shapes() {
271
+ let redacted = redact_secrets("Authorization Bearer sk-test abcdefghijklmnopqrstuvwxyz123456");
272
+ assert!(redacted.contains("[REDACTED]"));
273
+ assert!(!redacted.contains("sk-test"));
274
+ }
275
+
276
+ #[test]
277
+ fn profile_rejects_inline_secret() {
278
+ let profile = Profile {
279
+ provider: "codex".to_string(),
280
+ model: "gpt-5.5".to_string(),
281
+ auth_mode: "subscription".to_string(),
282
+ profile: "sk-inline-secret".to_string(),
283
+ credential_ref: None,
284
+ };
285
+ assert!(validate_profile(&profile).is_err());
286
+ }
287
+ }
@@ -0,0 +1,152 @@
1
+ use std::env;
2
+ use std::io::{self, Read};
3
+
4
+ use team_agent_core::{
5
+ contains_inline_secret, json_escape, list_tmux_targets, redact_secrets, render_message,
6
+ validate_profile, Profile,
7
+ };
8
+
9
+ fn main() {
10
+ let args: Vec<String> = env::args().collect();
11
+ let command = args.get(1).map(String::as_str).unwrap_or("");
12
+ let result = match command {
13
+ "list-targets" => list_targets(),
14
+ "render-message" => render_message_cmd(),
15
+ "redact" => redact_cmd(),
16
+ "validate-profile" => validate_profile_cmd(),
17
+ _ => Err(format!(
18
+ "unknown command {command:?}; expected list-targets, render-message, redact, validate-profile"
19
+ )),
20
+ };
21
+ match result {
22
+ Ok(json) => println!("{json}"),
23
+ Err(error) => {
24
+ println!(
25
+ "{{\"ok\":false,\"error\":\"{}\"}}",
26
+ json_escape(error.trim())
27
+ );
28
+ std::process::exit(1);
29
+ }
30
+ }
31
+ }
32
+
33
+ fn read_stdin() -> Result<String, String> {
34
+ let mut input = String::new();
35
+ io::stdin()
36
+ .read_to_string(&mut input)
37
+ .map_err(|err| format!("stdin read failed: {err}"))?;
38
+ Ok(input)
39
+ }
40
+
41
+ fn list_targets() -> Result<String, String> {
42
+ let targets = list_tmux_targets()?;
43
+ let mut items = Vec::new();
44
+ for target in targets {
45
+ items.push(format!(
46
+ "{{\"pane_id\":\"{}\",\"session_name\":\"{}\",\"window_index\":\"{}\",\"window_name\":\"{}\",\"pane_index\":\"{}\",\"pane_tty\":\"{}\",\"pane_current_command\":\"{}\",\"pane_active\":{},\"fingerprint\":\"{}\"}}",
47
+ json_escape(&target.pane_id),
48
+ json_escape(&target.session_name),
49
+ json_escape(&target.window_index),
50
+ json_escape(&target.window_name),
51
+ json_escape(&target.pane_index),
52
+ json_escape(&target.pane_tty),
53
+ json_escape(&target.pane_current_command),
54
+ target.pane_active,
55
+ json_escape(&target.fingerprint())
56
+ ));
57
+ }
58
+ Ok(format!("{{\"ok\":true,\"targets\":[{}]}}", items.join(",")))
59
+ }
60
+
61
+ fn render_message_cmd() -> Result<String, String> {
62
+ let input = read_stdin()?;
63
+ let sender = extract_string(&input, "from")
64
+ .or_else(|| extract_string(&input, "sender"))
65
+ .unwrap_or_else(|| "unknown".to_string());
66
+ let task_id = extract_string(&input, "task_id");
67
+ let content = extract_string(&input, "content").unwrap_or_default();
68
+ let token = extract_string(&input, "message_id").unwrap_or_else(|| "missing".to_string());
69
+ let rendered = render_message(&sender, task_id.as_deref(), &content, &token);
70
+ Ok(format!(
71
+ "{{\"ok\":true,\"text\":\"{}\",\"token\":\"{}\"}}",
72
+ json_escape(&rendered),
73
+ json_escape(&token)
74
+ ))
75
+ }
76
+
77
+ fn redact_cmd() -> Result<String, String> {
78
+ let input = read_stdin()?;
79
+ let text = extract_string(&input, "text").unwrap_or(input);
80
+ Ok(format!(
81
+ "{{\"ok\":true,\"text\":\"{}\"}}",
82
+ json_escape(&redact_secrets(&text))
83
+ ))
84
+ }
85
+
86
+ fn validate_profile_cmd() -> Result<String, String> {
87
+ let input = read_stdin()?;
88
+ let profile = Profile {
89
+ provider: extract_string(&input, "provider").unwrap_or_default(),
90
+ model: extract_string(&input, "model").unwrap_or_default(),
91
+ auth_mode: extract_string(&input, "auth_mode").unwrap_or_default(),
92
+ profile: extract_string(&input, "profile").unwrap_or_default(),
93
+ credential_ref: extract_string(&input, "credential_ref"),
94
+ };
95
+ let mut errors = match validate_profile(&profile) {
96
+ Ok(()) => Vec::new(),
97
+ Err(errors) => errors,
98
+ };
99
+ if contains_inline_secret(&input) {
100
+ errors.push("input contains a probable inline secret".to_string());
101
+ }
102
+ if errors.is_empty() {
103
+ Ok("{\"ok\":true,\"errors\":[]}".to_string())
104
+ } else {
105
+ let encoded: Vec<String> = errors
106
+ .into_iter()
107
+ .map(|err| format!("\"{}\"", json_escape(&err)))
108
+ .collect();
109
+ Ok(format!("{{\"ok\":false,\"errors\":[{}]}}", encoded.join(",")))
110
+ }
111
+ }
112
+
113
+ fn extract_string(input: &str, key: &str) -> Option<String> {
114
+ let needle = format!("\"{key}\"");
115
+ let key_pos = input.find(&needle)?;
116
+ let rest = &input[key_pos + needle.len()..];
117
+ let colon = rest.find(':')?;
118
+ let value = rest[colon + 1..].trim_start();
119
+ if !value.starts_with('"') {
120
+ return None;
121
+ }
122
+ parse_json_string(value)
123
+ }
124
+
125
+ fn parse_json_string(input: &str) -> Option<String> {
126
+ let mut out = String::new();
127
+ let mut chars = input.chars();
128
+ if chars.next()? != '"' {
129
+ return None;
130
+ }
131
+ let mut escaped = false;
132
+ for ch in chars {
133
+ if escaped {
134
+ match ch {
135
+ '"' => out.push('"'),
136
+ '\\' => out.push('\\'),
137
+ 'n' => out.push('\n'),
138
+ 'r' => out.push('\r'),
139
+ 't' => out.push('\t'),
140
+ other => out.push(other),
141
+ }
142
+ escaped = false;
143
+ continue;
144
+ }
145
+ match ch {
146
+ '\\' => escaped = true,
147
+ '"' => return Some(out),
148
+ other => out.push(other),
149
+ }
150
+ }
151
+ None
152
+ }