@team-agent/installer 0.5.99 → 0.5.101

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 (47) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -156
  4. package/crates/team-agent/src/cli/diagnose.rs +32 -10
  5. package/crates/team-agent/src/cli/emit.rs +6 -185
  6. package/crates/team-agent/src/cli/mod.rs +32 -182
  7. package/crates/team-agent/src/cli/send/coordinator.rs +2 -2
  8. package/crates/team-agent/src/cli/spec.rs +41 -67
  9. package/crates/team-agent/src/cli/tests/main_preserved.rs +2 -185
  10. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +9 -9
  11. package/crates/team-agent/src/cli/triage.rs +1 -1
  12. package/crates/team-agent/src/cli/types.rs +0 -49
  13. package/crates/team-agent/src/compiler.rs +3 -0
  14. package/crates/team-agent/src/leader/lease.rs +1 -1
  15. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +211 -16
  16. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +1 -0
  17. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +15 -5
  18. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +89 -6
  19. package/crates/team-agent/src/lifecycle/launch/role_source.rs +168 -32
  20. package/crates/team-agent/src/lifecycle/launch.rs +11 -3
  21. package/crates/team-agent/src/lifecycle/profile_launch.rs +23 -8
  22. package/crates/team-agent/src/lifecycle/restart/agent.rs +2 -2
  23. package/crates/team-agent/src/lifecycle/restart/avatar.rs +1806 -0
  24. package/crates/team-agent/src/lifecycle/restart/common.rs +114 -1
  25. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +14 -8
  26. package/crates/team-agent/src/lifecycle/restart/selection.rs +14 -2
  27. package/crates/team-agent/src/lifecycle/restart.rs +11 -4
  28. package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +2 -2
  29. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +1 -1
  30. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +9 -2
  31. package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +1398 -0
  32. package/crates/team-agent/src/lifecycle/tests/realmachine_clusters_1_6_red.rs +0 -60
  33. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +2 -2
  34. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  35. package/crates/team-agent/src/lifecycle/types.rs +10 -2
  36. package/crates/team-agent/src/messaging/send.rs +2 -2
  37. package/crates/team-agent/src/model/spec.rs +1 -0
  38. package/crates/team-agent/src/provider/types.rs +3 -1
  39. package/crates/team-agent/src/state/persist.rs +58 -2
  40. package/crates/team-agent/src/state/projection.rs +5 -1
  41. package/crates/team-agent/src/state/repository.rs +125 -9
  42. package/crates/team-agent/src/transport/test_support.rs +23 -3
  43. package/package.json +4 -4
  44. package/skills/team-agent/SKILL.md +17 -46
  45. package/skills/team-agent/references/recovery-runbook.md +2 -2
  46. package/skills/team-agent/references/team-agent-operator.md +4 -2
  47. package/skills/team-agent/references/team-in-team.md +0 -6
@@ -106,92 +106,6 @@ fn seed_team_spec(ws: &std::path::Path) {
106
106
  std::fs::write(ws.join("team.spec.yaml"), spec).unwrap();
107
107
  }
108
108
 
109
- // ── ACK-CRACK [P1 byte-shape] — acknowledge_idle must write golden's TTL suppression shape ───────
110
- // golden runtime.py:680-688: manual-acknowledge persists
111
- // coordinator.idle_acknowledged[team] = {acknowledged_at, expires_at, ttl_seconds}
112
- // coordinator.suppressed_idle_alerts[team][worker].idle_fallback =
113
- // {suppressed_at, suppressed_by:"manual_acknowledge", manual_acknowledge:true, expires_at, ttl_seconds}
114
- // The clear logic does datetime.fromisoformat(entry["expires_at"]); a MISSING expires_at -> ValueError
115
- // -> "invalid_suppression_timestamp" -> immediate self-clear (latent crack once detect_idle_fallbacks
116
- // is ported). So BOTH idle_acknowledged and the entry MUST carry a non-empty expires_at.
117
- #[test]
118
- fn acknowledge_idle_writes_golden_ttl_suppression_shape() {
119
- let ws = tmp_workspace();
120
- crate::state::persist::save_runtime_state(
121
- &ws,
122
- &serde_json::json!({
123
- "active_team_key": "teamX",
124
- "agents": {"w1": {"status": "running", "provider": "codex"}}
125
- }),
126
- )
127
- .unwrap();
128
- let _ = lifecycle_port::acknowledge_idle(&ws, None).expect("acknowledge_idle ok");
129
- let state = crate::state::persist::load_runtime_state(&ws).unwrap();
130
- let ack = &state["coordinator"]["idle_acknowledged"]["teamX"];
131
- assert!(
132
- ack.get("expires_at")
133
- .and_then(serde_json::Value::as_str)
134
- .is_some_and(|s| !s.is_empty()),
135
- "ACK-CRACK: idle_acknowledged[team] must carry a non-empty expires_at (golden); got {ack}"
136
- );
137
- assert!(
138
- ack.get("ttl_seconds").is_some(),
139
- "idle_acknowledged[team] must carry ttl_seconds; got {ack}"
140
- );
141
- let entry = &state["coordinator"]["suppressed_idle_alerts"]["teamX"]["w1"]["idle_fallback"];
142
- assert!(
143
- entry.get("expires_at").and_then(serde_json::Value::as_str).is_some_and(|s| !s.is_empty()),
144
- "ACK-CRACK: the manual-ack suppression entry must carry expires_at (else clear logic ValueErrors \
145
- -> instant self-clear); got {entry}"
146
- );
147
- assert_eq!(
148
- entry["suppressed_by"],
149
- serde_json::json!("manual_acknowledge"),
150
- "golden suppressed_by; got {entry}"
151
- );
152
- assert_eq!(
153
- entry["manual_acknowledge"],
154
- serde_json::json!(true),
155
- "golden manual_acknowledge:true; got {entry}"
156
- );
157
- }
158
- // ── ACK return-shape [P1 byte-parity] — acknowledge_idle must RETURN golden's keys ───────────────
159
- // golden runtime.py:691: return {ok, team, agent_id, acknowledged_at, expires_at, ttl_seconds}.
160
- // Rust (cli/mod.rs) returns only {ok, team, ttl_seconds} -> missing agent_id, acknowledged_at,
161
- // expires_at. RED. (acknowledged_at/expires_at are the same values written into idle_acknowledged.)
162
- #[test]
163
- fn acknowledge_idle_return_carries_golden_keys() {
164
- let ws = tmp_workspace();
165
- crate::state::persist::save_runtime_state(
166
- &ws,
167
- &serde_json::json!({ "active_team_key": "teamX", "agents": {"w1": {"status": "running", "provider": "codex"}} }),
168
- )
169
- .unwrap();
170
- let r = lifecycle_port::acknowledge_idle(&ws, None).expect("acknowledge_idle ok");
171
- let obj = r.as_object().expect("ack returns a dict");
172
- for key in [
173
- "ok",
174
- "team",
175
- "agent_id",
176
- "acknowledged_at",
177
- "expires_at",
178
- "ttl_seconds",
179
- ] {
180
- assert!(
181
- obj.contains_key(key),
182
- "ACK return-shape: golden return carries `{key}` (runtime.py:691: ok/team/agent_id/\
183
- acknowledged_at/expires_at/ttl_seconds); Rust omits it. got keys {:?}",
184
- obj.keys().collect::<Vec<_>>()
185
- );
186
- }
187
- assert!(
188
- obj.get("expires_at")
189
- .and_then(serde_json::Value::as_str)
190
- .is_some_and(|s| !s.is_empty()),
191
- "ACK return-shape: expires_at must be a non-empty timestamp; got {r}"
192
- );
193
- let _ = std::fs::remove_dir_all(&ws);
194
- }
195
109
  // ── BUG-2 [real bug] — inbox must RETURN the stored messages, not a hardcoded []. ────────────────
196
110
  // Golden status/inbox.py:35-38 -> MessageStore.inbox(agent_id) (core.py:242, owner_team_id=None):
197
111
  // select <MESSAGE_SELECT> from messages where sender = ? or recipient = ? order by created_at desc
@@ -680,16 +594,7 @@ fn remove_agent_from_spec_refusal_is_not_success_envelope() {
680
594
  fn stuck_cancel_persists_suppression_and_stuck_list_reads_state() {
681
595
  let ws = tmp_workspace();
682
596
  seed_collect_state(&ws);
683
- let out = json_output(
684
- cmd_stuck_cancel(&StuckCancelArgs {
685
- agent: "fake_impl".to_string(),
686
- workspace: ws.clone(),
687
- alert_type: None,
688
- json: true,
689
- team: None,
690
- })
691
- .unwrap(),
692
- );
597
+ let out = messaging::stuck_cancel(&ws, "fake_impl", None, "leader").unwrap();
693
598
  let team_key = seeded_team_key(&ws);
694
599
  assert_eq!(out["ok"], json!(true));
695
600
  assert_eq!(
@@ -715,98 +620,10 @@ fn stuck_cancel_persists_suppression_and_stuck_list_reads_state() {
715
620
  && e["agent_id"] == json!("fake_impl")),
716
621
  "stuck_cancel must write coordinator.idle_alert_suppressed"
717
622
  );
718
- let listed = json_output(
719
- cmd_stuck_list(&StuckListArgs {
720
- workspace: ws.clone(),
721
- json: true,
722
- team: None,
723
- })
724
- .unwrap(),
725
- );
623
+ let listed = messaging::stuck_list(&ws).unwrap();
726
624
  assert_eq!(
727
625
  listed["suppressed_idle_alerts"]["fake_impl"]["stuck"]["suppressed_by"],
728
626
  json!("leader"),
729
627
  "stuck-list must read the persisted state mirror, not return a hard-coded empty list"
730
628
  );
731
629
  }
732
-
733
- #[test]
734
- fn stuck_cancel_explicit_team_is_rejected_until_backend_is_scoped() {
735
- let ws = tmp_workspace();
736
- seed_collect_state(&ws);
737
- let err = cmd_stuck_cancel(&StuckCancelArgs {
738
- agent: "fake_impl".to_string(),
739
- workspace: ws.clone(),
740
- alert_type: None,
741
- json: true,
742
- team: Some("current".to_string()),
743
- })
744
- .expect_err("explicit --team must not silently write global stuck suppression");
745
- assert!(
746
- err.to_string().contains("not supported yet"),
747
- "stuck-cancel --team must be an explicit refusal until backend supports scoped writes; got {err}"
748
- );
749
- let _ = std::fs::remove_dir_all(&ws);
750
- }
751
-
752
- #[test]
753
- fn stuck_cancel_invalid_alert_type_is_rejected() {
754
- let ws = tmp_workspace();
755
- seed_collect_state(&ws);
756
- let code = run(
757
- &cli_argv(&[
758
- "stuck-cancel",
759
- "fake_impl",
760
- "--workspace",
761
- &ws.to_string_lossy(),
762
- "--alert-type",
763
- "bogus",
764
- "--json",
765
- ]),
766
- &ws,
767
- );
768
- assert_eq!(
769
- code,
770
- ExitCode::Error,
771
- "Python rejects alert_type outside stuck/idle_fallback/cross_worker_deadlock/all; Rust must not silently coerce bogus to stuck"
772
- );
773
- }
774
- #[test]
775
- fn acknowledge_idle_records_manual_idle_fallback_suppression_and_event() {
776
- let ws = tmp_workspace();
777
- seed_collect_state(&ws);
778
- let out = json_output(
779
- cmd_acknowledge_idle(&AcknowledgeIdleArgs {
780
- team: None,
781
- workspace: ws.clone(),
782
- json: true,
783
- })
784
- .unwrap(),
785
- );
786
- let team_key = seeded_team_key(&ws);
787
- assert_eq!(out["ok"], json!(true));
788
- assert_eq!(out["team"], json!(team_key));
789
- assert_eq!(out["ttl_seconds"], json!(1800));
790
- let state = read_state(&ws);
791
- let ack = &state["coordinator"]["idle_acknowledged"][&team_key];
792
- assert_eq!(ack["ttl_seconds"], json!(1800));
793
- assert!(ack["acknowledged_at"].as_str().is_some());
794
- assert_eq!(
795
- state["coordinator"]["suppressed_idle_alerts"][&team_key]["fake_impl"]["idle_fallback"]
796
- ["suppressed_by"],
797
- json!("manual_acknowledge")
798
- );
799
- assert_eq!(
800
- state["coordinator"]["suppressed_idle_alerts"][&team_key]["fake_impl"]["idle_fallback"]
801
- ["manual_acknowledge"],
802
- json!(true)
803
- );
804
- assert!(
805
- read_events(&ws)
806
- .iter()
807
- .any(|e| e["event"] == json!("coordinator.idle_acknowledged")
808
- && e["team"] == json!(team_key)
809
- && e["ttl_seconds"] == json!(1800)),
810
- "acknowledge-idle must emit coordinator.idle_acknowledged"
811
- );
812
- }
@@ -332,15 +332,15 @@ fn dispatch_routes_diagnose_checks_runtime_beyond_healthy_leader() {
332
332
  leader_env: Default::default(),
333
333
  }]);
334
334
  crate::transport_factory::with_leader_endpoint_transport(endpoint, transport, || {
335
- for command in ["doctor", "diagnose"] {
336
- let code = run(
337
- &cli_argv(&[command, "--workspace", &ws.to_string_lossy(), "--json"]),
338
- &ws,
339
- );
340
- assert_eq!(code, ExitCode::Error, "healthy binding alone cannot hide missing coordinator");
341
- }
342
- let result = cmd_diagnose(&DiagnoseArgs {
335
+ let code = run(
336
+ &cli_argv(&["doctor", "--workspace", &ws.to_string_lossy(), "--json"]),
337
+ &ws,
338
+ );
339
+ assert_eq!(code, ExitCode::Error, "healthy binding alone cannot hide missing coordinator");
340
+ let result = cmd_doctor(&DoctorArgs {
343
341
  workspace: ws.clone(), json: true, team: None,
342
+ spec: None, gate: None, comms: false, fix: false,
343
+ fix_schema: false, cleanup_orphans: false, confirm: false,
344
344
  }).expect("diagnostic report");
345
345
  let CmdOutput::Json(report) = result.output else {
346
346
  panic!("expected JSON diagnostic report");
@@ -377,7 +377,7 @@ fn dispatch_routes_diagnose_self_signed_attached_without_registry_is_not_ok() {
377
377
  let ws = tmp_workspace();
378
378
  seed_self_signed_attached_leader(&ws);
379
379
  let code = run(
380
- &cli_argv(&["diagnose", "--workspace", &ws.to_string_lossy(), "--json"]),
380
+ &cli_argv(&["doctor", "--workspace", &ws.to_string_lossy(), "--json"]),
381
381
  &ws,
382
382
  );
383
383
  assert_ne!(
@@ -138,7 +138,7 @@ mod tests {
138
138
  "providers": {"claude": {"version": "secret"}},
139
139
  "runtime": {"workspace": "/tmp/secret"}
140
140
  });
141
- let output = render("diagnose", &report);
141
+ let output = render("doctor", &report);
142
142
  assert!(output.contains("issue: short_issue"));
143
143
  assert!(output.contains("issue: code=object_issue"));
144
144
  assert!(output.contains("repair: action=run repair"));
@@ -312,14 +312,6 @@ pub struct QuickStartArgs {
312
312
  // user-confirmed. quick-start is now an initial-creation-only verb.
313
313
  }
314
314
 
315
- /// `init`(`parser.py` bootstrap verb)。
316
- #[derive(Debug, Clone, PartialEq, Eq)]
317
- pub struct InitArgs {
318
- pub workspace: PathBuf,
319
- pub force: bool,
320
- pub json: bool,
321
- }
322
-
323
315
  /// `send`(`parser.py:262`)。`target` xor `--to`(fanout);`message` 多 token join 空格。
324
316
  #[derive(Debug, Clone, PartialEq)]
325
317
  pub struct SendArgs {
@@ -554,36 +546,6 @@ pub struct RemoveAgentArgs {
554
546
  pub json: bool,
555
547
  }
556
548
 
557
- /// `stuck-list`(`parser.py:424`)。
558
- #[derive(Debug, Clone, PartialEq, Eq)]
559
- pub struct StuckListArgs {
560
- pub workspace: PathBuf,
561
- pub json: bool,
562
- /// Stage 4: explicit `--team` scope (read-only enumeration).
563
- pub team: Option<String>,
564
- }
565
-
566
- /// `stuck-cancel`(`parser.py:429`)。`--alert-type` ∈ {stuck, idle_fallback, all},默认 stuck。
567
- #[derive(Debug, Clone, PartialEq, Eq)]
568
- pub struct StuckCancelArgs {
569
- pub agent: String,
570
- pub workspace: PathBuf,
571
- /// `None` 表 `all`(展开全集);`Some(AlertType)` 表单类型。
572
- pub alert_type: Option<AlertType>,
573
- pub json: bool,
574
- /// Parsed but rejected until stuck suppression storage grows a team-scoped
575
- /// backend selector.
576
- pub team: Option<String>,
577
- }
578
-
579
- /// `acknowledge-idle`(`parser.py:436`)。
580
- #[derive(Debug, Clone, PartialEq, Eq)]
581
- pub struct AcknowledgeIdleArgs {
582
- pub team: Option<String>,
583
- pub workspace: PathBuf,
584
- pub json: bool,
585
- }
586
-
587
549
  /// `doctor`(`parser.py:318`)。`--gate` ∈ {orphans, comms};`--fix` 须配 `--gate`(`cmd_doctor` 校验)。
588
550
  #[derive(Debug, Clone, PartialEq, Eq)]
589
551
  pub struct DoctorArgs {
@@ -683,17 +645,6 @@ pub struct WaitArgs {
683
645
  pub json: bool,
684
646
  }
685
647
 
686
- /// `diagnose` compatibility spelling for the canonical `doctor` health report.
687
- /// The CLI normalizes it to `DoctorArgs`; this narrow type remains for direct
688
- /// Rust callers that used the pre-unification API.
689
- #[derive(Debug, Clone, PartialEq, Eq)]
690
- pub struct DiagnoseArgs {
691
- pub workspace: PathBuf,
692
- pub json: bool,
693
- /// Stage 4: explicit `--team` scope (read-only health report).
694
- pub team: Option<String>,
695
- }
696
-
697
648
  /// `preflight`(`parser.py:160`)。
698
649
  #[derive(Debug, Clone, PartialEq, Eq)]
699
650
  pub struct PreflightArgs {
@@ -555,6 +555,9 @@ fn compile_role_agent_with_mode(
555
555
  ]),
556
556
  ),
557
557
  ];
558
+ if let Some(label) = string_field(&meta, "label") {
559
+ agent_items.insert(1, ("label", Value::Str(label)));
560
+ }
558
561
  if let Some(profile) = string_field(&meta, "profile") {
559
562
  agent_items.push(("profile", Value::Str(profile)));
560
563
  }
@@ -1313,7 +1313,7 @@ fn apply_endpoint_convergence(
1313
1313
  "persisted": false,
1314
1314
  "checked_paths": convergence_checked_paths(team_id.as_str()),
1315
1315
  "action": format!(
1316
- "old tmux endpoint {old_endpoint} still has this team's session or pane tuple; run team-agent diagnose --json before retrying restart"
1316
+ "old tmux endpoint {old_endpoint} still has this team's session or pane tuple; run team-agent doctor --json before retrying restart"
1317
1317
  ),
1318
1318
  })),
1319
1319
  false,
@@ -177,6 +177,216 @@ pub(super) fn upsert_agent_state_from_role(
177
177
  "runtime state agents is not an object".to_string(),
178
178
  ));
179
179
  };
180
+ let entry = starting_agent_entry(workspace, agent_id, meta, dynamic_role_file);
181
+ agent_map.insert(agent_id.as_str().to_string(), entry);
182
+ crate::lifecycle::restart::remove::clear_agent_retirement_in_state(&mut state, agent_id);
183
+ save_launched_team_state_for_key(
184
+ workspace,
185
+ &state,
186
+ Some(canonical_team_key),
187
+ Some(agent_id.as_str()),
188
+ )
189
+ }
190
+
191
+ /// ---
192
+ /// purpose: 首次登记 Pi fork 席位,原子带入目标角色与完整 fork capture seed
193
+ /// returns: 成功后 TARGET 已按 ForkAgent typed intent 登记为 starting
194
+ /// errors: 重复席位、无效 capture seed 或 state 写入失败时拒绝
195
+ /// ---
196
+ pub(crate) fn fork_upsert_agent_state_from_role(
197
+ workspace: &Path,
198
+ canonical_team_key: &str,
199
+ agent_id: &AgentId,
200
+ meta: &Value,
201
+ compiled_agent: &Value,
202
+ dynamic_role_file: &Path,
203
+ seed: &ForkCaptureSeed,
204
+ ) -> Result<(), LifecycleError> {
205
+ if agent_id == &seed.source_agent_id {
206
+ return Err(LifecycleError::RequirementUnmet(
207
+ "fork target must differ from source".to_string(),
208
+ ));
209
+ }
210
+ if seed.captured.captured_via != crate::provider::CaptureVia::ForkSnapshot
211
+ || seed.captured.attribution_confidence != crate::provider::Confidence::High
212
+ || seed.captured.session_id.is_none()
213
+ || seed.captured.rollout_path.is_none()
214
+ || seed.captured_at.is_empty()
215
+ {
216
+ return Err(LifecycleError::RequirementUnmet(
217
+ "fork capture seed is incomplete or not a verified Pi snapshot".to_string(),
218
+ ));
219
+ }
220
+ if compiled_agent.get("id").and_then(Value::as_str) != Some(agent_id.as_str()) {
221
+ return Err(LifecycleError::Compile(
222
+ "compiled fork role id does not match target".to_string(),
223
+ ));
224
+ }
225
+ let sessions_root = std::fs::canonicalize(&seed.pi_sessions_root)
226
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
227
+ let backing = seed.captured.rollout_path.as_ref().ok_or_else(|| {
228
+ LifecycleError::RequirementUnmet("fork backing path is missing".to_string())
229
+ })?;
230
+ let backing_path = std::fs::canonicalize(&backing.0)
231
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
232
+ if !backing_path.starts_with(&sessions_root) {
233
+ return Err(LifecycleError::RequirementUnmet(
234
+ "fork backing is outside the target Pi session root".to_string(),
235
+ ));
236
+ }
237
+
238
+ let mut state =
239
+ crate::state::projection::select_runtime_state(workspace, Some(canonical_team_key))
240
+ .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
241
+ if !state.is_object() {
242
+ state = serde_json::json!({});
243
+ }
244
+ let root = state.as_object_mut().ok_or_else(|| {
245
+ LifecycleError::StatePersist("runtime state root is not an object".to_string())
246
+ })?;
247
+ let agents = root
248
+ .entry("agents".to_string())
249
+ .or_insert_with(|| serde_json::json!({}));
250
+ if !agents.is_object() {
251
+ *agents = serde_json::json!({});
252
+ }
253
+ let agent_map = agents.as_object_mut().ok_or_else(|| {
254
+ LifecycleError::StatePersist("runtime state agents is not an object".to_string())
255
+ })?;
256
+ if agent_map.contains_key(agent_id.as_str()) {
257
+ return Err(LifecycleError::RequirementUnmet(format!(
258
+ "agent id already exists in selected team: {}",
259
+ agent_id
260
+ )));
261
+ }
262
+
263
+ let source_profile_dir = agent_map
264
+ .get(seed.source_agent_id.as_str())
265
+ .and_then(serde_json::Value::as_object)
266
+ .and_then(|source| source.get("_profile_dir"))
267
+ .and_then(serde_json::Value::as_str)
268
+ .filter(|path| !path.is_empty())
269
+ .map(|path| serde_json::json!(path));
270
+ let mut entry = starting_agent_entry(workspace, agent_id, meta, dynamic_role_file);
271
+ let entry_object = entry.as_object_mut().ok_or_else(|| {
272
+ LifecycleError::StatePersist("fork target entry is not an object".to_string())
273
+ })?;
274
+ for field in [
275
+ "provider",
276
+ "auth_mode",
277
+ "role",
278
+ "model",
279
+ "effort",
280
+ "profile",
281
+ "tools",
282
+ "system_prompt",
283
+ "output_contract",
284
+ "communication_mode",
285
+ "working_directory",
286
+ "cwd",
287
+ "config",
288
+ "name",
289
+ "dangerously_skip_permissions",
290
+ ] {
291
+ if let Some(value) = compiled_agent.get(field) {
292
+ entry_object.insert(field.to_string(), yaml_value_to_json(value));
293
+ }
294
+ }
295
+ if compiled_agent.get("model").is_some() && !entry_object.contains_key("model_source") {
296
+ entry_object.insert("model_source".to_string(), serde_json::json!("team"));
297
+ }
298
+ entry_object.insert("agent_id".to_string(), serde_json::json!(agent_id.as_str()));
299
+ entry_object.insert("label".to_string(), serde_json::json!(agent_id.as_str()));
300
+ entry_object.insert(
301
+ "owner_team_id".to_string(),
302
+ serde_json::json!(canonical_team_key),
303
+ );
304
+ entry_object.insert(
305
+ "claude_projects_root".to_string(),
306
+ serde_json::json!(sessions_root.to_string_lossy().to_string()),
307
+ );
308
+ entry_object.insert(
309
+ "forked_from".to_string(),
310
+ serde_json::json!(seed.source_agent_id.as_str()),
311
+ );
312
+ entry_object.insert(
313
+ "spawn_cwd".to_string(),
314
+ serde_json::json!(seed.captured.spawn_cwd.to_string_lossy().to_string()),
315
+ );
316
+ entry_object.insert(
317
+ "session_id".to_string(),
318
+ serde_json::json!(seed.captured.session_id.as_ref().map(|id| id.as_str())),
319
+ );
320
+ let backing = seed.captured.rollout_path.as_ref().ok_or_else(|| {
321
+ LifecycleError::RequirementUnmet("fork backing path is missing".to_string())
322
+ })?;
323
+ entry_object.insert(
324
+ "rollout_path".to_string(),
325
+ serde_json::json!(backing.0.to_string_lossy().to_string()),
326
+ );
327
+ entry_object.insert(
328
+ "captured_at".to_string(),
329
+ serde_json::json!(seed.captured_at.as_str()),
330
+ );
331
+ entry_object.insert(
332
+ "captured_via".to_string(),
333
+ serde_json::to_value(seed.captured.captured_via)
334
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?,
335
+ );
336
+ entry_object.insert(
337
+ "attribution_confidence".to_string(),
338
+ serde_json::to_value(seed.captured.attribution_confidence)
339
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?,
340
+ );
341
+ entry_object.insert("capture_state".to_string(), serde_json::json!("captured"));
342
+ if let Some(profile_dir) = source_profile_dir {
343
+ entry_object.insert("_profile_dir".to_string(), profile_dir);
344
+ } else {
345
+ entry_object.insert(
346
+ "_profile_dir".to_string(),
347
+ serde_json::json!(seed.profile_dir.to_string_lossy().to_string()),
348
+ );
349
+ }
350
+ agent_map.insert(agent_id.as_str().to_string(), entry);
351
+ crate::lifecycle::restart::remove::clear_agent_retirement_in_state(&mut state, agent_id);
352
+ crate::state::repository::StateRepository::new(workspace)
353
+ .save(
354
+ crate::state::repository::StateWriteIntent::ForkAgent {
355
+ team_key: canonical_team_key,
356
+ agent_id: agent_id.as_str(),
357
+ },
358
+ &state,
359
+ )
360
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))
361
+ }
362
+
363
+ fn yaml_value_to_json(value: &Value) -> serde_json::Value {
364
+ match value {
365
+ Value::Null => serde_json::Value::Null,
366
+ Value::Bool(value) => serde_json::json!(value),
367
+ Value::Int(value) => serde_json::json!(value),
368
+ Value::Float(value) => serde_json::Number::from_f64(*value)
369
+ .map(serde_json::Value::Number)
370
+ .unwrap_or(serde_json::Value::Null),
371
+ Value::Str(value) => serde_json::json!(value),
372
+ Value::List(values) => {
373
+ serde_json::Value::Array(values.iter().map(yaml_value_to_json).collect())
374
+ }
375
+ Value::Map(values) => serde_json::Value::Object(
376
+ values
377
+ .iter()
378
+ .map(|(key, value)| (key.clone(), yaml_value_to_json(value)))
379
+ .collect(),
380
+ ),
381
+ }
382
+ }
383
+
384
+ fn starting_agent_entry(
385
+ workspace: &Path,
386
+ agent_id: &AgentId,
387
+ meta: &Value,
388
+ dynamic_role_file: &Path,
389
+ ) -> serde_json::Value {
180
390
  let provider = meta
181
391
  .get("provider")
182
392
  .and_then(Value::as_str)
@@ -189,10 +399,6 @@ pub(super) fn upsert_agent_state_from_role(
189
399
  .get("role")
190
400
  .and_then(Value::as_str)
191
401
  .unwrap_or_else(|| agent_id.as_str());
192
- // E42 (0.3.24 P0, double-spec deadlock): persist the initial state row as
193
- // "starting" (not "running"). The caller (add_agent_with_transport_at_paths)
194
- // promotes to "running" only after start_agent_at_paths returns Running.
195
- // If the spawn fails, the rollback below removes the entry entirely.
196
402
  let mut entry = serde_json::json!({
197
403
  "provider": provider,
198
404
  "auth_mode": auth_mode,
@@ -221,9 +427,6 @@ pub(super) fn upsert_agent_state_from_role(
221
427
  }
222
428
  }
223
429
  }
224
- // 0.4.x provider effort MVP step 8 (dynamic add-agent): persist effort
225
- // from the role doc front matter (compiler.rs validates syntax/semantics
226
- // at compile; add-agent path validates here too in case of direct YAML).
227
430
  if let Some(effort_str) = meta.get("effort").and_then(Value::as_str) {
228
431
  if !effort_str.is_empty() {
229
432
  if let Some(obj) = entry.as_object_mut() {
@@ -232,7 +435,6 @@ pub(super) fn upsert_agent_state_from_role(
232
435
  }
233
436
  }
234
437
  if let Some(obj) = entry.as_object_mut() {
235
- // 0.5.66 bypass 单源:policy 从 role 文档字段派生,不再用全队 `DangerousApproval`。
236
438
  let meta_provider = meta
237
439
  .get("provider")
238
440
  .and_then(Value::as_str)
@@ -240,14 +442,7 @@ pub(super) fn upsert_agent_state_from_role(
240
442
  .unwrap_or(Provider::Codex);
241
443
  persist_effective_approval_policy_from_yaml_agent(obj, meta, meta_provider);
242
444
  }
243
- agent_map.insert(agent_id.as_str().to_string(), entry);
244
- crate::lifecycle::restart::remove::clear_agent_retirement_in_state(&mut state, agent_id);
245
- save_launched_team_state_for_key(
246
- workspace,
247
- &state,
248
- Some(canonical_team_key),
249
- Some(agent_id.as_str()),
250
- )
445
+ entry
251
446
  }
252
447
 
253
448
  /// ---
@@ -59,6 +59,7 @@ pub fn clone_agent(
59
59
  source_agent_id,
60
60
  as_agent_id,
61
61
  label,
62
+ None,
62
63
  )?;
63
64
  // The materialized role carries the source role's DECLARED tools verbatim —
64
65
  // a clone must preserve the source seat's full tools set (add-agent does
@@ -1,9 +1,9 @@
1
1
  //! ---
2
- //! purpose: 在当前席位窗口注入官方斜杠命令完成就地分身,不读 provider session 落盘
2
+ //! purpose: Pi 以完整会话快照创建独立席位;已验证的其他 provider 保持窗口内分身
3
3
  //! contract:
4
4
  //! provides:
5
5
  //! - name: fork_agent_with_transport
6
- //! what: grok/claude subscription 注入纯净斜杠命令;屏幕出现该 provider 的实测标记才算成功
6
+ //! what: Pi 走锁内快照新席位;已验证的其他 provider 保持窗口内斜杠 fork
7
7
  //! - name: in_window_fork
8
8
  //! what: 已验证的 provider+subscription 给出 command+screen_mark;未验证返回 None
9
9
  //! depends:
@@ -12,8 +12,8 @@
12
12
  //! - crate::lifecycle::profile_launch
13
13
  //! - crate::transport::Transport
14
14
  //! boundary:
15
- //! - 不 spawn 新 pane,不改 TEAM_AGENT_ID / MCP / 席位名
16
- //! - 不读 provider 会话落盘文件取会话身份
15
+ //! - 仅 Pi 的锁内专用管道读取 exact backing、注册并启动新席位
16
+ //! - 非 Pi 不 spawn 新 pane,不改 TEAM_AGENT_ID / MCP / 席位名
17
17
  //! - 重试只重按回车,不重粘 /fork
18
18
  //! - 不把 pane 锁超时从 200ms 调大
19
19
  //! - 未验证的 provider 不猜斜杠命令
@@ -99,7 +99,7 @@ pub fn fork_agent_with_transport(
99
99
  workspace: &Path,
100
100
  source_agent_id: &AgentId,
101
101
  as_agent_id: &AgentId,
102
- _label: Option<&str>,
102
+ label: Option<&str>,
103
103
  _open_display: bool,
104
104
  team: Option<&str>,
105
105
  transport: &dyn Transport,
@@ -142,6 +142,15 @@ pub fn fork_agent_with_transport(
142
142
  .unwrap_or("subscription");
143
143
  let provider = parse_provider(provider_raw).unwrap_or(Provider::Grok);
144
144
  let auth = parse_auth_mode(auth_raw).unwrap_or(AuthMode::Subscription);
145
+ if provider == Provider::Pi {
146
+ return crate::lifecycle::restart::fork_pi_new_seat_locked(
147
+ &selected,
148
+ source_agent_id,
149
+ as_agent_id,
150
+ label,
151
+ transport,
152
+ );
153
+ }
145
154
  let Some(spec) = in_window_fork(provider, auth) else {
146
155
  return Err(refuse_missing_in_window_fork(provider, provider_raw));
147
156
  };
@@ -201,6 +210,7 @@ pub fn fork_agent_with_transport(
201
210
  },
202
211
  session_id: None,
203
212
  backing_state: ForkBackingState::Verified,
213
+ pi_fork: None,
204
214
  })
205
215
  }
206
216