@team-agent/installer 0.4.6 → 0.4.8

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 (29) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +33 -1
  4. package/crates/team-agent/src/cli/status_port.rs +119 -66
  5. package/crates/team-agent/src/cli/tests/status_send.rs +74 -48
  6. package/crates/team-agent/src/coordinator/tick.rs +2 -21
  7. package/crates/team-agent/src/leader/helpers.rs +1 -22
  8. package/crates/team-agent/src/lifecycle/launch.rs +1 -11
  9. package/crates/team-agent/src/lifecycle/profile_launch.rs +25 -11
  10. package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -1
  11. package/crates/team-agent/src/lifecycle/restart/common.rs +20 -26
  12. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +30 -8
  13. package/crates/team-agent/src/lifecycle/restart/selection.rs +67 -16
  14. package/crates/team-agent/src/lifecycle/restart.rs +1 -0
  15. package/crates/team-agent/src/lifecycle/tests/core.rs +99 -0
  16. package/crates/team-agent/src/messaging/delivery.rs +113 -0
  17. package/crates/team-agent/src/provider/adapter.rs +18 -372
  18. package/crates/team-agent/src/provider/adapters/claude.rs +106 -0
  19. package/crates/team-agent/src/provider/adapters/codex.rs +117 -0
  20. package/crates/team-agent/src/provider/adapters/copilot.rs +124 -0
  21. package/crates/team-agent/src/provider/adapters/fake.rs +17 -0
  22. package/crates/team-agent/src/provider/adapters/mod.rs +27 -0
  23. package/crates/team-agent/src/provider/mod.rs +6 -0
  24. package/crates/team-agent/src/provider/session/capture.rs +131 -1
  25. package/crates/team-agent/src/provider/wire.rs +139 -0
  26. package/crates/team-agent/src/state/paths.rs +8 -4
  27. package/crates/team-agent/src/state/persist.rs +17 -0
  28. package/package.json +4 -4
  29. package/skills/team-agent/SKILL.md +6 -0
@@ -0,0 +1,124 @@
1
+ //! Copilot provider-local command builders + permission helpers.
2
+ //!
3
+ //! Extracted from `provider/adapter.rs` (0.4.x decoupling step 2). Pure
4
+ //! extraction — byte-identical to the original inline forms. Scope kept
5
+ //! small: base command + resume + permission flags + MCP type→transport
6
+ //! translation. Auth hint (`copilot_auth_hint`) and session store scan
7
+ //! (`scan_copilot_session_store`, `copilot_candidate`) stay in
8
+ //! `adapter.rs` because they depend on `command_on_path` /
9
+ //! `CaptureSessionContext` / sqlite session-store helpers. Step 3 of the
10
+ //! decoupling plan can move those into provider session store hooks.
11
+
12
+ use crate::model::enums::AuthMode;
13
+ use crate::provider::McpConfig;
14
+
15
+ pub(crate) fn copilot_base_command(
16
+ auth_mode: AuthMode,
17
+ mcp_config: Option<&McpConfig>,
18
+ system_prompt: Option<&str>,
19
+ model: Option<&str>,
20
+ tools: &[&str],
21
+ ) -> Vec<String> {
22
+ let _ = (auth_mode, system_prompt);
23
+ let mut argv = vec![
24
+ "copilot".to_string(),
25
+ // Noise control trio + disable remote control (防 GitHub web 远控 worker)
26
+ "--no-color".to_string(),
27
+ "--no-auto-update".to_string(),
28
+ "--no-remote".to_string(),
29
+ // P0: disable built-in github-mcp-server. Residual risk covered by
30
+ // spawn-time `copilot mcp list` scan + per-name --disable-mcp-server.
31
+ "--disable-builtin-mcps".to_string(),
32
+ ];
33
+ if copilot_dangerous_auto_approve(tools) {
34
+ argv.push("--allow-all".to_string());
35
+ } else {
36
+ for flag in copilot_permission_flags(tools) {
37
+ argv.push(flag);
38
+ }
39
+ }
40
+ // mcp_team ∈ canonical → approval-free (whole-server pattern).
41
+ argv.push("--allow-tool".to_string());
42
+ argv.push("team_orchestrator".to_string());
43
+ if let Some(model) = model {
44
+ argv.push("--model".to_string());
45
+ argv.push(model.to_string());
46
+ }
47
+ if let Some(config) = mcp_config {
48
+ // Copilot mcp config schema field is `transport` (stdio|http|sse),
49
+ // not the canonical `type`. McpConfig.raw is canonical; only copilot
50
+ // translates type→transport for --additional-mcp-config.
51
+ argv.push("--additional-mcp-config".to_string());
52
+ argv.push(copilot_translate_mcp_config(&config.raw).to_string());
53
+ }
54
+ argv
55
+ }
56
+
57
+ /// Translate `McpConfig.raw` canonical schema (`type`) into the copilot
58
+ /// `mcp add`/`--additional-mcp-config` expected `transport` field
59
+ /// (stdio|http|sse). Only the copilot adapter walks this translation —
60
+ /// claude/codex paths leave canonical schema untouched.
61
+ pub(crate) fn copilot_translate_mcp_config(raw: &serde_json::Value) -> serde_json::Value {
62
+ let Some(servers) = raw.as_object() else {
63
+ return raw.clone();
64
+ };
65
+ let mut translated = serde_json::Map::new();
66
+ for (name, server) in servers {
67
+ let Some(obj) = server.as_object() else {
68
+ translated.insert(name.clone(), server.clone());
69
+ continue;
70
+ };
71
+ let mut out = serde_json::Map::new();
72
+ for (key, value) in obj {
73
+ if key == "type" {
74
+ out.insert("transport".to_string(), value.clone());
75
+ } else {
76
+ out.insert(key.clone(), value.clone());
77
+ }
78
+ }
79
+ translated.insert(name.clone(), serde_json::Value::Object(out));
80
+ }
81
+ serde_json::Value::Object(translated)
82
+ }
83
+
84
+ /// Resume path = base + `--resume <sid>` (drop --session-id); kept separate
85
+ /// to avoid the plan accidentally emitting --session-id and --resume in the
86
+ /// same frame.
87
+ pub(crate) fn copilot_base_command_resume(
88
+ auth_mode: AuthMode,
89
+ mcp_config: Option<&McpConfig>,
90
+ system_prompt: Option<&str>,
91
+ model: Option<&str>,
92
+ tools: &[&str],
93
+ ) -> Vec<String> {
94
+ copilot_base_command(auth_mode, mcp_config, system_prompt, model, tools)
95
+ }
96
+
97
+ pub(crate) fn copilot_dangerous_auto_approve(tools: &[&str]) -> bool {
98
+ tools.contains(&"dangerous_auto_approve")
99
+ }
100
+
101
+ /// Granular deny mapping (canonical tool → copilot flag, all via
102
+ /// `--deny-tool <kind>`; help-permissions Tool Permissions has four kinds:
103
+ /// shell/write/mcp/url):
104
+ /// execute_bash ∉ allowed → `--deny-tool 'shell'`
105
+ /// fs_write ∉ allowed → `--deny-tool 'write'`
106
+ /// network ∉ allowed → `--deny-tool 'url'`
107
+ /// fs_read/fs_list have no copilot deny kind (honestly prompt_only).
108
+ pub(crate) fn copilot_permission_flags(tools: &[&str]) -> Vec<String> {
109
+ let mut flags = Vec::new();
110
+ if !tools.contains(&"execute_bash") {
111
+ flags.push("--deny-tool".to_string());
112
+ flags.push("shell".to_string());
113
+ }
114
+ if !tools.contains(&"fs_write") {
115
+ flags.push("--deny-tool".to_string());
116
+ flags.push("write".to_string());
117
+ }
118
+ if !tools.contains(&"network") {
119
+ // `--deny-tool 'url'` (omit domain → match all URLs).
120
+ flags.push("--deny-tool".to_string());
121
+ flags.push("url".to_string());
122
+ }
123
+ flags
124
+ }
@@ -0,0 +1,17 @@
1
+ //! Fake provider — scripted worker built into the team-agent CLI itself.
2
+ //! Extracted from `provider/adapter.rs` (0.4.x decoupling step 2).
3
+
4
+ pub(crate) fn fake_worker_command() -> Vec<String> {
5
+ let exe = std::env::current_exe()
6
+ .ok()
7
+ .and_then(|p| p.into_os_string().into_string().ok())
8
+ .unwrap_or_else(|| "team-agent".to_string());
9
+ vec![
10
+ exe,
11
+ "fake-worker".to_string(),
12
+ "--workspace".to_string(),
13
+ "{workspace}".to_string(),
14
+ "--agent-id".to_string(),
15
+ "{agent_id}".to_string(),
16
+ ]
17
+ }
@@ -0,0 +1,27 @@
1
+ //! Provider-local adapter implementations. Split from the monolithic
2
+ //! `provider/adapter.rs` as 0.4.x decoupling step 2. Each file owns its
3
+ //! provider's command builders, permission/sandbox/auth helpers, and
4
+ //! anything else that varies by provider but doesn't need shared
5
+ //! capture/scan utilities. The `ProviderAdapter` trait and the
6
+ //! `BasicProviderAdapter` registry stay in `adapter.rs`; the trait impl
7
+ //! dispatches into these helpers exactly as the inline forms did.
8
+ //!
9
+ //! Per-file scope:
10
+ //! * `claude` — Claude/ClaudeCode argv, dangerous-skip flag, disallowed
11
+ //! tools mapping, auth hint, model passthrough.
12
+ //! * `codex` — Codex argv, profile/sandbox flags, MCP `-c` injection
13
+ //! with 600s tool_timeout, developer-instructions escaping.
14
+ //! * `copilot` — Copilot argv (no-color/no-remote/disable-builtin-mcps),
15
+ //! allow/deny flag matrix, MCP `type→transport` translation, resume
16
+ //! base, weak auth hint.
17
+ //! * `fake` — Built-in scripted worker exec path.
18
+ //!
19
+ //! Behavior is byte-identical to pre-split. Future steps may move
20
+ //! `pre_spawn_adjust_plan`, `profile_env`, `session_backing_probe`, and
21
+ //! `session_candidates` into provider-owned hooks following the same
22
+ //! per-file layout.
23
+
24
+ pub(crate) mod claude;
25
+ pub(crate) mod codex;
26
+ pub(crate) mod copilot;
27
+ pub(crate) mod fake;
@@ -32,6 +32,9 @@
32
32
  pub use crate::model::enums::{AuthMode, Provider};
33
33
 
34
34
  pub mod adapter;
35
+ /// 0.4.x decoupling step 2: per-provider command builders + permission/auth
36
+ /// helpers split out of `adapter.rs`. Pure extraction — behavior unchanged.
37
+ pub(crate) mod adapters;
35
38
  pub mod approvals;
36
39
  pub mod classify;
37
40
  pub mod command;
@@ -40,6 +43,9 @@ pub mod session;
40
43
  pub mod session_scan;
41
44
  pub mod startup_prompt;
42
45
  pub mod types;
46
+ /// 0.4.x: provider wire-format helpers — single source of truth for
47
+ /// `Provider` ↔ string conversions (replaces 7 hand-rolled match copies).
48
+ pub(crate) mod wire;
43
49
  // helpers 全部原为模块私有(JSONL 解析 / 正则编译),非根可见 → 私有 mod,不参与 re-export。
44
50
  mod helpers;
45
51
 
@@ -296,7 +296,24 @@ where
296
296
  .get("rollout_path")
297
297
  .and_then(Value::as_str)
298
298
  .is_some_and(|s| !s.is_empty());
299
- if has_session {
299
+ // S1-CAPTURE-001 (0.4.8): a pending_session_id that differs from the
300
+ // on-row session_id means a fresh respawn is in flight — the prior
301
+ // worker's session_id may still be on the row (concurrent capture race,
302
+ // delayed persist, or persist-backfill replay) but the NEW worker has
303
+ // not yet been bound. Stamping capture_state=captured here would
304
+ // falsely declare the OLD session authoritative for the NEW worker
305
+ // pane — the leader/unassigned mis-attribution surfaced in S1-CAPTURE-001
306
+ // gate evidence. Only stamp `captured` when session_id agrees with
307
+ // _pending_session_id (or _pending is absent, meaning no rebind in
308
+ // flight).
309
+ let pending_mismatch = match (
310
+ agent_obj.get("_pending_session_id").and_then(Value::as_str).filter(|s| !s.is_empty()),
311
+ agent_obj.get("session_id").and_then(Value::as_str).filter(|s| !s.is_empty()),
312
+ ) {
313
+ (Some(pending), Some(current)) => pending != current,
314
+ _ => false,
315
+ };
316
+ if has_session && !pending_mismatch {
300
317
  // Captured already — fix state field if drifted.
301
318
  if agent_obj
302
319
  .get("capture_state")
@@ -684,6 +701,29 @@ fn agent_session_complete(agent: &Value) -> bool {
684
701
  if !session_id_ok {
685
702
  return false;
686
703
  }
704
+ // S1-CAPTURE-001 (0.4.8, CR M3 provider-agnostic): pending mismatch guard.
705
+ // When fresh-start has written a new `_pending_session_id` but the old
706
+ // authoritative `session_id` is still present and differs, the tuple is
707
+ // STALE — the new process has a different session that has not been
708
+ // captured yet. Returning true here would let capture skip this agent
709
+ // and keep treating the old transcript as authoritative (the gate's
710
+ // observed mis-attribution). Force re-capture by reporting incomplete.
711
+ // The fresh-tuple clear in mark_agent_started (lifecycle/restart/agent.rs)
712
+ // is the primary fix; this guard handles historical poison state where
713
+ // both fields already coexist from a prior version.
714
+ let pending = agent
715
+ .get("_pending_session_id")
716
+ .and_then(Value::as_str)
717
+ .filter(|s| !s.is_empty());
718
+ let current = agent
719
+ .get("session_id")
720
+ .and_then(Value::as_str)
721
+ .filter(|s| !s.is_empty());
722
+ if let (Some(pending), Some(current)) = (pending, current) {
723
+ if pending != current {
724
+ return false;
725
+ }
726
+ }
687
727
  let rollout_path = match agent
688
728
  .get("rollout_path")
689
729
  .and_then(Value::as_str)
@@ -1002,6 +1042,16 @@ fn apply_captured_session(
1002
1042
  serde_json::to_value(captured.attribution_confidence).unwrap_or(Value::Null),
1003
1043
  );
1004
1044
  agent_obj.remove("attribution_ambiguous");
1045
+ // S1-CAPTURE-001 (0.4.8): after writing the authoritative tuple, the
1046
+ // `_pending_session_id` placeholder is no longer needed — remove it so
1047
+ // future reads don't trip the pending-mismatch guard in
1048
+ // agent_session_complete. Also stamp capture_state="captured" for
1049
+ // diagnose/status observability.
1050
+ agent_obj.remove("_pending_session_id");
1051
+ agent_obj.insert(
1052
+ "capture_state".to_string(),
1053
+ serde_json::json!("captured"),
1054
+ );
1005
1055
  true
1006
1056
  }
1007
1057
 
@@ -1784,4 +1834,84 @@ mod u1_tests {
1784
1834
  let _ = std::fs::remove_dir_all(workspace.join(".team"));
1785
1835
  let _ = std::fs::remove_dir_all(&workspace);
1786
1836
  }
1837
+
1838
+ /// S1-CAPTURE-001 (0.4.8): pending mismatch guard. When fresh-start has
1839
+ /// written a new `_pending_session_id` but the old authoritative
1840
+ /// `session_id` is still present and differs, `agent_session_complete`
1841
+ /// must return false so capture re-runs and re-binds to the new process.
1842
+ /// Without this, the historical poison state (old + new coexist) lets
1843
+ /// the stale rollout look authoritative.
1844
+ #[test]
1845
+ fn agent_session_complete_returns_false_on_pending_mismatch() {
1846
+ let dir = std::env::temp_dir().join(format!(
1847
+ "ta-pending-mismatch-{}",
1848
+ std::process::id()
1849
+ ));
1850
+ let _ = std::fs::create_dir_all(&dir);
1851
+ let rollout = dir.join("old.jsonl");
1852
+ std::fs::write(&rollout, "{\"type\":\"assistant\"}\n").unwrap();
1853
+
1854
+ let agent = serde_json::json!({
1855
+ "provider": "claude",
1856
+ "session_id": "old",
1857
+ "rollout_path": rollout.to_string_lossy(),
1858
+ "captured_at": "2026-01-01T00:00:00+00:00",
1859
+ "captured_via": "fs_watch",
1860
+ "_pending_session_id": "new",
1861
+ });
1862
+
1863
+ assert!(
1864
+ !super::agent_session_complete(&agent),
1865
+ "S1-CAPTURE-001: pending != session_id must force incomplete \
1866
+ (capture must re-attribute to new session)"
1867
+ );
1868
+
1869
+ // Sanity: when they match, complete is true.
1870
+ let agent_match = serde_json::json!({
1871
+ "provider": "claude",
1872
+ "session_id": "new",
1873
+ "rollout_path": rollout.to_string_lossy(),
1874
+ "captured_at": "2026-01-01T00:00:00+00:00",
1875
+ "captured_via": "fs_watch",
1876
+ "_pending_session_id": "new",
1877
+ });
1878
+ assert!(
1879
+ super::agent_session_complete(&agent_match),
1880
+ "pending == session_id (in-progress capture confirmed): must be complete"
1881
+ );
1882
+
1883
+ let _ = std::fs::remove_file(&rollout);
1884
+ let _ = std::fs::remove_dir_all(&dir);
1885
+ }
1886
+
1887
+ /// S1-CAPTURE-001 (0.4.8): apply_captured_session clears
1888
+ /// `_pending_session_id` and stamps `capture_state="captured"` so the
1889
+ /// pending-mismatch guard doesn't fire on subsequent reads.
1890
+ #[test]
1891
+ fn apply_captured_session_clears_pending_and_stamps_capture_state() {
1892
+ let mut agent = serde_json::Map::new();
1893
+ agent.insert(
1894
+ "_pending_session_id".to_string(),
1895
+ serde_json::json!("new-sess"),
1896
+ );
1897
+ let captured = CapturedSession {
1898
+ session_id: Some(SessionId::new("new-sess")),
1899
+ rollout_path: Some(RolloutPath::new(PathBuf::from("/tmp/new.jsonl"))),
1900
+ captured_via: CaptureVia::FsWatch,
1901
+ attribution_confidence: Confidence::High,
1902
+ spawn_cwd: PathBuf::from("/tmp/cwd"),
1903
+ };
1904
+ let written = super::apply_captured_session(&mut agent, &captured);
1905
+ assert!(written, "apply must return true for valid captured");
1906
+ assert!(
1907
+ agent.get("_pending_session_id").is_none(),
1908
+ "S1-CAPTURE-001: _pending_session_id must be removed after capture \
1909
+ (agent={agent:?})"
1910
+ );
1911
+ assert_eq!(
1912
+ agent.get("capture_state").and_then(serde_json::Value::as_str),
1913
+ Some("captured"),
1914
+ "S1-CAPTURE-001: capture_state must be stamped 'captured'"
1915
+ );
1916
+ }
1787
1917
  }
@@ -0,0 +1,139 @@
1
+ //! Provider wire-format helpers — the single source of truth for converting
2
+ //! `Provider` ↔ string forms used across state JSON, CLI args, log fields,
3
+ //! and the workers we shell out to.
4
+ //!
5
+ //! This module replaces 7 hand-rolled copies of these match arms that lived
6
+ //! in `leader/helpers.rs`, `coordinator/tick.rs` (×2), `lifecycle/restart/common.rs`,
7
+ //! `lifecycle/profile_launch.rs`, `lifecycle/launch.rs`, `provider/adapter.rs`,
8
+ //! and `lifecycle/restart/rebuild.rs`. Centralising them removes the kind of
9
+ //! drift that left `restart/rebuild.rs` with a `claude-code` alias the other
10
+ //! sites silently dropped, and shrinks the surface area when a new provider
11
+ //! is added.
12
+ //!
13
+ //! Wire-format invariants (do not change without a coordinated migration):
14
+ //! * `provider_wire(Provider)` returns the canonical snake_case string used
15
+ //! in state JSON. These values are persisted on disk and read by older
16
+ //! runtimes — changing them is a breaking change.
17
+ //! * `parse_provider(&str)` accepts the canonical form AND every historical
18
+ //! alias we have ever written. New aliases go in `aliases()` and become
19
+ //! a parse target automatically.
20
+ //! * `command_name(Provider)` returns the binary name we exec for that
21
+ //! provider. `Claude` and `ClaudeCode` both shell out to `claude` —
22
+ //! they are distinct state values but share an executable.
23
+
24
+ use crate::model::enums::Provider;
25
+
26
+ /// Canonical snake_case wire string for `Provider`. Used for state JSON,
27
+ /// log fields, and any cross-process serialization. Stable: do not change
28
+ /// existing values.
29
+ pub(crate) fn provider_wire(provider: Provider) -> &'static str {
30
+ match provider {
31
+ Provider::Claude => "claude",
32
+ Provider::ClaudeCode => "claude_code",
33
+ Provider::Codex => "codex",
34
+ Provider::Copilot => "copilot",
35
+ Provider::GeminiCli => "gemini_cli",
36
+ Provider::Fake => "fake",
37
+ }
38
+ }
39
+
40
+ /// Parse a wire string back to `Provider`. Accepts the canonical
41
+ /// `provider_wire` output AND every historical alias listed by `aliases()`.
42
+ /// Returns `None` for unknown strings.
43
+ pub(crate) fn parse_provider(raw: &str) -> Option<Provider> {
44
+ for &provider in ALL_PROVIDERS {
45
+ for alias in aliases(provider) {
46
+ if *alias == raw {
47
+ return Some(provider);
48
+ }
49
+ }
50
+ }
51
+ None
52
+ }
53
+
54
+ /// All aliases (including the canonical wire form) that parse to `provider`.
55
+ /// The canonical wire form is always the first entry, so callers that need
56
+ /// the "primary" string can take `aliases(p)[0]` — though most code should
57
+ /// use `provider_wire(p)` directly for clarity.
58
+ pub(crate) fn aliases(provider: Provider) -> &'static [&'static str] {
59
+ match provider {
60
+ Provider::Claude => &["claude"],
61
+ // `claude-code` (kebab) is a historical alias that appeared in
62
+ // restart/rebuild.rs:1186-1194 only. Keeping it parseable means
63
+ // legacy state files still load.
64
+ Provider::ClaudeCode => &["claude_code", "claude-code"],
65
+ Provider::Codex => &["codex"],
66
+ Provider::Copilot => &["copilot"],
67
+ Provider::GeminiCli => &["gemini_cli"],
68
+ Provider::Fake => &["fake"],
69
+ }
70
+ }
71
+
72
+ /// Executable name we exec for the worker. `Claude` and `ClaudeCode` both
73
+ /// resolve to `claude` — the distinction is in state semantics
74
+ /// (`ClaudeCode` is the canonical CLI; `Claude` is a legacy alias kept for
75
+ /// older specs).
76
+ pub(crate) fn command_name(provider: Provider) -> &'static str {
77
+ match provider {
78
+ Provider::Claude | Provider::ClaudeCode => "claude",
79
+ Provider::Codex => "codex",
80
+ Provider::Copilot => "copilot",
81
+ Provider::GeminiCli => "gemini",
82
+ Provider::Fake => "team-agent",
83
+ }
84
+ }
85
+
86
+ const ALL_PROVIDERS: &[Provider] = &[
87
+ Provider::Claude,
88
+ Provider::ClaudeCode,
89
+ Provider::Codex,
90
+ Provider::Copilot,
91
+ Provider::GeminiCli,
92
+ Provider::Fake,
93
+ ];
94
+
95
+ #[cfg(test)]
96
+ mod tests {
97
+ use super::*;
98
+
99
+ #[test]
100
+ fn wire_round_trip_for_every_provider() {
101
+ for &p in ALL_PROVIDERS {
102
+ let wire = provider_wire(p);
103
+ assert_eq!(parse_provider(wire), Some(p), "round-trip {p:?}");
104
+ }
105
+ }
106
+
107
+ #[test]
108
+ fn claude_code_kebab_alias_parses() {
109
+ // Historical alias preserved from restart/rebuild.rs:1186-1194.
110
+ assert_eq!(parse_provider("claude-code"), Some(Provider::ClaudeCode));
111
+ }
112
+
113
+ #[test]
114
+ fn unknown_string_returns_none() {
115
+ assert_eq!(parse_provider(""), None);
116
+ assert_eq!(parse_provider("openai"), None);
117
+ assert_eq!(parse_provider("CLAUDE"), None); // case-sensitive
118
+ }
119
+
120
+ #[test]
121
+ fn aliases_first_entry_is_canonical_wire() {
122
+ for &p in ALL_PROVIDERS {
123
+ assert_eq!(aliases(p)[0], provider_wire(p), "canonical first for {p:?}");
124
+ }
125
+ }
126
+
127
+ #[test]
128
+ fn command_name_claude_and_claude_code_both_resolve_to_claude_binary() {
129
+ assert_eq!(command_name(Provider::Claude), "claude");
130
+ assert_eq!(command_name(Provider::ClaudeCode), "claude");
131
+ }
132
+
133
+ #[test]
134
+ fn command_name_covers_every_provider() {
135
+ for &p in ALL_PROVIDERS {
136
+ assert!(!command_name(p).is_empty(), "{p:?} has no command name");
137
+ }
138
+ }
139
+ }
@@ -160,10 +160,14 @@ pub enum CommandScope {
160
160
  /// Caller passed `--team X` explicitly (or there's only one alive team
161
161
  /// and we resolved it). Carries the canonical team_key.
162
162
  Resolved(String),
163
- /// No `--team` and multiple alive teams. Destructive commands MUST
164
- /// refuse with this list of candidates so the operator chooses
165
- /// explicitly. Read-only commands may still proceed (their scope is
166
- /// "all teams" by default).
163
+ /// No `--team` and multiple alive teams. Destructive commands AND
164
+ /// selected-team commands (status, etc.) MUST refuse with this list
165
+ /// of candidates so the operator chooses explicitly. Only true
166
+ /// all-team aggregation commands may proceed without --team.
167
+ /// (S4QR-001 0.4.8: status was previously documented as a read-only
168
+ /// command that could proceed; in fact it projects a single team's
169
+ /// view and silently defaulting to the active team caused gate
170
+ /// failures when multiple alive teams existed.)
167
171
  Ambiguous(Vec<String>),
168
172
  /// No `--team` and no teams alive yet (fresh workspace) — bare
169
173
  /// commands fall through to legacy single-team behaviour.
@@ -560,6 +560,23 @@ fn backfill_capture_fields(incoming_agent: &mut Value, latest_agent: &Value) {
560
560
  if !latest_complete {
561
561
  return;
562
562
  }
563
+ // S1-CAPTURE-001 (0.4.8) Rule 5: a fresh restart marker (`_pending_session_id`
564
+ // present on incoming) signals the worker just respawned and the prior
565
+ // tuple was intentionally cleared. The capture scanner is the only path
566
+ // permitted to promote `_pending_session_id` into the authoritative tuple
567
+ // after it confirms backing. Persist-side backfill MUST NOT revive the
568
+ // latest tuple in this state — doing so resurrects the previous worker's
569
+ // session/rollout pair and delivered tokens land in the OLD transcript
570
+ // (the leader/unassigned mis-attribution surfaced in S1-CAPTURE-001 gate
571
+ // evidence). Refuse backfill regardless of whether incoming session_id
572
+ // is null vs latest's value: the pending marker is authoritative intent.
573
+ let incoming_pending = incoming_row
574
+ .get("_pending_session_id")
575
+ .and_then(Value::as_str)
576
+ .filter(|s| !s.is_empty());
577
+ if incoming_pending.is_some() {
578
+ return;
579
+ }
563
580
  // Rule 2: incoming carries a DIFFERENT non-null session_id → do not mix.
564
581
  let incoming_session = incoming_row.get("session_id").and_then(Value::as_str);
565
582
  let latest_session = latest_agent.get("session_id").and_then(Value::as_str);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.4.6",
24
- "@team-agent/cli-darwin-x64": "0.4.6",
25
- "@team-agent/cli-linux-x64": "0.4.6"
23
+ "@team-agent/cli-darwin-arm64": "0.4.8",
24
+ "@team-agent/cli-darwin-x64": "0.4.8",
25
+ "@team-agent/cli-linux-x64": "0.4.8"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",
@@ -18,6 +18,12 @@ team-agent claude
18
18
 
19
19
  Pass provider flags after the provider name, for example `team-agent codex --dangerously-bypass-approvals-and-sandbox`. Existing tmux layouts are valid too, including Finder/Ghostty launchers, as long as `team-agent quick-start` is invoked from the leader's current tmux pane. Do not start a real team from a naked terminal that Team Agent cannot address through tmux.
20
20
 
21
+ ## Leader Role
22
+
23
+ Invoking this skill turns the current agent into the team leader. The leader **orchestrates**: read reports, set direction, decompose work, dispatch tasks to teammates, review results, and decide. The leader does **not** execute hands-on work — no `cargo test`, no product-code edits, no `git push`, no build/verify cycles. Those belong to teammates. If the leader catches themselves running tests, editing source files, or pushing commits, they have stepped out of role; stop and re-dispatch.
24
+
25
+ When the user has been communicating in Chinese throughout the conversation, all leader↔teammate messaging (`send`, `report_result`, MCP messages, task descriptions) must also be in Chinese. The leader dispatches in Chinese, the worker reports back in Chinese. Switch back to the user's language only at the user-facing boundary.
26
+
21
27
  ## Minimal Copy-Paste Team
22
28
 
23
29
  ```bash