@team-agent/installer 0.4.5 → 0.4.7

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 +6 -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/tests/health_sync.rs +6 -1
  7. package/crates/team-agent/src/coordinator/tick.rs +46 -21
  8. package/crates/team-agent/src/leader/helpers.rs +1 -22
  9. package/crates/team-agent/src/lifecycle/launch.rs +34 -17
  10. package/crates/team-agent/src/lifecycle/profile_launch.rs +1 -11
  11. package/crates/team-agent/src/lifecycle/restart/agent.rs +160 -0
  12. package/crates/team-agent/src/lifecycle/restart/common.rs +82 -22
  13. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +108 -50
  14. package/crates/team-agent/src/lifecycle/restart/selection.rs +23 -0
  15. package/crates/team-agent/src/lifecycle/tests/core.rs +19 -11
  16. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +23 -1
  17. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +163 -15
  18. package/crates/team-agent/src/provider/adapter.rs +217 -377
  19. package/crates/team-agent/src/provider/adapters/claude.rs +106 -0
  20. package/crates/team-agent/src/provider/adapters/codex.rs +117 -0
  21. package/crates/team-agent/src/provider/adapters/copilot.rs +124 -0
  22. package/crates/team-agent/src/provider/adapters/fake.rs +17 -0
  23. package/crates/team-agent/src/provider/adapters/mod.rs +27 -0
  24. package/crates/team-agent/src/provider/mod.rs +6 -0
  25. package/crates/team-agent/src/provider/session/capture.rs +593 -55
  26. package/crates/team-agent/src/provider/wire.rs +139 -0
  27. package/crates/team-agent/src/state/persist.rs +225 -10
  28. package/package.json +4 -4
  29. package/skills/team-agent/SKILL.md +6 -0
@@ -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
+ }
@@ -519,28 +519,62 @@ fn preserve_missing_agents(
519
519
  }
520
520
  }
521
521
 
522
- /// A0/R2: session-capture fields are written by another process (capture/update_state)
523
- /// between a writer's load and save; a stale incoming row must not regress them to null.
524
- /// Monotonic backfill ONLY for the capture field family (no generic deep-merge, so a
525
- /// deliberate field clear elsewhere is not masked).
522
+ /// 0.4.6 tuple-atomic backfill (restart-persist-capture-contract-audit.md):
523
+ /// the authoritative session tuple is `session_id + rollout_path +
524
+ /// captured_at + captured_via`. `attribution_confidence` is metadata on
525
+ /// that tuple. Persist may preserve an already-complete tuple across stale
526
+ /// saves, but it must NEVER:
527
+ /// * synthesise a partial tuple (e.g. copy `session_id` while the other
528
+ /// three are absent — this caused bug-045 by resurrecting an
529
+ /// intentionally cleared session id);
530
+ /// * mix tuple fields across two different session_ids (one writer's
531
+ /// `session_id` glued to another writer's `rollout_path`);
532
+ /// * create non-null session truth where none existed.
533
+ ///
534
+ /// Rules:
535
+ /// 1. If LATEST has a complete tuple AND INCOMING has the same `session_id`
536
+ /// (or null), backfill the full tuple together — concurrent capture
537
+ /// protection (A0/R2).
538
+ /// 2. If LATEST has a complete tuple AND INCOMING has a DIFFERENT non-null
539
+ /// `session_id`, copy nothing (incoming is a different worker session
540
+ /// identity; the latest tuple belongs to the previous identity).
541
+ /// 3. If LATEST tuple is INCOMPLETE (any of the 4 fields null), copy
542
+ /// nothing for the tuple. An incomplete latest tuple is not
543
+ /// authoritative truth, so persist cannot use it as backfill source.
544
+ /// 4. `attribution_confidence` rides with the tuple (copied iff tuple
545
+ /// backfill fires).
526
546
  fn backfill_capture_fields(incoming_agent: &mut Value, latest_agent: &Value) {
527
- const CAPTURE_FIELDS: [&str; 5] = [
547
+ const TUPLE_FIELDS: [&str; 4] = [
528
548
  "session_id",
529
549
  "rollout_path",
530
550
  "captured_at",
531
551
  "captured_via",
532
- "attribution_confidence",
533
552
  ];
534
553
  let Some(incoming_row) = incoming_agent.as_object_mut() else {
535
554
  return;
536
555
  };
556
+ // Rule 3: latest tuple must be COMPLETE to be a backfill source.
557
+ let latest_complete = TUPLE_FIELDS
558
+ .iter()
559
+ .all(|field| latest_agent.get(field).is_some_and(|v| !v.is_null()));
560
+ if !latest_complete {
561
+ return;
562
+ }
563
+ // Rule 2: incoming carries a DIFFERENT non-null session_id → do not mix.
537
564
  let incoming_session = incoming_row.get("session_id").and_then(Value::as_str);
538
565
  let latest_session = latest_agent.get("session_id").and_then(Value::as_str);
539
- let session_changed = incoming_session.is_some() && incoming_session != latest_session;
540
- for field in CAPTURE_FIELDS {
541
- if session_changed && field != "session_id" {
542
- continue;
566
+ if let Some(inc) = incoming_session {
567
+ if Some(inc) != latest_session {
568
+ return;
543
569
  }
570
+ }
571
+ // Rule 1 + 4: copy the full tuple together (only fields incoming has as
572
+ // null/missing) plus attribution_confidence.
573
+ for field in TUPLE_FIELDS
574
+ .iter()
575
+ .copied()
576
+ .chain(std::iter::once("attribution_confidence"))
577
+ {
544
578
  if incoming_row.get(field).is_none_or(Value::is_null) {
545
579
  if let Some(value) = latest_agent.get(field).filter(|value| !value.is_null()) {
546
580
  incoming_row.insert(field.to_string(), value.clone());
@@ -1313,4 +1347,185 @@ mod tests {
1313
1347
  "migration must be persisted to disk so subsequent processes see it"
1314
1348
  );
1315
1349
  }
1350
+
1351
+ /// 0.4.6 tuple-atomic contract test #1 (audit §Required Tests, line 264):
1352
+ /// latest has `session_id` only (PARTIAL TUPLE), incoming has null
1353
+ /// tuple → no backfill. This is bug-045's core failure mode: persist
1354
+ /// must not resurrect a scalar session_id when the latest row's tuple
1355
+ /// is incomplete.
1356
+ #[test]
1357
+ fn backfill_capture_skips_when_latest_tuple_is_partial() {
1358
+ let mut incoming = json!({
1359
+ "session_id": Value::Null,
1360
+ "rollout_path": Value::Null,
1361
+ "captured_at": Value::Null,
1362
+ "captured_via": Value::Null,
1363
+ });
1364
+ let latest = json!({
1365
+ "session_id": "partial-only-uuid",
1366
+ "rollout_path": Value::Null,
1367
+ "captured_at": Value::Null,
1368
+ "captured_via": Value::Null,
1369
+ });
1370
+ backfill_capture_fields(&mut incoming, &latest);
1371
+ assert_eq!(
1372
+ incoming.get("session_id"),
1373
+ Some(&Value::Null),
1374
+ "0.4.6: partial latest tuple must NOT backfill session_id; got {incoming}"
1375
+ );
1376
+ }
1377
+
1378
+ /// 0.4.6 tuple-atomic contract test #2 (audit, line 265):
1379
+ /// latest has complete tuple, incoming has null tuple → all 4 fields
1380
+ /// backfilled together. This is the concurrent-capture protection
1381
+ /// (A0/R2) that must still work after the tuple-atomic rewrite.
1382
+ #[test]
1383
+ fn backfill_capture_atomically_copies_complete_tuple() {
1384
+ let mut incoming = json!({
1385
+ "session_id": Value::Null,
1386
+ "rollout_path": Value::Null,
1387
+ "captured_at": Value::Null,
1388
+ "captured_via": Value::Null,
1389
+ });
1390
+ let latest = json!({
1391
+ "session_id": "real-uuid",
1392
+ "rollout_path": "/tmp/real.jsonl",
1393
+ "captured_at": "2026-06-25T10:00:00+00:00",
1394
+ "captured_via": "session.captured",
1395
+ "attribution_confidence": "high",
1396
+ });
1397
+ backfill_capture_fields(&mut incoming, &latest);
1398
+ assert_eq!(
1399
+ incoming.get("session_id").and_then(Value::as_str),
1400
+ Some("real-uuid")
1401
+ );
1402
+ assert_eq!(
1403
+ incoming.get("rollout_path").and_then(Value::as_str),
1404
+ Some("/tmp/real.jsonl")
1405
+ );
1406
+ assert_eq!(
1407
+ incoming.get("captured_at").and_then(Value::as_str),
1408
+ Some("2026-06-25T10:00:00+00:00")
1409
+ );
1410
+ assert_eq!(
1411
+ incoming.get("captured_via").and_then(Value::as_str),
1412
+ Some("session.captured")
1413
+ );
1414
+ assert_eq!(
1415
+ incoming.get("attribution_confidence").and_then(Value::as_str),
1416
+ Some("high"),
1417
+ "attribution_confidence rides with the tuple"
1418
+ );
1419
+ }
1420
+
1421
+ /// 0.4.6 tuple-atomic contract test #3 (audit, line 266):
1422
+ /// latest has complete tuple for one session_id, incoming has non-null
1423
+ /// DIFFERENT session_id → no mixed tuple. Prevents one writer's
1424
+ /// session_id getting glued to another writer's rollout_path/captured_at.
1425
+ #[test]
1426
+ fn backfill_capture_refuses_mixing_different_session_ids() {
1427
+ let mut incoming = json!({
1428
+ "session_id": "incoming-different-uuid",
1429
+ "rollout_path": Value::Null,
1430
+ "captured_at": Value::Null,
1431
+ "captured_via": Value::Null,
1432
+ });
1433
+ let latest = json!({
1434
+ "session_id": "latest-uuid",
1435
+ "rollout_path": "/tmp/latest.jsonl",
1436
+ "captured_at": "2026-06-25T10:00:00+00:00",
1437
+ "captured_via": "session.captured",
1438
+ });
1439
+ backfill_capture_fields(&mut incoming, &latest);
1440
+ // session_id stays as incoming (not overwritten).
1441
+ assert_eq!(
1442
+ incoming.get("session_id").and_then(Value::as_str),
1443
+ Some("incoming-different-uuid"),
1444
+ "0.4.6: incoming session_id must not be overwritten by a different latest"
1445
+ );
1446
+ // Sibling fields stay null (no cross-session mixing).
1447
+ assert_eq!(
1448
+ incoming.get("rollout_path"),
1449
+ Some(&Value::Null),
1450
+ "0.4.6: must not copy latest rollout_path onto a different session_id"
1451
+ );
1452
+ }
1453
+
1454
+ /// 0.4.6 tuple-atomic contract test #4 (audit, line 267):
1455
+ /// `normalize_agent_session_state` may only insert null defaults — it
1456
+ /// must never create non-null session truth.
1457
+ #[test]
1458
+ fn normalize_agent_session_state_only_inserts_null_defaults() {
1459
+ let mut state = json!({
1460
+ "agents": {
1461
+ "alpha": {}
1462
+ }
1463
+ });
1464
+ normalize_agent_session_state(&mut state);
1465
+ let alpha = state.pointer("/agents/alpha").expect("alpha");
1466
+ for field in [
1467
+ "session_id",
1468
+ "rollout_path",
1469
+ "captured_at",
1470
+ "captured_via",
1471
+ "attribution_confidence",
1472
+ "spawn_cwd",
1473
+ ] {
1474
+ assert_eq!(
1475
+ alpha.get(field),
1476
+ Some(&Value::Null),
1477
+ "normalize must insert null for {field}; got {alpha}"
1478
+ );
1479
+ }
1480
+ }
1481
+
1482
+ /// Bug 045 end-to-end persist regression: poisoned latest (session_id
1483
+ /// but no captured_at/captured_via) cannot resurrect a cleared incoming
1484
+ /// row through a real save → reload cycle.
1485
+ #[test]
1486
+ fn poisoned_partial_latest_does_not_resurrect_on_save_reload() {
1487
+ let ws = temp_ws();
1488
+ // Seed disk with the exact bug-045 poisoned shape: session_id set,
1489
+ // no captured_at / captured_via / rollout_path.
1490
+ let poisoned = json!({
1491
+ "session_name": "team-bug045-poisoned",
1492
+ "team_key": "teamP",
1493
+ "agents": {
1494
+ "alpha": {
1495
+ "status": "stopped",
1496
+ "provider": "claude",
1497
+ "session_id": "stale-poisoned-uuid",
1498
+ "rollout_path": Value::Null,
1499
+ "captured_at": Value::Null,
1500
+ "captured_via": Value::Null
1501
+ }
1502
+ }
1503
+ });
1504
+ save_runtime_state(&ws, &poisoned).unwrap();
1505
+
1506
+ // Restart-fresh writes a cleared row.
1507
+ let cleared = json!({
1508
+ "session_name": "team-bug045-poisoned",
1509
+ "team_key": "teamP",
1510
+ "agents": {
1511
+ "alpha": {
1512
+ "status": "running",
1513
+ "provider": "claude",
1514
+ "session_id": Value::Null,
1515
+ "rollout_path": Value::Null,
1516
+ "captured_at": Value::Null,
1517
+ "captured_via": Value::Null
1518
+ }
1519
+ }
1520
+ });
1521
+ save_runtime_state(&ws, &cleared).unwrap();
1522
+
1523
+ let on_disk = read_state(&ws);
1524
+ let alpha = on_disk.pointer("/agents/alpha").expect("alpha");
1525
+ assert_eq!(
1526
+ alpha.get("session_id"),
1527
+ Some(&Value::Null),
1528
+ "0.4.6: partial poisoned latest must not resurrect cleared session_id; got {alpha}"
1529
+ );
1530
+ }
1316
1531
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
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.5",
24
- "@team-agent/cli-darwin-x64": "0.4.5",
25
- "@team-agent/cli-linux-x64": "0.4.5"
23
+ "@team-agent/cli-darwin-arm64": "0.4.7",
24
+ "@team-agent/cli-darwin-x64": "0.4.7",
25
+ "@team-agent/cli-linux-x64": "0.4.7"
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