@team-agent/installer 0.5.115 → 0.5.117

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 (36) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/mod.rs +143 -12
  4. package/crates/team-agent/src/cli/shutdown_socket.rs +288 -0
  5. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +11 -4
  6. package/crates/team-agent/src/contract_runtime/backend.rs +88 -8
  7. package/crates/team-agent/src/contract_runtime/commands.rs +88 -7
  8. package/crates/team-agent/src/contract_runtime/discovery.rs +42 -10
  9. package/crates/team-agent/src/contract_runtime/mcp.rs +3 -0
  10. package/crates/team-agent/src/contract_runtime/pump.rs +2 -0
  11. package/crates/team-agent/src/contract_runtime/registry.rs +86 -9
  12. package/crates/team-agent/src/contract_runtime/workspace.rs +79 -11
  13. package/crates/team-agent/src/leader/lease.rs +1 -18
  14. package/crates/team-agent/src/lifecycle/pane_input_lock.rs +86 -8
  15. package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -0
  16. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +101 -72
  17. package/crates/team-agent/src/lifecycle/restart/selection.rs +30 -8
  18. package/crates/team-agent/src/messaging/delivery.rs +132 -27
  19. package/crates/team-agent/src/messaging/enter_rescue.rs +334 -0
  20. package/crates/team-agent/src/messaging/enter_rescue_tests.rs +566 -0
  21. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  22. package/crates/team-agent/src/messaging/mod.rs +1 -0
  23. package/crates/team-agent/src/messaging/scheduler.rs +8 -0
  24. package/crates/team-agent/src/messaging/tests/basic.rs +2 -0
  25. package/crates/team-agent/src/messaging/types.rs +1 -0
  26. package/crates/team-agent/src/provider/session/capture.rs +24 -1
  27. package/crates/team-agent/src/state/persist.rs +72 -2
  28. package/crates/team-agent/src/state/projection.rs +29 -0
  29. package/crates/team-agent/src/state/repository/tests.rs +70 -0
  30. package/crates/team-agent/src/state/repository.rs +52 -1
  31. package/crates/team-agent/src/tmux_backend/native_input.rs +494 -0
  32. package/crates/team-agent/src/tmux_backend/native_input_tests.rs +781 -0
  33. package/crates/team-agent/src/tmux_backend.rs +49 -6
  34. package/crates/team-agent/src/transport/native_input.rs +594 -0
  35. package/crates/team-agent/src/transport.rs +19 -0
  36. package/package.json +4 -4
@@ -4,21 +4,33 @@ use std::collections::BTreeMap;
4
4
  use std::path::PathBuf;
5
5
  use team_agent_contract::contract::{
6
6
  descriptor::{PromptCarrier, ProviderDescriptor, ToolNaming},
7
+ types::InstanceIdentity,
7
8
  hooks::{PlanHook, ProviderHooks},
8
9
  plan::{LaunchPlan, ResolvedLaunch},
9
10
  types::{ContractError, Support},
10
11
  };
11
12
  use team_agent_contract::kiro::{self, KiroAdapter, KIRO_DESCRIPTOR};
12
13
  use team_agent_contract::providers::cursor::{self, CursorAdapter, CURSOR_DESCRIPTOR};
13
- use team_agent_contract::runtime::{delivery::ControlDefinition, native_panel::NativePanelPolicy};
14
+ use team_agent_contract::providers::grok_build::{self, GrokAdapter, GROK_BUILD_DESCRIPTOR};
15
+ use team_agent_contract::runtime::{
16
+ delivery::{ControlDefinition, StartupDefinition},
17
+ native_panel::{NativePanelPolicy, SessionPanelPolicy},
18
+ };
19
+
20
+ const DESCRIPTORS: &[&ProviderDescriptor] = &[&KIRO_DESCRIPTOR, &CURSOR_DESCRIPTOR, &GROK_BUILD_DESCRIPTOR];
14
21
 
15
- const DESCRIPTORS: &[&ProviderDescriptor] = &[&KIRO_DESCRIPTOR, &CURSOR_DESCRIPTOR];
22
+ #[derive(Clone)]
23
+ pub enum NativeRuntime {
24
+ Cursor(cursor::native::RuntimeInstallation),
25
+ GrokBuild(grok_build::native::RuntimeInstallation),
26
+ }
16
27
 
17
28
  /// Leaf selection stays here; the framework, lifecycle and outbox remain shared.
18
29
  #[derive(Clone, Copy, Debug, PartialEq, Eq)]
19
30
  pub(super) enum NativeProvider {
20
31
  Kiro,
21
32
  Cursor,
33
+ GrokBuild,
22
34
  }
23
35
 
24
36
  impl NativeProvider {
@@ -26,19 +38,21 @@ impl NativeProvider {
26
38
  match self {
27
39
  Self::Kiro => &KIRO_DESCRIPTOR,
28
40
  Self::Cursor => &CURSOR_DESCRIPTOR,
41
+ Self::GrokBuild => &GROK_BUILD_DESCRIPTOR,
29
42
  }
30
43
  }
31
44
 
32
45
  pub fn action_budget(self) -> std::time::Duration {
33
46
  // Kiro's existing budget is unchanged; Cursor's measured cold-ready
34
47
  // path needs a larger finite bound, not an unbounded readiness wait.
35
- std::time::Duration::from_secs(match self { Self::Kiro => 30, Self::Cursor => 90 })
48
+ std::time::Duration::from_secs(match self { Self::Kiro => 30, Self::Cursor | Self::GrokBuild => 90 })
36
49
  }
37
50
 
38
51
  pub fn controls(self) -> &'static [ControlDefinition] {
39
52
  match self {
40
53
  Self::Kiro => kiro::interaction::CONTROLS,
41
54
  Self::Cursor => cursor::interaction::CONTROLS,
55
+ Self::GrokBuild => grok_build::interaction::CONTROLS,
42
56
  }
43
57
  }
44
58
 
@@ -48,10 +62,24 @@ impl NativeProvider {
48
62
  matches!(self.descriptor().prompt.carrier, Support::Supported(PromptCarrier::RuntimeBridge))
49
63
  }
50
64
 
51
- /// Cursor has no measured neutral TUI tools panel. Its protocol binding
52
- /// must come from the separately fenced native tool-roundtrip ledger.
65
+ /// These providers use the separately fenced native tool-roundtrip ledger,
66
+ /// never a guessed neutral tools panel or another provider's inspector.
53
67
  pub fn uses_tool_roundtrips(self) -> bool {
54
- matches!(self, Self::Cursor)
68
+ matches!(self, Self::Cursor | Self::GrokBuild)
69
+ }
70
+
71
+ pub fn startup(self) -> Option<&'static StartupDefinition> {
72
+ match self {
73
+ Self::GrokBuild => Some(&grok_build::interaction::STARTUP),
74
+ Self::Kiro | Self::Cursor => None,
75
+ }
76
+ }
77
+
78
+ pub fn session_panel(self) -> Option<&'static SessionPanelPolicy> {
79
+ match self {
80
+ Self::GrokBuild => Some(&grok_build::interaction::SESSION_PANEL),
81
+ Self::Kiro | Self::Cursor => None,
82
+ }
55
83
  }
56
84
 
57
85
  pub fn tool_panel(self) -> Result<&'static NativePanelPolicy, ContractError> {
@@ -61,9 +89,20 @@ impl NativeProvider {
61
89
  field: "Cursor native Team-tool inspector",
62
90
  reason: cursor::UNVERIFIED,
63
91
  }),
92
+ Self::GrokBuild => Err(ContractError::Unverified {
93
+ field: "Grok Build native Team-tool inspector",
94
+ reason: grok_build::UNVERIFIED,
95
+ }),
64
96
  }
65
97
  }
66
98
 
99
+ pub fn server_key_for(self, identity: &InstanceIdentity) -> Result<String, ContractError> {
100
+ if self == Self::GrokBuild {
101
+ return Ok(grok_build::server_key(identity));
102
+ }
103
+ Ok(self.server_key()?.into())
104
+ }
105
+
67
106
  pub fn server_key(self) -> Result<&'static str, ContractError> {
68
107
  match self.descriptor().tool_names.naming.require("tool naming")? {
69
108
  ToolNaming::StaticServer { key } => Ok(key),
@@ -84,6 +123,9 @@ impl NativeProvider {
84
123
  Self::Cursor => CursorAdapter::new(cursor::McpStdio {
85
124
  executable, arguments, environment,
86
125
  }).map(NativeAdapter::Cursor),
126
+ Self::GrokBuild => GrokAdapter::new(grok_build::McpStdio {
127
+ executable, arguments, environment,
128
+ }).map(NativeAdapter::GrokBuild),
87
129
  }
88
130
  }
89
131
  }
@@ -91,13 +133,14 @@ impl NativeProvider {
91
133
  pub(super) enum NativeAdapter {
92
134
  Kiro(KiroAdapter),
93
135
  Cursor(CursorAdapter),
136
+ GrokBuild(GrokAdapter),
94
137
  }
95
138
 
96
139
  impl NativeAdapter {
97
140
  pub fn with_launch_context(
98
141
  self,
99
142
  project: PathBuf,
100
- runtime: Option<cursor::native::RuntimeInstallation>,
143
+ runtime: Option<NativeRuntime>,
101
144
  ) -> Result<Self, ContractError> {
102
145
  match self {
103
146
  Self::Kiro(adapter) if runtime.is_none() => Ok(Self::Kiro(adapter)),
@@ -105,7 +148,16 @@ impl NativeAdapter {
105
148
  Self::Cursor(adapter) => {
106
149
  let adapter = adapter.with_project(project)?;
107
150
  Ok(Self::Cursor(match runtime {
108
- Some(runtime) => adapter.with_runtime(runtime),
151
+ Some(NativeRuntime::Cursor(runtime)) => adapter.with_runtime(runtime),
152
+ Some(_) => return Err(ContractError::Mismatch("native runtime provider")),
153
+ None => adapter,
154
+ }))
155
+ }
156
+ Self::GrokBuild(adapter) => {
157
+ let adapter = adapter.with_project(project)?;
158
+ Ok(Self::GrokBuild(match runtime {
159
+ Some(NativeRuntime::GrokBuild(runtime)) => adapter.with_runtime(runtime),
160
+ Some(_) => return Err(ContractError::Mismatch("native runtime provider")),
109
161
  None => adapter,
110
162
  }))
111
163
  }
@@ -116,6 +168,7 @@ impl NativeAdapter {
116
168
  match self {
117
169
  Self::Kiro(adapter) => adapter.hooks(),
118
170
  Self::Cursor(adapter) => adapter.hooks(),
171
+ Self::GrokBuild(adapter) => adapter.hooks(),
119
172
  }
120
173
  }
121
174
 
@@ -123,6 +176,7 @@ impl NativeAdapter {
123
176
  match self {
124
177
  Self::Kiro(adapter) => adapter.plan(request),
125
178
  Self::Cursor(adapter) => adapter.plan(request),
179
+ Self::GrokBuild(adapter) => adapter.plan(request),
126
180
  }
127
181
  }
128
182
  }
@@ -132,6 +186,7 @@ pub(super) fn provider(key: &str) -> Result<NativeProvider, ContractError> {
132
186
  match descriptor.identity.id {
133
187
  "kiro" => Ok(NativeProvider::Kiro),
134
188
  "cursor" => Ok(NativeProvider::Cursor),
189
+ "grok_build" => Ok(NativeProvider::GrokBuild),
135
190
  _ => Err(ContractError::Invalid("contract provider dispatcher")),
136
191
  }
137
192
  }
@@ -168,11 +223,15 @@ mod tests {
168
223
  fn canonical_cursor_does_not_reclassify_legacy_names() {
169
224
  assert_eq!(provider("cursor").unwrap(), NativeProvider::Cursor);
170
225
  assert_eq!(provider("kiro").unwrap(), NativeProvider::Kiro);
171
- for legacy in ["cursor_agent", "agent", "pi", "codex"] {
226
+ for legacy in ["cursor_agent", "agent", "pi", "codex", "grok"] {
172
227
  assert!(descriptor(legacy).is_none());
173
228
  assert!(!recognizes(legacy));
174
229
  assert!(provider(legacy).is_err());
175
230
  }
231
+ assert_eq!(provider("grok_build").unwrap(), NativeProvider::GrokBuild);
232
+ assert!(recognizes(" Grok_Build "));
233
+ assert!(descriptor("Grok_Build").is_none());
234
+ assert!(GROK_BUILD_DESCRIPTOR.identity.aliases.is_empty());
176
235
  assert!(recognizes(" Cursor "));
177
236
  assert!(descriptor("Cursor").is_none());
178
237
  for descriptor in descriptors() {
@@ -180,6 +239,24 @@ mod tests {
180
239
  }
181
240
  }
182
241
 
242
+ #[test]
243
+ fn grok_server_key_is_generation_bound_and_has_no_inspector_fallback() {
244
+ use team_agent_contract::contract::types::{Generation, InstanceId, ScopeId, SeatId};
245
+ let mut identity = InstanceIdentity {
246
+ scope: ScopeId::new("scope").unwrap(), seat: SeatId::new("worker").unwrap(),
247
+ instance: InstanceId::new("instance").unwrap(), generation: Generation(1),
248
+ };
249
+ let first = NativeProvider::GrokBuild.server_key_for(&identity).unwrap();
250
+ assert_eq!(first, grok_build::server_key(&identity));
251
+ identity.generation = Generation(2);
252
+ assert_ne!(first, NativeProvider::GrokBuild.server_key_for(&identity).unwrap());
253
+ assert!(NativeProvider::GrokBuild.server_key().is_err());
254
+ assert!(NativeProvider::GrokBuild.tool_panel().is_err());
255
+ assert!(NativeProvider::GrokBuild.startup().is_some());
256
+ assert!(NativeProvider::GrokBuild.session_panel().is_some());
257
+ assert!(NativeProvider::GrokBuild.uses_tool_roundtrips());
258
+ }
259
+
183
260
  #[test]
184
261
  fn cursor_has_no_kiro_control_or_tool_inspector_fallback() {
185
262
  assert!(NativeProvider::Cursor.controls().is_empty());
@@ -1,5 +1,6 @@
1
- //! Stable per-seat primary cwd. The project stays an additional directory;
2
- //! private MCP files never overwrite a shared project or another seat's config.
1
+ //! Stable per-seat primary cwd outside project Git ancestry. Provider-specific
2
+ //! project access remains explicit; private MCP files never overwrite a shared
3
+ //! project or another seat's config, and old cwd incarnations are not migrated.
3
4
  use std::io::{Read, Write};
4
5
  use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
5
6
  use std::path::Path;
@@ -23,7 +24,7 @@ struct CwdRecord {
23
24
  pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, BackendError> {
24
25
  let root = root_for(&binding.workspace.path, &binding.scope);
25
26
  validate_directory(&root)?;
26
- let parent = root.join("workspaces");
27
+ let parent = binding.transport_root.join("workspaces");
27
28
  let path = parent.join(seat.as_str());
28
29
  let record_path = root.join(format!("cwd-{}.json", seat.as_str()));
29
30
  match std::fs::symlink_metadata(&record_path) {
@@ -43,12 +44,23 @@ pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, B
43
44
  return Err(BackendError::Metadata);
44
45
  }
45
46
  let record: CwdRecord = serde_json::from_slice(&bytes).map_err(|_| BackendError::Metadata)?;
46
- if record.version != 1 || record.seat != *seat || record.cwd.path != path {
47
+ // Old sessions stay pinned to their original project-local cwd.
48
+ // Only new allocations move outside Git; never relocate a resume.
49
+ let expected = match record.version {
50
+ 1 => root.join("workspaces").join(seat.as_str()),
51
+ 2 => {
52
+ validate_directory(&binding.transport_root)?;
53
+ reject_git_ancestors(&binding.transport_root)?;
54
+ path.clone()
55
+ }
56
+ _ => return Err(BackendError::Metadata),
57
+ };
58
+ if record.seat != *seat || record.cwd.path != expected {
47
59
  return Err(BackendError::Metadata);
48
60
  }
49
- validate_directory(&parent)?;
50
- validate_directory(&path)?;
51
- if resolve_cwd(&path)? != record.cwd {
61
+ validate_directory(expected.parent().ok_or(BackendError::Metadata)?)?;
62
+ validate_directory(&expected)?;
63
+ if resolve_cwd(&expected)? != record.cwd {
52
64
  return Err(BackendError::Metadata);
53
65
  }
54
66
  return Ok(record.cwd);
@@ -56,6 +68,8 @@ pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, B
56
68
  Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
57
69
  Err(error) => return Err(error.into()),
58
70
  }
71
+ validate_directory(&binding.transport_root)?;
72
+ reject_git_ancestors(&binding.transport_root)?;
59
73
  match std::fs::symlink_metadata(&parent) {
60
74
  Ok(_) => validate_directory(&parent)?,
61
75
  Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
@@ -68,7 +82,7 @@ pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, B
68
82
  std::fs::DirBuilder::new().mode(0o700).create(&path)?;
69
83
  validate_directory(&path)?;
70
84
  let cwd = resolve_cwd(&path)?;
71
- let record = CwdRecord { version: 1, seat: seat.clone(), cwd: cwd.clone() };
85
+ let record = CwdRecord { version: 2, seat: seat.clone(), cwd: cwd.clone() };
72
86
  let bytes = serde_json::to_vec(&record).map_err(|_| BackendError::Metadata)?;
73
87
  let mut file = std::fs::OpenOptions::new().write(true).create_new(true)
74
88
  .mode(0o600).custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC).open(record_path)?;
@@ -77,6 +91,19 @@ pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, B
77
91
  Ok(cwd)
78
92
  }
79
93
 
94
+ // Metadata only: handle both ordinary .git directories and worktree .git files;
95
+ // never read Git config, credentials, execute hooks, or assume /tmp is non-Git.
96
+ fn reject_git_ancestors(path: &Path) -> Result<(), BackendError> {
97
+ for ancestor in path.ancestors() {
98
+ match std::fs::symlink_metadata(ancestor.join(".git")) {
99
+ Ok(_) => return Err(BackendError::Metadata),
100
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
101
+ Err(error) => return Err(error.into()),
102
+ }
103
+ }
104
+ Ok(())
105
+ }
106
+
80
107
  fn validate_directory(path: &Path) -> Result<(), BackendError> {
81
108
  let metadata = std::fs::symlink_metadata(path)?;
82
109
  if !metadata.is_dir() || metadata.file_type().is_symlink() || path.canonicalize()? != path
@@ -108,8 +135,9 @@ mod tests {
108
135
  version: 1, workspace: resolve_cwd(&path).unwrap(), team: "test".into(),
109
136
  scope: ScopeId::new("cwd-test").unwrap(), endpoint: "framework:cwd-test".into(),
110
137
  candidate: "/controlled/candidate".into(), candidate_sha256: Digest([1; 32]),
111
- tmux: "/controlled/tmux".into(), transport_root: path.join("transport"),
138
+ tmux: "/controlled/tmux".into(), transport_root: path.with_extension("transport"),
112
139
  };
140
+ std::fs::DirBuilder::new().mode(0o700).create(&binding.transport_root).unwrap();
113
141
  let root = root_for(&path, &binding.scope);
114
142
  std::fs::create_dir_all(root.parent().unwrap()).unwrap();
115
143
  std::fs::DirBuilder::new().mode(0o700).create(&root).unwrap();
@@ -117,7 +145,10 @@ mod tests {
117
145
  }
118
146
  }
119
147
  impl Drop for Fixture {
120
- fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.path); }
148
+ fn drop(&mut self) {
149
+ let _ = std::fs::remove_dir_all(&self.path);
150
+ let _ = std::fs::remove_dir_all(&self.binding.transport_root);
151
+ }
121
152
  }
122
153
 
123
154
  #[test]
@@ -151,10 +182,47 @@ mod tests {
151
182
  fn unrecorded_existing_directory_is_not_claimed() {
152
183
  let f = Fixture::new();
153
184
  let root = root_for(&f.path, &f.binding.scope);
154
- let parent = root.join("workspaces");
185
+ let parent = f.binding.transport_root.join("workspaces");
155
186
  std::fs::DirBuilder::new().mode(0o700).create(&parent).unwrap();
156
187
  std::fs::DirBuilder::new().mode(0o700).create(parent.join("worker")).unwrap();
157
188
  assert!(prepare(&f.binding, &SeatId::new("worker").unwrap()).is_err());
158
189
  assert!(!root.join("cwd-worker.json").exists());
159
190
  }
191
+
192
+ #[test]
193
+ fn new_primary_is_outside_project_git_ancestors() {
194
+ let f = Fixture::new();
195
+ std::fs::create_dir(f.path.join(".git")).unwrap();
196
+ let cwd = prepare(&f.binding, &SeatId::new("worker").unwrap()).unwrap();
197
+ assert!(cwd.path.starts_with(&f.binding.transport_root));
198
+ assert!(!cwd.path.starts_with(&f.path));
199
+ reject_git_ancestors(&cwd.path).unwrap();
200
+ }
201
+
202
+ #[test]
203
+ fn existing_version_one_cwd_is_not_migrated() {
204
+ let f = Fixture::new();
205
+ let seat = SeatId::new("worker").unwrap();
206
+ let root = root_for(&f.path, &f.binding.scope);
207
+ let parent = root.join("workspaces");
208
+ let path = parent.join(seat.as_str());
209
+ std::fs::DirBuilder::new().mode(0o700).create(&parent).unwrap();
210
+ std::fs::DirBuilder::new().mode(0o700).create(&path).unwrap();
211
+ let cwd = resolve_cwd(&path).unwrap();
212
+ let record = CwdRecord { version: 1, seat: seat.clone(), cwd: cwd.clone() };
213
+ std::fs::write(root.join("cwd-worker.json"), serde_json::to_vec(&record).unwrap()).unwrap();
214
+ assert_eq!(prepare(&f.binding, &seat).unwrap(), cwd);
215
+ assert!(!f.binding.transport_root.join("workspaces").exists());
216
+ }
217
+
218
+ #[test]
219
+ fn git_directory_or_worktree_file_on_transport_ancestor_is_refused() {
220
+ let f = Fixture::new();
221
+ let marker = f.binding.transport_root.join(".git");
222
+ std::fs::write(&marker, b"gitdir: controlled-marker").unwrap();
223
+ assert!(prepare(&f.binding, &SeatId::new("worker").unwrap()).is_err());
224
+ std::fs::remove_file(&marker).unwrap();
225
+ std::fs::create_dir(&marker).unwrap();
226
+ assert!(prepare(&f.binding, &SeatId::new("worker").unwrap()).is_err());
227
+ }
160
228
  }
@@ -4,8 +4,6 @@
4
4
 
5
5
  use std::collections::BTreeSet;
6
6
  use std::path::{Path, PathBuf};
7
- use std::sync::atomic::{AtomicU64, Ordering};
8
- use std::time::{SystemTime, UNIX_EPOCH};
9
7
 
10
8
  use serde_json::{json, Value};
11
9
  use sha2::{Digest, Sha256};
@@ -1837,22 +1835,7 @@ fn canonical_workspace(workspace: &Path) -> PathBuf {
1837
1835
  }
1838
1836
 
1839
1837
  fn next_pane_binding_nonce(workspace: &Path, endpoint: &str, pane: &PaneId) -> String {
1840
- static COUNTER: AtomicU64 = AtomicU64::new(0);
1841
- let nanos = SystemTime::now()
1842
- .duration_since(UNIX_EPOCH)
1843
- .map_or(0, |duration| duration.as_nanos());
1844
- let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
1845
- let material = format!(
1846
- "{}\0{endpoint}\0{}\0{}\0{nanos}\0{counter}",
1847
- canonical_workspace(workspace).display(),
1848
- pane.as_str(),
1849
- std::process::id(),
1850
- );
1851
- let digest = Sha256::digest(material.as_bytes());
1852
- digest[..16]
1853
- .iter()
1854
- .map(|byte| format!("{byte:02x}"))
1855
- .collect()
1838
+ crate::tmux_backend::next_pane_binding_nonce(workspace, endpoint, pane)
1856
1839
  }
1857
1840
 
1858
1841
  fn pane_binding_nonce_for_claim(
@@ -36,6 +36,7 @@ pub(crate) struct PaneInputLockRequest<'a> {
36
36
 
37
37
  pub(crate) struct PaneInputLockGuard {
38
38
  file: Option<std::fs::File>,
39
+ guard_gate: Option<std::fs::File>,
39
40
  }
40
41
 
41
42
  /// ---
@@ -54,18 +55,58 @@ pub(crate) fn acquire_or_proceed(request: PaneInputLockRequest<'_>) -> Option<Pa
54
55
  // No workspace (some unit backends): nothing to lock against. Not an alarm.
55
56
  return None;
56
57
  };
57
- acquire_file_lock(workspace, &request, timeout)
58
+ acquire_file_lock(workspace, &request, timeout, "proceed")
59
+ }
60
+
61
+ /// Every legacy writer holds shared admission through its whole operation,
62
+ /// even when its ordinary 200ms lock times out. Required guards hold exclusive
63
+ /// admission, so neither can enter while the other is already writing.
64
+ pub(crate) fn acquire_for_write(request: PaneInputLockRequest<'_>) -> Result<Option<PaneInputLockGuard>, ()> {
65
+ if request.workspace.is_none() { return Ok(None); }
66
+ let gate = acquire_guard_gate(&request, false)?;
67
+ let mut guard = acquire_or_proceed(request).unwrap_or(PaneInputLockGuard { file: None, guard_gate: None });
68
+ guard.guard_gate = Some(gate);
69
+ Ok(Some(guard))
70
+ }
71
+
72
+ fn acquire_guard_gate(request: &PaneInputLockRequest<'_>, exclusive: bool) -> Result<std::fs::File, ()> {
73
+ let opened = (|| -> std::io::Result<std::fs::File> {
74
+ let workspace = request.workspace.ok_or_else(|| std::io::Error::other("workspace missing"))?;
75
+ let path = pane_input_lock_path(workspace, request.target_key).with_extension("guard");
76
+ if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; }
77
+ let file = std::fs::OpenOptions::new().create(true).read(true).write(true).truncate(false).open(path)?;
78
+ let result = if exclusive { file.try_lock() } else { file.try_lock_shared() };
79
+ result.map_err(|error| match error {
80
+ std::fs::TryLockError::WouldBlock => std::io::Error::from(std::io::ErrorKind::WouldBlock),
81
+ std::fs::TryLockError::Error(error) => error,
82
+ })?;
83
+ Ok(file)
84
+ })();
85
+ opened.map_err(|_| emit_timeout_alarm(request, PANE_INPUT_LOCK_TIMEOUT, Duration::ZERO, "guard_gate", "refuse"))
86
+ }
87
+
88
+ /// Guarded native input must never treat a missing/contended lock as permission.
89
+ pub(crate) fn acquire_required(request: PaneInputLockRequest<'_>) -> Option<PaneInputLockGuard> {
90
+ let Some(workspace) = request.workspace else {
91
+ emit_timeout_alarm(&request, PANE_INPUT_LOCK_TIMEOUT, Duration::ZERO, "workspace_missing", "refuse");
92
+ return None;
93
+ };
94
+ let gate = acquire_guard_gate(&request, true).ok()?;
95
+ let mut guard = acquire_file_lock(workspace, &request, PANE_INPUT_LOCK_TIMEOUT, "refuse")?;
96
+ guard.guard_gate = Some(gate);
97
+ Some(guard)
58
98
  }
59
99
 
60
100
  fn acquire_file_lock(
61
101
  workspace: &Path,
62
102
  request: &PaneInputLockRequest<'_>,
63
103
  timeout: Duration,
104
+ disposition: &str,
64
105
  ) -> Option<PaneInputLockGuard> {
65
106
  let path = pane_input_lock_path(workspace, request.target_key);
66
107
  if let Some(parent) = path.parent() {
67
108
  if std::fs::create_dir_all(parent).is_err() {
68
- emit_timeout_alarm(request, timeout, Duration::ZERO, "lock_dir");
109
+ emit_timeout_alarm(request, timeout, Duration::ZERO, "lock_dir", disposition);
69
110
  return None;
70
111
  }
71
112
  }
@@ -78,7 +119,7 @@ fn acquire_file_lock(
78
119
  {
79
120
  Ok(file) => file,
80
121
  Err(_) => {
81
- emit_timeout_alarm(request, timeout, Duration::ZERO, "lock_open");
122
+ emit_timeout_alarm(request, timeout, Duration::ZERO, "lock_open", disposition);
82
123
  return None;
83
124
  }
84
125
  };
@@ -86,17 +127,17 @@ fn acquire_file_lock(
86
127
  loop {
87
128
  match crate::platform::file_lock::try_lock_once_nonblocking(&file) {
88
129
  Ok(true) => {
89
- return Some(PaneInputLockGuard { file: Some(file) });
130
+ return Some(PaneInputLockGuard { file: Some(file), guard_gate: None });
90
131
  }
91
132
  Ok(false) => {}
92
133
  Err(_) => {
93
- emit_timeout_alarm(request, timeout, started.elapsed(), "lock_io");
134
+ emit_timeout_alarm(request, timeout, started.elapsed(), "lock_io", disposition);
94
135
  return None;
95
136
  }
96
137
  }
97
138
  let elapsed = started.elapsed();
98
139
  if elapsed >= timeout {
99
- emit_timeout_alarm(request, timeout, elapsed, "timeout");
140
+ emit_timeout_alarm(request, timeout, elapsed, "timeout", disposition);
100
141
  return None;
101
142
  }
102
143
  std::thread::sleep(std::cmp::min(
@@ -127,10 +168,11 @@ fn emit_timeout_alarm(
127
168
  timeout: Duration,
128
169
  waited: Duration,
129
170
  reason: &str,
171
+ disposition: &str,
130
172
  ) {
131
173
  // A5 watches this exact token on stderr. Commenting this line must turn A5 red.
132
174
  eprintln!(
133
- "{PANE_INPUT_LOCK_TIMEOUT_EVENT} target={} operation={} waited_ms={} timeout_ms={} reason={reason} proceeding",
175
+ "{PANE_INPUT_LOCK_TIMEOUT_EVENT} target={} operation={} waited_ms={} timeout_ms={} reason={reason} disposition={disposition}",
134
176
  request.target_key,
135
177
  request.operation,
136
178
  waited.as_millis(),
@@ -146,7 +188,7 @@ fn emit_timeout_alarm(
146
188
  "waited_ms": waited.as_millis() as u64,
147
189
  "timeout_ms": timeout.as_millis() as u64,
148
190
  "reason": reason,
149
- "disposition": "proceed",
191
+ "disposition": disposition,
150
192
  }),
151
193
  );
152
194
  }
@@ -157,5 +199,41 @@ impl Drop for PaneInputLockGuard {
157
199
  if let Some(file) = self.file.take() {
158
200
  let _ = crate::platform::file_lock::unlock(&file);
159
201
  }
202
+ if let Some(gate) = self.guard_gate.take() { let _ = gate.unlock(); }
203
+ }
204
+ }
205
+
206
+ #[cfg(test)]
207
+ mod required_tests {
208
+ #![allow(clippy::unwrap_used)]
209
+ use super::*;
210
+
211
+ #[test]
212
+ fn required_pane_input_lock_missing_workspace_refuses() {
213
+ assert!(acquire_required(PaneInputLockRequest { workspace: None, target_key: "%1", operation: "fixture" }).is_none());
214
+ }
215
+
216
+ #[test]
217
+ fn required_pane_input_lock_contention_refuses_and_drop_releases_same_key() {
218
+ let workspace = std::env::temp_dir().join(format!("ta-required-pane-lock-{}", std::process::id()));
219
+ std::fs::create_dir(&workspace).unwrap();
220
+ let request = || PaneInputLockRequest { workspace: Some(workspace.as_path()), target_key: "%1", operation: "fixture" };
221
+ let held = acquire_required(request()).unwrap();
222
+ assert!(acquire_required(request()).is_none());
223
+ let events = std::fs::read_to_string(workspace.join(".team/logs/events.jsonl")).unwrap();
224
+ assert!(events.lines().filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
225
+ .any(|row| row.to_string().contains("\"disposition\":\"refuse\"")));
226
+ drop(held);
227
+ drop(acquire_required(request()).unwrap());
228
+ // Preserve the old timeout/proceed policy, but hold reader admission
229
+ // through that unheld-main-lock writer so no required guard crosses it.
230
+ let first_writer = acquire_for_write(request()).unwrap().unwrap();
231
+ let timed_out_writer = acquire_for_write(request()).unwrap().unwrap();
232
+ assert!(timed_out_writer.file.is_none());
233
+ drop(first_writer);
234
+ assert!(acquire_required(request()).is_none());
235
+ drop(timed_out_writer);
236
+ drop(acquire_required(request()).unwrap());
237
+ std::fs::remove_dir_all(workspace).unwrap();
160
238
  }
161
239
  }
@@ -307,6 +307,7 @@ pub(crate) fn start_agent_at_paths(
307
307
  &window,
308
308
  noop_pane.as_ref(),
309
309
  )?;
310
+ state["agents"][agent_id.as_str()]["explicit_stop"] = serde_json::Value::Bool(false);
310
311
  let team_key = restart_projection_team_key(&state, team);
311
312
  save_restart_projected_state(workspace, &mut state, &team_key, &[agent_id.as_str()])?;
312
313
  if let Ok(spec) = load_team_spec(spec_workspace) {
@@ -1258,6 +1259,7 @@ pub(super) fn stop_agent_at_paths(
1258
1259
  let _ = write_stop_drain_event(workspace, agent_id, &target_str, &drain_evidence);
1259
1260
  }
1260
1261
  mark_agent_stopped(&mut state, agent_id, agent, &window)?;
1262
+ state["agents"][agent_id.as_str()]["explicit_stop"] = serde_json::Value::Bool(true);
1261
1263
  let team_key = restart_projection_team_key(&state, team);
1262
1264
  // golden operations.py:95: save_team_scoped_state (team projection) — NOT a raw save, so a
1263
1265
  // multi-team workspace keeps the other teams' persisted runtime state instead of being clobbered.
@@ -1380,6 +1382,7 @@ pub(super) fn mark_agent_started(
1380
1382
  }
1381
1383
  }
1382
1384
  agent.insert("status".to_string(), serde_json::json!("running"));
1385
+ agent.insert("explicit_stop".to_string(), serde_json::Value::Bool(false));
1383
1386
  agent.insert("agent_id".to_string(), serde_json::json!(agent_id.as_str()));
1384
1387
  agent.insert("window".to_string(), serde_json::json!(window));
1385
1388
  agent.insert(
@@ -1741,6 +1744,7 @@ fn reset_agent_at_paths(
1741
1744
  .and_then(serde_json::Value::as_object_mut)
1742
1745
  {
1743
1746
  obj.insert("spawn_epoch".to_string(), serde_json::json!(new_epoch));
1747
+ obj.insert("explicit_stop".to_string(), serde_json::Value::Bool(false));
1744
1748
  }
1745
1749
  let team_key = restart_projection_team_key(&state, team);
1746
1750
  sync_restart_team_projections(&mut state, &team_key);