@team-agent/installer 0.5.49 → 0.5.51

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 (90) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +6 -4
  4. package/crates/team-agent/src/cli/emit.rs +91 -39
  5. package/crates/team-agent/src/cli/mod.rs +33 -15
  6. package/crates/team-agent/src/cli/named_address.rs +82 -53
  7. package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
  8. package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
  9. package/crates/team-agent/src/cli/send/persist.rs +154 -0
  10. package/crates/team-agent/src/cli/send/presentation.rs +333 -0
  11. package/crates/team-agent/src/cli/send/resolve.rs +361 -0
  12. package/crates/team-agent/src/cli/send.rs +103 -1308
  13. package/crates/team-agent/src/cli/spec.rs +2 -2
  14. package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
  15. package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
  16. package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
  17. package/crates/team-agent/src/cli/status_port/format.rs +145 -0
  18. package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
  19. package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
  20. package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
  21. package/crates/team-agent/src/cli/status_port/store.rs +412 -0
  22. package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
  23. package/crates/team-agent/src/cli/status_port.rs +47 -1548
  24. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -1
  25. package/crates/team-agent/src/cli/tests/named_address.rs +9 -7
  26. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -3
  27. package/crates/team-agent/src/cli/tests/status_send.rs +17 -33
  28. package/crates/team-agent/src/cli/types.rs +5 -8
  29. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  30. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  31. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  32. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  33. package/crates/team-agent/src/db/message_store.rs +154 -44
  34. package/crates/team-agent/src/event_log.rs +73 -0
  35. package/crates/team-agent/src/leader/start.rs +28 -4
  36. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  37. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +297 -0
  38. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  39. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  40. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  41. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  42. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  43. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  44. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  45. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  46. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  47. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  48. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  49. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  50. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  51. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  52. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  53. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  54. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  55. package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -26
  56. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  57. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +67 -26
  58. package/crates/team-agent/src/lifecycle/restart/remove.rs +435 -72
  59. package/crates/team-agent/src/lifecycle/restart.rs +1 -1
  60. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +575 -17
  61. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +55 -2
  62. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  63. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -1
  64. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  65. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  66. package/crates/team-agent/src/mcp_server/mod.rs +2 -2
  67. package/crates/team-agent/src/mcp_server/tests/send.rs +22 -15
  68. package/crates/team-agent/src/mcp_server/tests/wire.rs +6 -0
  69. package/crates/team-agent/src/mcp_server/tools.rs +26 -15
  70. package/crates/team-agent/src/mcp_server/wire.rs +2 -18
  71. package/crates/team-agent/src/messaging/activity.rs +4 -2
  72. package/crates/team-agent/src/messaging/address.rs +86 -0
  73. package/crates/team-agent/src/messaging/delivery.rs +165 -39
  74. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  75. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  76. package/crates/team-agent/src/messaging/mod.rs +11 -2
  77. package/crates/team-agent/src/messaging/persist.rs +309 -0
  78. package/crates/team-agent/src/messaging/results.rs +16 -24
  79. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  80. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  81. package/crates/team-agent/src/messaging/send.rs +133 -58
  82. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  83. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  84. package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
  85. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  86. package/crates/team-agent/src/redaction.rs +72 -2
  87. package/crates/team-agent/src/state/persist.rs +2 -1
  88. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  89. package/crates/team-agent/src/state/repository.rs +59 -16
  90. package/package.json +4 -4
@@ -153,12 +153,12 @@ pub(crate) struct CommandSpec {
153
153
  #[rustfmt::skip]
154
154
  pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
155
155
  CommandSpec { name: "quick-start", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::QuickStart), summary: "start or attach a team from TEAM.md", usage: "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--no-display] [--backend tmux|conpty] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
156
- CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "send a message/task", usage: "usage: team-agent send TARGET MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--targets AGENTS] [--to-name NAME | --to-name agent | --to-name team/agent | --to-name workspace::team/agent] [--pane PANE] [--task TASK] [--sender SENDER] [--watch-result] [--requires-ack|--no-ack] [--no-wait] [--timeout SECONDS] [--confirm-human] [--message-id ID] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
156
+ CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "persist a message for a logical recipient", usage: "usage: team-agent send TO MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: Some("next compatibility release"), action: Some("use positional logical TO and the returned message id"), governance: None },
157
157
  CommandSpec { name: "status", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Status), summary: "show current team status", usage: "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
158
158
  CommandSpec { name: "collect", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Collect), summary: "collect reported results", usage: "usage: team-agent collect [--workspace WORKSPACE] [--team TEAM] [--result-file FILE] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
159
159
  CommandSpec { name: "restart", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Restart), summary: "restart the selected team", usage: "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
160
160
  CommandSpec { name: "shutdown", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Shutdown), summary: "stop the selected team", usage: "usage: team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
161
- CommandSpec { name: "add-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::AddAgent), summary: "add a worker", usage: "usage: team-agent add-agent AGENT --role-file FILE [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
161
+ CommandSpec { name: "add-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::AddAgent), summary: "add or force-recreate a worker", usage: "usage: team-agent add-agent AGENT --role-file FILE [--force] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
162
162
  CommandSpec { name: "start-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::StartAgent), summary: "start an existing worker", usage: "usage: team-agent start-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--force] [--allow-fresh] [--no-display] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
163
163
  CommandSpec { name: "stop-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::StopAgent), summary: "stop a worker", usage: "usage: team-agent stop-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
164
164
  CommandSpec { name: "reset-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::ResetAgent), summary: "reset a worker session", usage: "usage: team-agent reset-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--discard-session] [--no-display] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
@@ -0,0 +1,358 @@
1
+ use super::*;
2
+
3
+ pub(super) fn agent_window(agent_id: &str, agent_state: &Value) -> String {
4
+ ["window", "window_name"]
5
+ .iter()
6
+ .find_map(|key| {
7
+ agent_state
8
+ .get(*key)
9
+ .and_then(Value::as_str)
10
+ .filter(|s| !s.is_empty())
11
+ })
12
+ .unwrap_or(agent_id)
13
+ .to_string()
14
+ }
15
+
16
+ pub(super) fn enrich_agents(
17
+ workspace: &Path,
18
+ state: &Value,
19
+ tmux_session_present: bool,
20
+ freshness: &RuntimeFreshness,
21
+ ) -> Value {
22
+ let agents = state.get("agents");
23
+ let Some(Value::Object(input)) = agents else {
24
+ return json!({});
25
+ };
26
+ let team_dir = state
27
+ .get("team_dir")
28
+ .and_then(Value::as_str)
29
+ .filter(|value| !value.is_empty())
30
+ .map(PathBuf::from)
31
+ .unwrap_or_else(|| workspace.to_path_buf());
32
+ let mut out = Map::new();
33
+ for (agent_id, value) in input {
34
+ match value {
35
+ Value::Object(obj) => {
36
+ let mut enriched = obj.clone();
37
+ apply_effective_role_projection(&team_dir, agent_id, &mut enriched);
38
+ enriched.insert(
39
+ "interacted".to_string(),
40
+ Value::String(interacted_marker(obj.get("first_send_at"))),
41
+ );
42
+ // 0.5.41 Slice 3: order stale sources most-authoritative-first.
43
+ // Host boot mismatch wins because it invalidates all cached
44
+ // pane/pid/session facts. Provider-exit marker wins over
45
+ // pane liveness because the wrapper leaves an interactive
46
+ // shell live. Coordinator unavailability without stronger
47
+ // live provider proof means DB agent_health rows are stale.
48
+ // Tmux session missing keeps its existing legacy path so
49
+ // pre-0.5.41 tests remain byte-identical when no new
50
+ // signal fires.
51
+ let has_pane_binding = agent_has_pane_fact(&Value::Object(obj.clone()));
52
+ // 0.5.41 Slice 3 (fault-invisibility-locate.md §5 point 3
53
+ // + §9 RED4 live-provider guard): the wrapper-era pane
54
+ // liveness cannot prove provider liveness — but a state
55
+ // `pane_current_command` that MATCHES the agent's provider
56
+ // IS positive proof (the abnormal.rs classifier writes it
57
+ // when the pane's foreground command is the provider CLI).
58
+ // When that positive proof is present, no stale downgrade
59
+ // fires here so the agent renders as working.
60
+ let provider_command_positive_proof = provider_current_command_matches(obj);
61
+ // 0.5.41 Slice 3 (0.5.35 R4 regression guard): when the
62
+ // runtime classifier has already written canonical
63
+ // `worker_state=UNKNOWN` / `activity.status=uncertain`,
64
+ // that is the authoritative honest observation — do NOT
65
+ // reclassify it as `coordinator_unavailable` stale (which
66
+ // would land it in the Stopped bucket instead of Unknown).
67
+ // Host-boot mismatch and provider-exited marker are
68
+ // stronger, more specific signals and still win.
69
+ let canonical_unknown = agent_canonical_worker_state_is_unknown(obj);
70
+ let new_reason = if freshness.host_boot_stale && has_pane_binding {
71
+ freshness.host_boot_stale_reason()
72
+ } else if freshness.provider_exited_agents.contains(agent_id) {
73
+ Some("worker_provider_exited")
74
+ } else if !freshness.coordinator_service_available
75
+ && has_pane_binding
76
+ && !provider_command_positive_proof
77
+ && !canonical_unknown
78
+ {
79
+ Some("coordinator_unavailable")
80
+ } else {
81
+ None
82
+ };
83
+ let legacy_reason = if provider_command_positive_proof {
84
+ None
85
+ } else {
86
+ stale_reason_for_agent(&Value::Object(obj.clone()), tmux_session_present)
87
+ };
88
+ let reason = new_reason.or(legacy_reason);
89
+ if let Some(reason) = reason {
90
+ enriched.insert("stale".to_string(), Value::Bool(true));
91
+ enriched.insert(
92
+ "stale_reason".to_string(),
93
+ Value::String(reason.to_string()),
94
+ );
95
+ // Downgrade cached BUSY/working when the stale source is
96
+ // one of the new authoritative signals OR the pre-existing
97
+ // session-missing signal. Legacy code only downgraded on
98
+ // !tmux_session_present; that let host_boot / provider-
99
+ // exit / coord-unavailable stale rows keep raw=running.
100
+ let is_new_signal = matches!(
101
+ reason,
102
+ "host_boot_mismatch" | "worker_provider_exited" | "coordinator_unavailable"
103
+ );
104
+ if !tmux_session_present || is_new_signal {
105
+ downgrade_stale_agent(&mut enriched);
106
+ }
107
+ }
108
+ out.insert(agent_id.clone(), Value::Object(enriched));
109
+ }
110
+ _ => {
111
+ out.insert(agent_id.clone(), value.clone());
112
+ }
113
+ }
114
+ }
115
+ Value::Object(out)
116
+ }
117
+
118
+ pub(super) fn apply_effective_role_projection(
119
+ team_dir: &Path,
120
+ agent_id: &str,
121
+ agent: &mut Map<String, Value>,
122
+ ) {
123
+ let role_file = agent
124
+ .get("dynamic_role_file")
125
+ .and_then(Value::as_str)
126
+ .filter(|value| !value.is_empty())
127
+ .map(PathBuf::from)
128
+ .unwrap_or_else(|| team_dir.join("agents").join(format!("{agent_id}.md")));
129
+ let Ok((meta, _)) = crate::compiler::read_front_matter(&role_file) else {
130
+ return;
131
+ };
132
+ let Some(meta) = yaml_map(&meta) else {
133
+ return;
134
+ };
135
+ if let Some(provider) = yaml_str(meta, "provider").filter(|value| !value.is_empty()) {
136
+ agent.insert("provider".to_string(), Value::String(provider.to_string()));
137
+ agent.insert(
138
+ "provider_source".to_string(),
139
+ Value::String("role".to_string()),
140
+ );
141
+ }
142
+ if let Some(model) = yaml_str(meta, "model").filter(|value| !value.is_empty()) {
143
+ if agent.get("model").and_then(Value::as_str) != Some(model) {
144
+ agent.insert("model_stale".to_string(), Value::Bool(true));
145
+ }
146
+ agent.insert("model".to_string(), Value::String(model.to_string()));
147
+ agent.insert(
148
+ "model_source".to_string(),
149
+ Value::String("role".to_string()),
150
+ );
151
+ }
152
+ }
153
+
154
+ pub(super) fn yaml_map(
155
+ value: &crate::model::yaml::Value,
156
+ ) -> Option<&Vec<(String, crate::model::yaml::Value)>> {
157
+ match value {
158
+ crate::model::yaml::Value::Map(items) => Some(items),
159
+ _ => None,
160
+ }
161
+ }
162
+
163
+ pub(super) fn yaml_str<'a>(
164
+ items: &'a [(String, crate::model::yaml::Value)],
165
+ key: &str,
166
+ ) -> Option<&'a str> {
167
+ items.iter().find_map(|(name, value)| {
168
+ if name == key {
169
+ match value {
170
+ crate::model::yaml::Value::Str(value) => Some(value.as_str()),
171
+ _ => None,
172
+ }
173
+ } else {
174
+ None
175
+ }
176
+ })
177
+ }
178
+
179
+ pub(super) fn downgrade_stale_agent(agent: &mut Map<String, Value>) {
180
+ let raw = agent
181
+ .get("status")
182
+ .and_then(Value::as_str)
183
+ .unwrap_or("")
184
+ .to_ascii_lowercase();
185
+ if matches!(raw.as_str(), "running" | "busy" | "working" | "idle") {
186
+ agent.insert("status".to_string(), Value::String("stopped".to_string()));
187
+ }
188
+ let worker_state = agent
189
+ .get("worker_state")
190
+ .and_then(Value::as_str)
191
+ .unwrap_or("")
192
+ .to_ascii_uppercase();
193
+ if matches!(worker_state.as_str(), "RUNNING" | "BUSY" | "PROBABLY_IDLE") {
194
+ agent.insert(
195
+ "worker_state".to_string(),
196
+ Value::String("DEAD".to_string()),
197
+ );
198
+ }
199
+ }
200
+
201
+ pub(super) fn stale_reason_for_agent(
202
+ agent: &Value,
203
+ tmux_session_present: bool,
204
+ ) -> Option<&'static str> {
205
+ let pane_dead = !tmux_session_present && agent_has_pane_fact(agent);
206
+ let process_dead =
207
+ agent_process_dead(agent) || (!tmux_session_present && agent_has_process_fact(agent));
208
+ match (pane_dead, process_dead) {
209
+ (true, true) => Some("both"),
210
+ (false, true) => Some("process_dead"),
211
+ (true, false) => Some("pane_dead"),
212
+ (false, false) => None,
213
+ }
214
+ }
215
+
216
+ /// 0.5.41 Slice 3 (0.5.35 R4 regression guard): true when the agent
217
+ /// row carries the canonical `worker_state=UNKNOWN` OR
218
+ /// `activity.status=uncertain` observation the runtime classifier
219
+ /// writes. Used to skip the coordinator-unavailable stale mark
220
+ /// (see `enrich_agents`) so the pre-existing R4 rendering (UNKNOWN
221
+ /// beats WORKING) is preserved.
222
+ pub(super) fn agent_canonical_worker_state_is_unknown(
223
+ agent: &serde_json::Map<String, Value>,
224
+ ) -> bool {
225
+ let worker_state_unknown = agent
226
+ .get("worker_state")
227
+ .and_then(Value::as_str)
228
+ .is_some_and(|value| value.eq_ignore_ascii_case("UNKNOWN"));
229
+ let activity_uncertain = agent
230
+ .get("activity")
231
+ .and_then(|v| v.get("status"))
232
+ .and_then(Value::as_str)
233
+ .is_some_and(|value| value.eq_ignore_ascii_case("uncertain"));
234
+ worker_state_unknown || activity_uncertain
235
+ }
236
+
237
+ /// 0.5.41 Slice 3 (fault-invisibility-locate.md §9 RED4 live-provider
238
+ /// guard): true when the agent row carries a `pane_current_command`
239
+ /// that matches the agent's provider CLI. This is positive proof
240
+ /// the provider is the pane's foreground process — the abnormal.rs
241
+ /// classifier writes this field after the marker/current-command
242
+ /// check clears. When true, stale-downgrade paths in
243
+ /// `enrich_agents` skip so the row keeps its BUSY/working state.
244
+ pub(super) fn provider_current_command_matches(agent: &serde_json::Map<String, Value>) -> bool {
245
+ let Some(command) = agent
246
+ .get("pane_current_command")
247
+ .and_then(Value::as_str)
248
+ .filter(|s| !s.is_empty())
249
+ else {
250
+ return false;
251
+ };
252
+ let Some(provider_wire) = agent.get("provider").and_then(Value::as_str) else {
253
+ return false;
254
+ };
255
+ let Some(provider) = crate::provider::wire::parse_provider(provider_wire) else {
256
+ return false;
257
+ };
258
+ crate::leader::command_matches_provider(provider, command)
259
+ }
260
+
261
+ pub(super) fn agent_has_pane_fact(agent: &Value) -> bool {
262
+ ["pane_id", "window", "window_name"].iter().any(|key| {
263
+ agent
264
+ .get(*key)
265
+ .and_then(Value::as_str)
266
+ .is_some_and(|value| !value.is_empty())
267
+ })
268
+ }
269
+
270
+ pub(super) fn agent_has_process_fact(agent: &Value) -> bool {
271
+ agent.get("pid").and_then(Value::as_i64).is_some()
272
+ || agent.get("process_started").and_then(Value::as_bool) == Some(true)
273
+ || agent
274
+ .get("provider_process_dead")
275
+ .and_then(Value::as_bool)
276
+ .is_some()
277
+ || agent
278
+ .get("process_liveness")
279
+ .and_then(Value::as_str)
280
+ .is_some()
281
+ }
282
+
283
+ pub(super) fn agent_process_dead(agent: &Value) -> bool {
284
+ if agent.get("provider_process_dead").and_then(Value::as_bool) == Some(true) {
285
+ return true;
286
+ }
287
+ ["process_liveness", "worker_state"].iter().any(|key| {
288
+ agent
289
+ .get(*key)
290
+ .and_then(Value::as_str)
291
+ .is_some_and(is_dead_process_state)
292
+ })
293
+ }
294
+
295
+ pub(super) fn is_dead_process_state(value: &str) -> bool {
296
+ matches!(
297
+ value,
298
+ "dead" | "missing" | "stopped" | "exited" | "terminated"
299
+ )
300
+ }
301
+
302
+ pub(super) fn interacted_marker(value: Option<&Value>) -> String {
303
+ let Some(raw) = value.and_then(Value::as_str) else {
304
+ return "never".to_string();
305
+ };
306
+ if raw.is_empty() {
307
+ return "never".to_string();
308
+ }
309
+ if chrono::DateTime::parse_from_rfc3339(raw).is_ok() {
310
+ raw.to_string()
311
+ } else {
312
+ "never".to_string()
313
+ }
314
+ }
315
+
316
+ pub(super) fn tmux_session_present(
317
+ workspace: &Path,
318
+ state: &Value,
319
+ session_name: Option<&str>,
320
+ ) -> bool {
321
+ // Bug #7 (prerelease 0.4.0 gate review §6): probe the SAME endpoint
322
+ // the runtime actually uses (state.tmux_endpoint / tmux_socket), not
323
+ // the workspace-hash socket. When state has no persisted endpoint,
324
+ // fall back to workspace — preserves legacy behavior. wait_readiness
325
+ // formula unchanged per 不可改项; only the input signal is fixed.
326
+ let Some(name) = session_name else {
327
+ return false;
328
+ };
329
+ if name.is_empty() {
330
+ return false;
331
+ }
332
+ let run_ws = crate::model::paths::canonical_run_workspace(workspace)
333
+ .unwrap_or_else(|_| workspace.to_path_buf());
334
+ // 0.5.x Phase 1d Batch 3: route through the factory so a
335
+ // conpty team does NOT get its `has_session` probe served by a
336
+ // tmux backend (which would always return false and drive the
337
+ // reader into a false `tmux_session_missing` state — design
338
+ // §Batch 3 Verification anchor). Tmux teams see byte-equivalent
339
+ // behavior because factory Layer 3 (legacy tmux endpoint) uses
340
+ // the same `tmux_backend_for_runtime_state_or_workspace` shape.
341
+ let resolved = crate::transport_factory::resolve_read_only_transport(
342
+ &run_ws,
343
+ Some(state),
344
+ crate::transport_factory::TransportPurpose::Status,
345
+ );
346
+ match resolved {
347
+ Ok(r) => r
348
+ .backend
349
+ .has_session(&crate::transport::SessionName::new(name))
350
+ .unwrap_or(false),
351
+ Err(_) => {
352
+ // Factory refused (e.g. explicit conpty without a
353
+ // resolvable team_key). Honest: return false rather
354
+ // than pretend a tmux session exists.
355
+ false
356
+ }
357
+ }
358
+ }
@@ -0,0 +1,79 @@
1
+ use super::*;
2
+
3
+ pub fn approvals(workspace: &Path, agent: Option<&str>, as_json: bool) -> Result<Value, CliError> {
4
+ let _ = as_json;
5
+ let state = read_runtime_state(workspace);
6
+ approvals_scoped(workspace, &state, agent, as_json)
7
+ }
8
+
9
+ pub fn approvals_scoped(
10
+ workspace: &Path,
11
+ state: &Value,
12
+ agent: Option<&str>,
13
+ as_json: bool,
14
+ ) -> Result<Value, CliError> {
15
+ let _ = as_json;
16
+ let session = state
17
+ .get("session_name")
18
+ .and_then(Value::as_str)
19
+ .filter(|s| !s.is_empty());
20
+ let mut approvals = Vec::new();
21
+ if let (Some(session), Some(agents)) = (session, state.get("agents").and_then(Value::as_object))
22
+ {
23
+ let run_ws = crate::model::paths::canonical_run_workspace(workspace)
24
+ .unwrap_or_else(|_| workspace.to_path_buf());
25
+ // 0.5.x Phase 1d Batch 3: use the factory-resolved backend
26
+ // so conpty teams get their scrollback from the shim rather
27
+ // than a fake tmux capture that always returns empty. Tmux
28
+ // teams take the same code path as before (byte-equivalent).
29
+ let resolved = crate::transport_factory::resolve_read_only_transport(
30
+ &run_ws,
31
+ Some(state),
32
+ crate::transport_factory::TransportPurpose::Status,
33
+ );
34
+ let backend: Box<dyn crate::transport::Transport> = match resolved {
35
+ Ok(r) => r.backend,
36
+ Err(_) => {
37
+ // Read-path fallback: refused factory means we don't
38
+ // try to inspect approval prompts. Empty vec = no
39
+ // waiting approvals, honest.
40
+ return Ok(json!({
41
+ "ok": true,
42
+ "waiting": false,
43
+ "waiting_count": 0,
44
+ "approvals": [],
45
+ }));
46
+ }
47
+ };
48
+ for (agent_id, agent_state) in agents {
49
+ if agent.is_some_and(|wanted| wanted != agent_id) {
50
+ continue;
51
+ }
52
+ let window = agent_window(agent_id, agent_state);
53
+ let target = crate::transport::Target::SessionWindow {
54
+ session: crate::transport::SessionName::new(session.to_string()),
55
+ window: crate::transport::WindowName::new(window.clone()),
56
+ };
57
+ let Ok(captured) = backend.capture(&target, crate::transport::CaptureRange::Tail(120))
58
+ else {
59
+ continue;
60
+ };
61
+ if let Some(prompt) = crate::provider::extract_approval_prompt(agent_id, &captured.text)
62
+ {
63
+ approvals.push(prompt.to_ordered_value());
64
+ }
65
+ }
66
+ }
67
+ let waiting_count = approvals.len();
68
+ Ok(json!({
69
+ "ok": true,
70
+ "waiting": waiting_count > 0,
71
+ "waiting_count": waiting_count,
72
+ "approvals": approvals,
73
+ "scan": {
74
+ "mode": "tail",
75
+ "lines": 120,
76
+ "raw_output": false,
77
+ },
78
+ }))
79
+ }
@@ -0,0 +1,207 @@
1
+ use super::*;
2
+
3
+ pub(super) fn compact_status(full: Value) -> Value {
4
+ let not_ready = compact_not_ready(&full);
5
+ let ready = compact_ready(&full, &not_ready);
6
+ json!({
7
+ "ok": true,
8
+ "team": full.get("team").cloned().unwrap_or(Value::Null),
9
+ "session_name": full.get("session_name").cloned().unwrap_or(Value::Null),
10
+ "leader_attach_command": full.get("leader_attach_command").cloned().unwrap_or(Value::Null),
11
+ "ready": ready,
12
+ "not_ready": not_ready,
13
+ "agents": compact_agents(full.get("agents")),
14
+ })
15
+ }
16
+
17
+ /// Synthesized readiness boolean for the slim payload. Stricter than the
18
+ /// raw `readiness.ready` because it also folds in coordinator + schema +
19
+ /// tmux session presence so operators don't need to read separate booleans.
20
+ pub(super) fn compact_ready(full: &Value, not_ready: &Value) -> bool {
21
+ not_ready.is_null()
22
+ && full
23
+ .get("readiness")
24
+ .and_then(|r| r.get("ready"))
25
+ .and_then(Value::as_bool)
26
+ .unwrap_or(false)
27
+ && full
28
+ .get("coordinator")
29
+ .and_then(|c| c.get("status"))
30
+ .and_then(Value::as_str)
31
+ .is_some_and(|s| s == "running" || s == "ok")
32
+ && full
33
+ .get("coordinator")
34
+ .and_then(|c| c.get("schema_ok"))
35
+ .and_then(Value::as_bool)
36
+ .unwrap_or(true)
37
+ }
38
+
39
+ /// Returns `Value::Null` when fully ready, otherwise an object:
40
+ /// `{"reasons": [...], "agents": [...]}` listing every gating issue.
41
+ pub(super) fn compact_not_ready(full: &Value) -> Value {
42
+ let reasons = not_ready_reasons(full);
43
+ if reasons.is_empty() {
44
+ return Value::Null;
45
+ }
46
+ let agents = full
47
+ .get("incomplete_session_capture_agents")
48
+ .and_then(Value::as_array)
49
+ .cloned()
50
+ .or_else(|| {
51
+ full.get("pending_session_agent_ids")
52
+ .and_then(Value::as_array)
53
+ .cloned()
54
+ })
55
+ .unwrap_or_default();
56
+ let mut obj = Map::new();
57
+ obj.insert(
58
+ "reasons".to_string(),
59
+ Value::Array(reasons.into_iter().map(Value::String).collect()),
60
+ );
61
+ obj.insert("agents".to_string(), Value::Array(agents));
62
+ Value::Object(obj)
63
+ }
64
+
65
+ pub(super) fn not_ready_reasons(full: &Value) -> Vec<String> {
66
+ let mut reasons = Vec::new();
67
+ let coord = full.get("coordinator");
68
+ let coord_status = coord
69
+ .and_then(|c| c.get("status"))
70
+ .and_then(Value::as_str)
71
+ .unwrap_or("");
72
+ if coord_status != "running" && coord_status != "ok" {
73
+ reasons.push("coordinator_not_running".to_string());
74
+ }
75
+ if coord
76
+ .and_then(|c| c.get("schema_ok"))
77
+ .and_then(Value::as_bool)
78
+ == Some(false)
79
+ {
80
+ reasons.push("coordinator_schema_not_ok".to_string());
81
+ }
82
+ if full.get("tmux_session_present").and_then(Value::as_bool) == Some(false) {
83
+ reasons.push("tmux_session_missing".to_string());
84
+ }
85
+ let readiness = full.get("readiness");
86
+ if readiness
87
+ .and_then(|r| r.get("all_spawned"))
88
+ .and_then(Value::as_bool)
89
+ == Some(false)
90
+ {
91
+ reasons.push("workers_not_spawned".to_string());
92
+ }
93
+ if readiness
94
+ .and_then(|r| r.get("all_attached_receiver"))
95
+ .and_then(Value::as_bool)
96
+ == Some(false)
97
+ {
98
+ reasons.push("leader_receiver_unbound".to_string());
99
+ }
100
+ if readiness
101
+ .and_then(|r| r.get("session_capture_complete"))
102
+ .and_then(Value::as_bool)
103
+ == Some(false)
104
+ {
105
+ reasons.push("session_capture_incomplete".to_string());
106
+ }
107
+ if readiness
108
+ .and_then(|r| r.get("awaiting_trust_prompt"))
109
+ .and_then(Value::as_bool)
110
+ == Some(true)
111
+ {
112
+ reasons.push("awaiting_trust_prompt".to_string());
113
+ }
114
+ reasons
115
+ }
116
+
117
+ pub(super) fn compact_agents(value: Option<&Value>) -> Value {
118
+ let Some(Value::Object(input)) = value else {
119
+ return json!({});
120
+ };
121
+ let mut out = Map::new();
122
+ for (agent_id, agent) in input {
123
+ out.insert(agent_id.clone(), compact_agent_state(agent_id, agent));
124
+ }
125
+ Value::Object(out)
126
+ }
127
+
128
+ /// 0.4.x: agent rows in the slim payload have exactly 4 fields. agent_id
129
+ /// is no longer copied in — the map key already carries it. Diagnostic
130
+ /// fields (model, tmux_window_present, session_id, captured_via,
131
+ /// attribution_confidence, display, interacted) move to `--detail`.
132
+ /// `activity` + `last_output_at` are preserved (RM-039-STAT-001).
133
+ pub(super) fn compact_agent_state(_agent_id: &str, agent: &Value) -> Value {
134
+ let Some(input) = agent.as_object() else {
135
+ return json!({});
136
+ };
137
+ let mut out = Map::new();
138
+ // 0.4.x Phase 1: add `worker_state` (canonical 5-state product
139
+ // surface). `activity` is preserved alongside as the deprecated
140
+ // legacy classifier output (CR R3 same-source contract).
141
+ for key in [
142
+ "status",
143
+ "provider",
144
+ "worker_state",
145
+ "activity",
146
+ "last_output_at",
147
+ "stale",
148
+ "stale_reason",
149
+ ] {
150
+ if let Some(value) = input.get(key) {
151
+ out.insert(key.to_string(), value.clone());
152
+ }
153
+ }
154
+ Value::Object(out)
155
+ }
156
+
157
+ pub(super) fn compact_tasks(value: Option<&Value>) -> Value {
158
+ let Some(Value::Array(tasks)) = value else {
159
+ return json!([]);
160
+ };
161
+ Value::Array(
162
+ tasks
163
+ .iter()
164
+ .map(|task| {
165
+ compact_object(
166
+ Some(task),
167
+ &[
168
+ "id",
169
+ "title",
170
+ "status",
171
+ "assignee",
172
+ "type",
173
+ "accepted_result_id",
174
+ ],
175
+ )
176
+ })
177
+ .collect(),
178
+ )
179
+ }
180
+
181
+ pub(super) fn compact_object(value: Option<&Value>, keys: &[&str]) -> Value {
182
+ let Some(Value::Object(input)) = value else {
183
+ return json!({});
184
+ };
185
+ let mut out = Map::new();
186
+ for key in keys {
187
+ if let Some(value) = input.get(*key) {
188
+ out.insert((*key).to_string(), value.clone());
189
+ }
190
+ }
191
+ Value::Object(out)
192
+ }
193
+
194
+ pub(super) fn take_array(value: Option<&Value>, limit: usize) -> Value {
195
+ let Some(Value::Array(items)) = value else {
196
+ return json!([]);
197
+ };
198
+ Value::Array(items.iter().take(limit).cloned().collect())
199
+ }
200
+
201
+ pub(super) fn take_array_tail(value: Option<&Value>, limit: usize) -> Value {
202
+ let Some(Value::Array(items)) = value else {
203
+ return json!([]);
204
+ };
205
+ let start = items.len().saturating_sub(limit);
206
+ Value::Array(items.iter().skip(start).cloned().collect())
207
+ }