@team-agent/installer 0.5.59 → 0.5.61

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 (68) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +44 -1
  4. package/crates/team-agent/src/cli/diagnose.rs +183 -0
  5. package/crates/team-agent/src/cli/emit.rs +63 -14
  6. package/crates/team-agent/src/cli/leader.rs +8 -1
  7. package/crates/team-agent/src/cli/mod.rs +263 -5
  8. package/crates/team-agent/src/cli/named_address.rs +18 -1
  9. package/crates/team-agent/src/cli/send/presentation.rs +2 -0
  10. package/crates/team-agent/src/cli/spec.rs +4 -1
  11. package/crates/team-agent/src/cli/status_port/store.rs +7 -3
  12. package/crates/team-agent/src/cli/tests/named_address.rs +65 -0
  13. package/crates/team-agent/src/cli/types.rs +33 -0
  14. package/crates/team-agent/src/communication_mode/mod.rs +53 -0
  15. package/crates/team-agent/src/compiler/tests.rs +5 -5
  16. package/crates/team-agent/src/compiler.rs +53 -1
  17. package/crates/team-agent/src/coordinator/tick.rs +21 -8
  18. package/crates/team-agent/src/db/message_store.rs +45 -1
  19. package/crates/team-agent/src/fake_worker.rs +21 -1
  20. package/crates/team-agent/src/leader/start.rs +732 -94
  21. package/crates/team-agent/src/leader/tests/identity.rs +64 -57
  22. package/crates/team-agent/src/leader/tests/identity_session_names.rs +42 -0
  23. package/crates/team-agent/src/lib.rs +4 -0
  24. package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +58 -0
  25. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +57 -51
  26. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +148 -8
  27. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +5 -0
  28. package/crates/team-agent/src/lifecycle/launch/spawn.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/launch.rs +1 -1
  30. package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
  31. package/crates/team-agent/src/lifecycle/restart/common.rs +1 -1
  32. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +35 -1
  33. package/crates/team-agent/src/lifecycle/types.rs +1 -0
  34. package/crates/team-agent/src/lifecycle/worker_command_context.rs +29 -11
  35. package/crates/team-agent/src/mcp_server/normalize.rs +1 -0
  36. package/crates/team-agent/src/mcp_server/tests/send.rs +26 -0
  37. package/crates/team-agent/src/mcp_server/tests/wire.rs +1 -1
  38. package/crates/team-agent/src/mcp_server/tools.rs +100 -66
  39. package/crates/team-agent/src/mcp_server/types.rs +5 -0
  40. package/crates/team-agent/src/mcp_server/wire.rs +27 -21
  41. package/crates/team-agent/src/messaging/delivery.rs +105 -38
  42. package/crates/team-agent/src/messaging/leader_channel.rs +33 -10
  43. package/crates/team-agent/src/messaging/leader_receiver.rs +40 -23
  44. package/crates/team-agent/src/messaging/presentation.rs +109 -0
  45. package/crates/team-agent/src/messaging/results.rs +165 -17
  46. package/crates/team-agent/src/messaging/send.rs +94 -81
  47. package/crates/team-agent/src/messaging/tests/leader_channel.rs +6 -6
  48. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +187 -0
  49. package/crates/team-agent/src/messaging/tests/runtime.rs +13 -6
  50. package/crates/team-agent/src/messaging/types.rs +5 -0
  51. package/crates/team-agent/src/messaging/watchers.rs +5 -0
  52. package/crates/team-agent/src/model/mod.rs +1 -0
  53. package/crates/team-agent/src/model/pane_authority_refusal.rs +358 -0
  54. package/crates/team-agent/src/model/spec.rs +16 -0
  55. package/crates/team-agent/src/provider/adapter.rs +57 -0
  56. package/crates/team-agent/src/provider/adapters/claude_fork.rs +10 -2
  57. package/crates/team-agent/src/provider/session/capture.rs +69 -34
  58. package/crates/team-agent/src/provider/session/context_fork/codex.rs +386 -9
  59. package/crates/team-agent/src/provider/session/context_fork/outcome.rs +3 -14
  60. package/crates/team-agent/src/provider/session/context_fork.rs +7 -2
  61. package/crates/team-agent/src/provider/session/mod.rs +3 -2
  62. package/crates/team-agent/src/provider/session_scan/claude.rs +92 -3
  63. package/crates/team-agent/src/provider/session_scan/codex.rs +28 -0
  64. package/crates/team-agent/src/provider/session_scan/common.rs +34 -8
  65. package/crates/team-agent/src/provider/session_scan.rs +8 -0
  66. package/crates/team-agent/src/topology.rs +3 -0
  67. package/package.json +4 -4
  68. package/skills/team-agent/command-coverage.json +379 -0
@@ -99,6 +99,141 @@ fn attached_leader_mailbox_is_rechecked_by_the_normal_delivery_tick() {
99
99
  assert!(case.events().contains("leader_receiver.mailbox_requeued"));
100
100
  }
101
101
 
102
+ #[test]
103
+ fn leader_submit_without_receipt_source_cannot_park_forever_without_a_completion_writer() {
104
+ let case = Case::new_without_rollout("missing-receipt-source");
105
+ let transport = RecordingTransport::new("");
106
+ let message_id = case.seed_message("missing receipt source canary");
107
+
108
+ let first = deliver_pending_message(
109
+ &case.workspace,
110
+ &case.store,
111
+ &transport,
112
+ &message_id,
113
+ &case.event_log,
114
+ &case.state,
115
+ )
116
+ .expect("verified leader transport submission");
117
+
118
+ assert_ne!(
119
+ case.message_status(&message_id),
120
+ "submitted_pending_acceptance",
121
+ "F4 acceptance-stall: a verified leader transport submit with no reachable \
122
+ receipt source must not enter submitted_pending_acceptance, because that \
123
+ protected state has no completion writer. The runtime may close the \
124
+ transport-acceptance fact or expose a provider-session receipt writer, \
125
+ but it must not report an indefinitely retryable fact-terminal. outcome={first:?}"
126
+ );
127
+ assert_eq!(
128
+ transport.inject_count(),
129
+ 1,
130
+ "the acceptance repair must not obtain progress by physically injecting twice"
131
+ );
132
+
133
+ let _ = deliver_pending_message(
134
+ &case.workspace,
135
+ &case.store,
136
+ &transport,
137
+ &message_id,
138
+ &case.event_log,
139
+ &case.state,
140
+ );
141
+ assert_eq!(
142
+ transport.inject_count(),
143
+ 1,
144
+ "a second coordinator observation must not replay the already-submitted leader message"
145
+ );
146
+ }
147
+
148
+ #[test]
149
+ fn nonempty_missing_receipt_source_is_not_token_absent() {
150
+ assert_unavailable_rollout_converges(
151
+ "missing-rollout",
152
+ PathBuf::from("definitely-missing-leader-rollout.jsonl"),
153
+ false,
154
+ );
155
+ }
156
+
157
+ #[test]
158
+ fn nonempty_unreadable_receipt_source_is_not_token_absent() {
159
+ assert_unavailable_rollout_converges(
160
+ "unreadable-rollout",
161
+ PathBuf::from("leader-rollout-is-a-directory"),
162
+ true,
163
+ );
164
+ }
165
+
166
+ fn assert_unavailable_rollout_converges(tag: &str, rollout: PathBuf, directory: bool) {
167
+ let case = Case::new_with_unavailable_rollout(tag, rollout, directory);
168
+ let transport = RecordingTransport::new("");
169
+ let message_id = case.seed_message("unavailable receipt source canary");
170
+
171
+ let first = deliver_pending_message(
172
+ &case.workspace,
173
+ &case.store,
174
+ &transport,
175
+ &message_id,
176
+ &case.event_log,
177
+ &case.state,
178
+ )
179
+ .expect("verified leader transport submission");
180
+
181
+ assert_eq!(
182
+ case.message_status(&message_id),
183
+ "injected_awaiting_receipt",
184
+ "F4 receipt observation must distinguish an unreadable nonempty rollout_path \
185
+ from a readable rollout that lacks the token. The former is \
186
+ receipt-source-unavailable and must converge to the typed parked state, \
187
+ not remain submitted_pending_acceptance. shape={tag} outcome={first:?}"
188
+ );
189
+ assert_eq!(
190
+ first.verification.as_deref(),
191
+ Some("leader_receipt_source_unavailable"),
192
+ "the outcome must expose read-unavailable rather than TokenAbsent; shape={tag}"
193
+ );
194
+ assert_eq!(transport.inject_count(), 1);
195
+
196
+ let _ = deliver_pending_message(
197
+ &case.workspace,
198
+ &case.store,
199
+ &transport,
200
+ &message_id,
201
+ &case.event_log,
202
+ &case.state,
203
+ );
204
+ assert_eq!(
205
+ transport.inject_count(),
206
+ 1,
207
+ "read-unavailable convergence must not obtain progress by a second physical \
208
+ injection; shape={tag}"
209
+ );
210
+ }
211
+
212
+ #[test]
213
+ fn worker_consumption_writer_remains_a_positive_control() {
214
+ let case = Case::new("worker-positive-control");
215
+ let message_id = case
216
+ .store
217
+ .create_message(
218
+ None,
219
+ "leader",
220
+ "worker",
221
+ "worker consume canary",
222
+ None,
223
+ false,
224
+ None,
225
+ )
226
+ .unwrap();
227
+
228
+ case.store.mark(&message_id, "consumed", None).unwrap();
229
+
230
+ assert_eq!(
231
+ case.message_status(&message_id),
232
+ "consumed",
233
+ "positive control: the independent worker consumption writer must remain reachable"
234
+ );
235
+ }
236
+
102
237
  struct Case {
103
238
  workspace: PathBuf,
104
239
  rollout: PathBuf,
@@ -134,6 +269,58 @@ impl Case {
134
269
  }
135
270
  }
136
271
 
272
+ fn new_without_rollout(tag: &str) -> Self {
273
+ let workspace = tmp_ws(&format!("leader-inject-acceptance-{tag}"));
274
+ let rollout = workspace.join("absent-leader.jsonl");
275
+ let state = serde_json::json!({
276
+ "active_team_key": "acceptance-team",
277
+ "session_name": "team-acceptance",
278
+ "leader_receiver": {
279
+ "pane_id": "%leader",
280
+ "status": "attached",
281
+ "provider": "claude"
282
+ }
283
+ });
284
+ crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
285
+ let store = MessageStore::open(&workspace).unwrap();
286
+ let event_log = EventLog::new(&workspace);
287
+ Self {
288
+ workspace,
289
+ rollout,
290
+ store,
291
+ event_log,
292
+ state,
293
+ }
294
+ }
295
+
296
+ fn new_with_unavailable_rollout(tag: &str, relative: PathBuf, directory: bool) -> Self {
297
+ let workspace = tmp_ws(&format!("leader-inject-acceptance-{tag}"));
298
+ let rollout = workspace.join(relative);
299
+ if directory {
300
+ std::fs::create_dir_all(&rollout).unwrap();
301
+ }
302
+ let state = serde_json::json!({
303
+ "active_team_key": "acceptance-team",
304
+ "session_name": "team-acceptance",
305
+ "leader_receiver": {
306
+ "pane_id": "%leader",
307
+ "status": "attached",
308
+ "provider": "claude",
309
+ "rollout_path": rollout,
310
+ }
311
+ });
312
+ crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
313
+ let store = MessageStore::open(&workspace).unwrap();
314
+ let event_log = EventLog::new(&workspace);
315
+ Self {
316
+ workspace,
317
+ rollout,
318
+ store,
319
+ event_log,
320
+ state,
321
+ }
322
+ }
323
+
137
324
  fn seed_message(&self, content: &str) -> String {
138
325
  self.store
139
326
  .create_message(None, "worker", "leader", content, None, false, None)
@@ -2976,9 +2976,8 @@ fn gate054_attached_status_still_accepts_when_pane_is_live() {
2976
2976
  // Injection-case revision: transport submit success is NOT a provider
2977
2977
  // receipt, so the old `delivered` pin was a false-positive lock
2978
2978
  // (MUST-10: physical submit is the framework's fact; acceptance is the
2979
- // provider's). The un-refused path now parks the SAME row in
2980
- // submitted_pending_acceptance until a provider receipt or retry
2981
- // schedule advances it.
2979
+ // provider's). The un-refused path now records the SAME row in the typed
2980
+ // post-injection fact state until a provider receipt advances it.
2982
2981
  let ws = tmp_ws("gate054ok");
2983
2982
  let store = store_for(&ws);
2984
2983
  let log = EventLog::new(&ws);
@@ -3012,10 +3011,18 @@ fn gate054_attached_status_still_accepts_when_pane_is_live() {
3012
3011
  1,
3013
3012
  "gate054: exactly one physical inject must have happened (MUST-10 anchor)"
3014
3013
  );
3014
+ let injected_awaiting_receipt = crate::db::message_store::MessageRowStatus::ALL
3015
+ .iter()
3016
+ .map(|status| status.as_str())
3017
+ .find(|status| *status == "injected_awaiting_receipt")
3018
+ .expect(
3019
+ "gate054 signed reanchor: typed status directory must expose \
3020
+ injected_awaiting_receipt",
3021
+ );
3015
3022
  assert_eq!(
3016
- out.message_status.0, "submitted_pending_acceptance",
3017
- "gate054 (injection-case revision): a successful physical submit without a provider \
3018
- receipt must park as submitted_pending_acceptance, never claim delivered: {out:?}"
3023
+ out.message_status.0, injected_awaiting_receipt,
3024
+ "gate054 signed reanchor: a successful physical inject without a provider receipt \
3025
+ must remain non-delivered as injected_awaiting_receipt, never claim delivered: {out:?}"
3019
3026
  );
3020
3027
  }
3021
3028
 
@@ -53,6 +53,11 @@ pub enum DeliveryRefusal {
53
53
  TmuxTargetMissing,
54
54
  MessageAlreadyClaimed,
55
55
  LeaderNotAttached,
56
+ /// The recorded leader pane is live but belongs to another workspace.
57
+ /// Keep this typed cause distinct from an actually unattached leader so
58
+ /// the send surface can return the existing recovery action.
59
+ #[serde(rename = "PaneWorkspaceMismatch")]
60
+ PaneWorkspaceMismatch,
56
61
  NoCallerPane,
57
62
  TeamOwnerMismatch,
58
63
  Ambiguous,
@@ -529,6 +529,11 @@ pub(crate) fn requeue_blocked_leader_messages(
529
529
  "blocked_leader_unbound": counts.blocked_leader_unbound,
530
530
  "queued_until_leader_attach": counts.queued_until_leader_attach,
531
531
  },
532
+ "attach_window_failures": {
533
+ "reason": "leader_not_attached",
534
+ "count": counts.blocked_leader_unbound,
535
+ "status": "requeued_pending_physical_retry",
536
+ },
532
537
  }),
533
538
  )?;
534
539
  }
@@ -18,6 +18,7 @@ pub mod ids;
18
18
  // cli/send.rs (positional adapter), mcp_server/tools.rs (owner-scoped
19
19
  // peer suggestions). Never a routing authority — advisory only.
20
20
  pub(crate) mod name_similarity;
21
+ pub mod pane_authority_refusal;
21
22
  pub mod paths;
22
23
  pub mod permissions;
23
24
  pub mod routing;
@@ -0,0 +1,358 @@
1
+ //! Shared typed catalog for ambient tmux pane authority refusals.
2
+ //!
3
+ //! This module owns identities and payload shape only. Launchers and CLI
4
+ //! presenters remain responsible for collecting facts and rendering them.
5
+
6
+ use std::path::PathBuf;
7
+
8
+ pub const REASON_FIELD: &str = "reason";
9
+ pub const AVAILABILITY_FIELD: &str = "availability";
10
+ pub const CAUSE_FIELD: &str = "cause";
11
+ pub const ACTION_REQUIRED_FIELD: &str = "action_required";
12
+ pub const HINT_ACTION_FIELD: &str = "hint_action";
13
+ pub const ACTION_FIELD: &str = "action";
14
+
15
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
16
+ pub enum PaneAuthorityRefusalField {
17
+ RequestedWorkspace,
18
+ ObservedPaneId,
19
+ ObservedPaneWorkspace,
20
+ Endpoint,
21
+ CallerControllingTty,
22
+ ObservedPaneTty,
23
+ }
24
+
25
+ impl PaneAuthorityRefusalField {
26
+ pub const fn as_str(self) -> &'static str {
27
+ match self {
28
+ Self::RequestedWorkspace => "requested_workspace",
29
+ Self::ObservedPaneId => "observed_pane_id",
30
+ Self::ObservedPaneWorkspace => "observed_pane_workspace",
31
+ Self::Endpoint => "endpoint",
32
+ Self::CallerControllingTty => "caller_controlling_tty",
33
+ Self::ObservedPaneTty => "observed_pane_tty",
34
+ }
35
+ }
36
+ }
37
+
38
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
39
+ pub enum PaneAuthorityRefusalReason {
40
+ AmbientTmuxEndpointUnavailable,
41
+ AmbientPaneIdUnavailable,
42
+ AmbientPaneWorkspaceUnavailable,
43
+ CallerControllingTtyUnavailable,
44
+ ObservedPaneTtyUnavailable,
45
+ PaneTtyMismatch,
46
+ PaneWorkspaceMismatch,
47
+ }
48
+
49
+ impl PaneAuthorityRefusalReason {
50
+ pub const ALL: [Self; 7] = [
51
+ Self::AmbientTmuxEndpointUnavailable,
52
+ Self::AmbientPaneIdUnavailable,
53
+ Self::AmbientPaneWorkspaceUnavailable,
54
+ Self::CallerControllingTtyUnavailable,
55
+ Self::ObservedPaneTtyUnavailable,
56
+ Self::PaneTtyMismatch,
57
+ Self::PaneWorkspaceMismatch,
58
+ ];
59
+
60
+ pub const fn as_str(self) -> &'static str {
61
+ match self {
62
+ Self::AmbientTmuxEndpointUnavailable => "AmbientTmuxEndpointUnavailable",
63
+ Self::AmbientPaneIdUnavailable => "AmbientPaneIdUnavailable",
64
+ Self::AmbientPaneWorkspaceUnavailable => "AmbientPaneWorkspaceUnavailable",
65
+ Self::CallerControllingTtyUnavailable => "CallerControllingTtyUnavailable",
66
+ Self::ObservedPaneTtyUnavailable => "ObservedPaneTtyUnavailable",
67
+ Self::PaneTtyMismatch => "PaneTtyMismatch",
68
+ Self::PaneWorkspaceMismatch => "PaneWorkspaceMismatch",
69
+ }
70
+ }
71
+
72
+ pub const fn required_fact_fields(self) -> &'static [PaneAuthorityRefusalField] {
73
+ use PaneAuthorityRefusalField::{
74
+ CallerControllingTty, Endpoint, ObservedPaneId, ObservedPaneTty, ObservedPaneWorkspace,
75
+ RequestedWorkspace,
76
+ };
77
+
78
+ match self {
79
+ Self::AmbientTmuxEndpointUnavailable => &[RequestedWorkspace, Endpoint],
80
+ Self::AmbientPaneIdUnavailable => &[RequestedWorkspace, ObservedPaneId, Endpoint],
81
+ Self::AmbientPaneWorkspaceUnavailable => &[
82
+ RequestedWorkspace,
83
+ ObservedPaneId,
84
+ ObservedPaneWorkspace,
85
+ Endpoint,
86
+ ],
87
+ Self::CallerControllingTtyUnavailable => &[
88
+ RequestedWorkspace,
89
+ ObservedPaneId,
90
+ Endpoint,
91
+ CallerControllingTty,
92
+ ],
93
+ Self::ObservedPaneTtyUnavailable => &[
94
+ RequestedWorkspace,
95
+ ObservedPaneId,
96
+ Endpoint,
97
+ ObservedPaneTty,
98
+ ],
99
+ Self::PaneTtyMismatch => &[
100
+ RequestedWorkspace,
101
+ ObservedPaneId,
102
+ Endpoint,
103
+ CallerControllingTty,
104
+ ObservedPaneTty,
105
+ ],
106
+ Self::PaneWorkspaceMismatch => &[
107
+ RequestedWorkspace,
108
+ ObservedPaneId,
109
+ ObservedPaneWorkspace,
110
+ Endpoint,
111
+ ],
112
+ }
113
+ }
114
+ }
115
+
116
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
117
+ pub enum PaneAuthorityFactAvailability {
118
+ Unavailable,
119
+ }
120
+
121
+ impl PaneAuthorityFactAvailability {
122
+ pub const fn as_str(self) -> &'static str {
123
+ match self {
124
+ Self::Unavailable => "unavailable",
125
+ }
126
+ }
127
+ }
128
+
129
+ #[derive(Clone, Debug, Eq, PartialEq)]
130
+ pub struct UnavailablePaneAuthorityFact<C> {
131
+ pub cause: C,
132
+ }
133
+
134
+ impl<C> UnavailablePaneAuthorityFact<C> {
135
+ pub const fn new(cause: C) -> Self {
136
+ Self { cause }
137
+ }
138
+
139
+ pub const fn availability(&self) -> PaneAuthorityFactAvailability {
140
+ PaneAuthorityFactAvailability::Unavailable
141
+ }
142
+ }
143
+
144
+ macro_rules! unavailable_causes {
145
+ ($name:ident { $($variant:ident => $wire:literal),+ $(,)? }) => {
146
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
147
+ pub enum $name {
148
+ $($variant),+
149
+ }
150
+
151
+ impl $name {
152
+ pub const fn as_str(self) -> &'static str {
153
+ match self {
154
+ $(Self::$variant => $wire),+
155
+ }
156
+ }
157
+ }
158
+ };
159
+ }
160
+
161
+ unavailable_causes!(AmbientTmuxEndpointUnavailableCause {
162
+ TmuxValueNotUnicode => "tmux_value_not_unicode",
163
+ TmuxTupleFieldCountInvalid => "tmux_tuple_field_count_invalid",
164
+ EndpointMissing => "endpoint_missing",
165
+ EndpointNotAbsolute => "endpoint_not_absolute",
166
+ ServerPidInvalid => "server_pid_invalid",
167
+ SessionIndexInvalid => "session_index_invalid",
168
+ });
169
+
170
+ unavailable_causes!(AmbientPaneIdUnavailableCause {
171
+ EnvironmentValueMissing => "environment_value_missing",
172
+ EnvironmentValueEmpty => "environment_value_empty",
173
+ EnvironmentValueNotUnicode => "environment_value_not_unicode",
174
+ });
175
+
176
+ unavailable_causes!(AmbientPaneWorkspaceUnavailableCause {
177
+ PaneQueryFailed => "pane_query_failed",
178
+ PaneNotFound => "pane_not_found",
179
+ CurrentPathMissing => "current_path_missing",
180
+ });
181
+
182
+ unavailable_causes!(CallerControllingTtyUnavailableCause {
183
+ NoControllingTty => "no_controlling_tty",
184
+ DeviceIdentityUnresolvable => "device_identity_unresolvable",
185
+ PlatformUnsupported => "platform_unsupported",
186
+ });
187
+
188
+ unavailable_causes!(ObservedPaneTtyUnavailableCause {
189
+ PaneTtyMissing => "pane_tty_missing",
190
+ PaneTtyUnresolvable => "pane_tty_unresolvable",
191
+ PaneTtyNotCharacterDevice => "pane_tty_not_character_device",
192
+ });
193
+
194
+ #[derive(Clone, Debug, Eq, PartialEq)]
195
+ pub struct AmbientTmuxEndpointUnavailableFacts {
196
+ pub requested_workspace: PathBuf,
197
+ pub endpoint: UnavailablePaneAuthorityFact<AmbientTmuxEndpointUnavailableCause>,
198
+ }
199
+
200
+ #[derive(Clone, Debug, Eq, PartialEq)]
201
+ pub struct AmbientPaneIdUnavailableFacts {
202
+ pub requested_workspace: PathBuf,
203
+ pub observed_pane_id: UnavailablePaneAuthorityFact<AmbientPaneIdUnavailableCause>,
204
+ pub endpoint: String,
205
+ }
206
+
207
+ #[derive(Clone, Debug, Eq, PartialEq)]
208
+ pub struct AmbientPaneWorkspaceUnavailableFacts {
209
+ pub requested_workspace: PathBuf,
210
+ pub observed_pane_id: String,
211
+ pub observed_pane_workspace: UnavailablePaneAuthorityFact<AmbientPaneWorkspaceUnavailableCause>,
212
+ pub endpoint: String,
213
+ }
214
+
215
+ #[derive(Clone, Debug, Eq, PartialEq)]
216
+ pub struct CallerControllingTtyUnavailableFacts {
217
+ pub requested_workspace: PathBuf,
218
+ pub observed_pane_id: String,
219
+ pub endpoint: String,
220
+ pub caller_controlling_tty: UnavailablePaneAuthorityFact<CallerControllingTtyUnavailableCause>,
221
+ }
222
+
223
+ #[derive(Clone, Debug, Eq, PartialEq)]
224
+ pub struct ObservedPaneTtyUnavailableFacts {
225
+ pub requested_workspace: PathBuf,
226
+ pub observed_pane_id: String,
227
+ pub endpoint: String,
228
+ pub observed_pane_tty: UnavailablePaneAuthorityFact<ObservedPaneTtyUnavailableCause>,
229
+ }
230
+
231
+ #[derive(Clone, Debug, Eq, PartialEq)]
232
+ pub struct PaneTtyMismatchFacts {
233
+ pub requested_workspace: PathBuf,
234
+ pub observed_pane_id: String,
235
+ pub endpoint: String,
236
+ pub caller_controlling_tty: u64,
237
+ pub observed_pane_tty: u64,
238
+ }
239
+
240
+ #[derive(Clone, Debug, Eq, PartialEq)]
241
+ pub struct PaneWorkspaceMismatchFacts {
242
+ pub requested_workspace: PathBuf,
243
+ pub observed_pane_id: String,
244
+ pub observed_pane_workspace: PathBuf,
245
+ pub endpoint: String,
246
+ }
247
+
248
+ #[derive(Clone, Debug, Eq, PartialEq)]
249
+ pub enum PaneAuthorityRefusalFacts {
250
+ AmbientTmuxEndpointUnavailable(AmbientTmuxEndpointUnavailableFacts),
251
+ AmbientPaneIdUnavailable(AmbientPaneIdUnavailableFacts),
252
+ AmbientPaneWorkspaceUnavailable(AmbientPaneWorkspaceUnavailableFacts),
253
+ CallerControllingTtyUnavailable(CallerControllingTtyUnavailableFacts),
254
+ ObservedPaneTtyUnavailable(ObservedPaneTtyUnavailableFacts),
255
+ PaneTtyMismatch(PaneTtyMismatchFacts),
256
+ PaneWorkspaceMismatch(PaneWorkspaceMismatchFacts),
257
+ }
258
+
259
+ impl PaneAuthorityRefusalFacts {
260
+ pub const fn reason(&self) -> PaneAuthorityRefusalReason {
261
+ match self {
262
+ Self::AmbientTmuxEndpointUnavailable(_) => {
263
+ PaneAuthorityRefusalReason::AmbientTmuxEndpointUnavailable
264
+ }
265
+ Self::AmbientPaneIdUnavailable(_) => {
266
+ PaneAuthorityRefusalReason::AmbientPaneIdUnavailable
267
+ }
268
+ Self::AmbientPaneWorkspaceUnavailable(_) => {
269
+ PaneAuthorityRefusalReason::AmbientPaneWorkspaceUnavailable
270
+ }
271
+ Self::CallerControllingTtyUnavailable(_) => {
272
+ PaneAuthorityRefusalReason::CallerControllingTtyUnavailable
273
+ }
274
+ Self::ObservedPaneTtyUnavailable(_) => {
275
+ PaneAuthorityRefusalReason::ObservedPaneTtyUnavailable
276
+ }
277
+ Self::PaneTtyMismatch(_) => PaneAuthorityRefusalReason::PaneTtyMismatch,
278
+ Self::PaneWorkspaceMismatch(_) => PaneAuthorityRefusalReason::PaneWorkspaceMismatch,
279
+ }
280
+ }
281
+ }
282
+
283
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
284
+ pub enum PaneAuthorityRecoveryHint {
285
+ AttachLeader,
286
+ }
287
+
288
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
289
+ pub enum PaneAuthorityRecoveryAction {
290
+ OpenTerminalOutsideCurrentTmuxPaneOrAttachFromMatchingPane,
291
+ }
292
+
293
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
294
+ pub struct PaneAuthorityRecovery {
295
+ pub action_required: bool,
296
+ pub hint_action: PaneAuthorityRecoveryHint,
297
+ pub action: PaneAuthorityRecoveryAction,
298
+ }
299
+
300
+ impl PaneAuthorityRecovery {
301
+ pub const REQUIRED: Self = Self {
302
+ action_required: true,
303
+ hint_action: PaneAuthorityRecoveryHint::AttachLeader,
304
+ action:
305
+ PaneAuthorityRecoveryAction::OpenTerminalOutsideCurrentTmuxPaneOrAttachFromMatchingPane,
306
+ };
307
+ }
308
+
309
+ #[derive(Clone, Debug, Eq, PartialEq)]
310
+ pub struct PaneAuthorityRefusal {
311
+ pub facts: PaneAuthorityRefusalFacts,
312
+ pub recovery: PaneAuthorityRecovery,
313
+ }
314
+
315
+ impl PaneAuthorityRefusal {
316
+ pub const fn new(facts: PaneAuthorityRefusalFacts) -> Self {
317
+ Self {
318
+ facts,
319
+ recovery: PaneAuthorityRecovery::REQUIRED,
320
+ }
321
+ }
322
+
323
+ pub const fn reason(&self) -> PaneAuthorityRefusalReason {
324
+ self.facts.reason()
325
+ }
326
+ }
327
+
328
+ #[cfg(test)]
329
+ mod tests {
330
+ use super::{PaneAuthorityRefusalField as Field, PaneAuthorityRefusalReason as Reason};
331
+
332
+ #[test]
333
+ fn reason_catalog_is_closed_and_mismatch_requires_all_four_workspace_facts() {
334
+ assert_eq!(Reason::ALL.len(), 7);
335
+ assert_eq!(
336
+ Reason::PaneWorkspaceMismatch.required_fact_fields(),
337
+ &[
338
+ Field::RequestedWorkspace,
339
+ Field::ObservedPaneId,
340
+ Field::ObservedPaneWorkspace,
341
+ Field::Endpoint,
342
+ ]
343
+ );
344
+ }
345
+
346
+ #[test]
347
+ fn unavailable_reasons_require_the_fact_they_could_not_observe() {
348
+ assert!(Reason::AmbientTmuxEndpointUnavailable
349
+ .required_fact_fields()
350
+ .contains(&Field::Endpoint));
351
+ assert!(Reason::AmbientPaneIdUnavailable
352
+ .required_fact_fields()
353
+ .contains(&Field::ObservedPaneId));
354
+ assert!(Reason::AmbientPaneWorkspaceUnavailable
355
+ .required_fact_fields()
356
+ .contains(&Field::ObservedPaneWorkspace));
357
+ }
358
+ }
@@ -400,6 +400,7 @@ fn check_agent(agent: &Yaml, path: &str, errors: &mut Vec<String>) {
400
400
  "preferred_for",
401
401
  "avoid_for",
402
402
  "output_contract",
403
+ "communication_mode",
403
404
  "paused",
404
405
  "auth_mode",
405
406
  "profile",
@@ -412,6 +413,21 @@ fn check_agent(agent: &Yaml, path: &str, errors: &mut Vec<String>) {
412
413
  if !agent.is_map() {
413
414
  return;
414
415
  }
416
+ if let Some(value) = agent.get("communication_mode") {
417
+ match value.as_str() {
418
+ Some(raw) if crate::communication_mode::CommunicationMode::parse(raw).is_none() => {
419
+ errors.push(format!(
420
+ "{path}/communication_mode: unknown communication_mode '{raw}'"
421
+ ));
422
+ }
423
+ None => {
424
+ errors.push(format!(
425
+ "{path}/communication_mode: unknown communication_mode"
426
+ ));
427
+ }
428
+ Some(_) => {}
429
+ }
430
+ }
415
431
  // 0.4.x provider effort MVP step 3: effort syntactic + semantic validation.
416
432
  if let Some(raw) = agent.get("effort").and_then(Yaml::as_str) {
417
433
  match crate::model::enums::ProviderEffort::parse(raw) {