@team-agent/installer 0.5.66 → 0.5.68

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 (164) hide show
  1. package/Cargo.lock +8 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +1 -0
  4. package/crates/team-agent/src/cli/adapters.rs +5 -0
  5. package/crates/team-agent/src/cli/diagnose.rs +82 -0
  6. package/crates/team-agent/src/cli/emit.rs +3 -3
  7. package/crates/team-agent/src/cli/grok_slot.rs +299 -0
  8. package/crates/team-agent/src/cli/leader.rs +21 -7
  9. package/crates/team-agent/src/cli/leaders.rs +125 -1
  10. package/crates/team-agent/src/cli/mod.rs +20 -0
  11. package/crates/team-agent/src/cli/send/presentation.rs +7 -1
  12. package/crates/team-agent/src/cli/spec.rs +2 -0
  13. package/crates/team-agent/src/cli/status_port/compact.rs +28 -0
  14. package/crates/team-agent/src/cli/status_port/snapshot.rs +25 -1
  15. package/crates/team-agent/src/cli/tests/base.rs +26 -3
  16. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +129 -19
  17. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +387 -6
  18. package/crates/team-agent/src/cli/tests/status_send.rs +79 -10
  19. package/crates/team-agent/src/communication_mode/mod.rs +6 -4
  20. package/crates/team-agent/src/compiler.rs +59 -0
  21. package/crates/team-agent/src/coordinator/backoff.rs +55 -0
  22. package/crates/team-agent/src/coordinator/conpty_shim.rs +82 -0
  23. package/crates/team-agent/src/coordinator/health.rs +173 -0
  24. package/crates/team-agent/src/coordinator/mod.rs +31 -0
  25. package/crates/team-agent/src/coordinator/orphan.rs +31 -0
  26. package/crates/team-agent/src/coordinator/runtime_detectors.rs +30 -0
  27. package/crates/team-agent/src/coordinator/runtime_observation.rs +25 -0
  28. package/crates/team-agent/src/coordinator/steps/abnormal.rs +59 -0
  29. package/crates/team-agent/src/coordinator/steps/delivery.rs +10 -0
  30. package/crates/team-agent/src/coordinator/steps/health_sync.rs +10 -0
  31. package/crates/team-agent/src/coordinator/steps/mod.rs +22 -0
  32. package/crates/team-agent/src/coordinator/steps/persist.rs +10 -0
  33. package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +10 -0
  34. package/crates/team-agent/src/coordinator/steps/session_gate.rs +10 -0
  35. package/crates/team-agent/src/coordinator/tick.rs +133 -22
  36. package/crates/team-agent/src/coordinator/types.rs +49 -0
  37. package/crates/team-agent/src/db/message_store.rs +39 -5
  38. package/crates/team-agent/src/layout/worker_env.rs +20 -3
  39. package/crates/team-agent/src/layout/worker_window_helpers.rs +2 -0
  40. package/crates/team-agent/src/leader/provider_attribution.rs +53 -0
  41. package/crates/team-agent/src/leader/registry.rs +65 -0
  42. package/crates/team-agent/src/leader/start.rs +920 -63
  43. package/crates/team-agent/src/lifecycle/display.rs +56 -0
  44. package/crates/team-agent/src/lifecycle/helpers.rs +57 -0
  45. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +332 -12
  46. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +62 -0
  47. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +59 -0
  48. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +101 -11
  49. package/crates/team-agent/src/lifecycle/launch/cursor_create_chat.rs +229 -0
  50. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +332 -0
  51. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +204 -456
  52. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +24 -0
  53. package/crates/team-agent/src/lifecycle/launch/grok_per_seat.rs +260 -0
  54. package/crates/team-agent/src/lifecycle/launch/identity.rs +146 -0
  55. package/crates/team-agent/src/lifecycle/launch/layout.rs +60 -0
  56. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +112 -0
  57. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +530 -0
  58. package/crates/team-agent/src/lifecycle/launch/ownership.rs +40 -0
  59. package/crates/team-agent/src/lifecycle/launch/plan.rs +31 -0
  60. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +66 -0
  61. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +78 -0
  62. package/crates/team-agent/src/lifecycle/launch/readiness.rs +85 -38
  63. package/crates/team-agent/src/lifecycle/launch/role_source.rs +44 -44
  64. package/crates/team-agent/src/lifecycle/launch/spawn.rs +62 -2
  65. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +139 -0
  66. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +109 -0
  67. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +214 -1
  68. package/crates/team-agent/src/lifecycle/launch.rs +99 -29
  69. package/crates/team-agent/src/lifecycle/lock.rs +42 -1
  70. package/crates/team-agent/src/lifecycle/mod.rs +11 -0
  71. package/crates/team-agent/src/lifecycle/pane_input_lock.rs +161 -0
  72. package/crates/team-agent/src/lifecycle/profile_launch.rs +98 -0
  73. package/crates/team-agent/src/lifecycle/profile_smoke.rs +51 -1
  74. package/crates/team-agent/src/lifecycle/restart/agent.rs +124 -0
  75. package/crates/team-agent/src/lifecycle/restart/common.rs +369 -11
  76. package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +27 -0
  77. package/crates/team-agent/src/lifecycle/restart/preflight.rs +25 -0
  78. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +106 -0
  79. package/crates/team-agent/src/lifecycle/restart/remove.rs +196 -5
  80. package/crates/team-agent/src/lifecycle/restart/selection.rs +72 -0
  81. package/crates/team-agent/src/lifecycle/restart/team_state.rs +22 -0
  82. package/crates/team-agent/src/lifecycle/restart.rs +29 -0
  83. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -3
  84. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +309 -0
  85. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +150 -157
  86. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +2 -2
  87. package/crates/team-agent/src/lifecycle/tests/core.rs +4 -0
  88. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +287 -0
  89. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +229 -0
  90. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +353 -0
  91. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +784 -0
  92. package/crates/team-agent/src/lifecycle/tests/gate_fixtures.rs +562 -0
  93. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +239 -0
  94. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +498 -0
  95. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +250 -0
  96. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +293 -0
  97. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +63 -38
  98. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -2
  99. package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +16 -8
  100. package/crates/team-agent/src/lifecycle/tests/mcp_tool_name_format_red.rs +69 -0
  101. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +20 -19
  102. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +97 -61
  103. package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +2 -0
  104. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +40 -7
  105. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +59 -1
  106. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +69 -26
  107. package/crates/team-agent/src/lifecycle/tests.rs +23 -13
  108. package/crates/team-agent/src/lifecycle/types.rs +61 -0
  109. package/crates/team-agent/src/lifecycle/worker_command_context.rs +85 -11
  110. package/crates/team-agent/src/mcp_server/tests/scoped.rs +100 -1
  111. package/crates/team-agent/src/mcp_server/tests.rs +196 -2
  112. package/crates/team-agent/src/messaging/delivery.rs +405 -40
  113. package/crates/team-agent/src/messaging/helpers.rs +2 -0
  114. package/crates/team-agent/src/messaging/leader_receiver.rs +53 -1
  115. package/crates/team-agent/src/messaging/results.rs +4 -0
  116. package/crates/team-agent/src/messaging/send.rs +34 -0
  117. package/crates/team-agent/src/messaging/tests/dup_inject.rs +406 -0
  118. package/crates/team-agent/src/messaging/tests/e23.rs +9 -0
  119. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +66 -0
  120. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  121. package/crates/team-agent/src/messaging/types.rs +4 -1
  122. package/crates/team-agent/src/model/enums.rs +14 -5
  123. package/crates/team-agent/src/model/permissions.rs +3 -0
  124. package/crates/team-agent/src/os_probe.rs +73 -2
  125. package/crates/team-agent/src/provider/adapter.rs +231 -4
  126. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +86 -0
  127. package/crates/team-agent/src/provider/adapters/grok.rs +157 -0
  128. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  129. package/crates/team-agent/src/provider/bypass_flags.rs +46 -2
  130. package/crates/team-agent/src/provider/classify.rs +4 -2
  131. package/crates/team-agent/src/provider/faults.rs +2 -1
  132. package/crates/team-agent/src/provider/mod.rs +11 -0
  133. package/crates/team-agent/src/provider/session/capture.rs +158 -20
  134. package/crates/team-agent/src/provider/session/context_fork/claude.rs +38 -1
  135. package/crates/team-agent/src/provider/session/context_fork/codex.rs +72 -1
  136. package/crates/team-agent/src/provider/session/context_fork/outcome.rs +57 -1
  137. package/crates/team-agent/src/provider/session/context_fork.rs +77 -3
  138. package/crates/team-agent/src/provider/session/mod.rs +18 -0
  139. package/crates/team-agent/src/provider/session/resume.rs +57 -0
  140. package/crates/team-agent/src/provider/session_scan/claude.rs +125 -1
  141. package/crates/team-agent/src/provider/session_scan/codex.rs +94 -1
  142. package/crates/team-agent/src/provider/session_scan/common.rs +204 -2
  143. package/crates/team-agent/src/provider/session_scan/copilot.rs +33 -1
  144. package/crates/team-agent/src/provider/session_scan/cursor.rs +538 -0
  145. package/crates/team-agent/src/provider/session_scan/grok.rs +288 -0
  146. package/crates/team-agent/src/provider/session_scan.rs +8 -0
  147. package/crates/team-agent/src/provider/submit_now.rs +94 -0
  148. package/crates/team-agent/src/provider/tests/adapter.rs +304 -0
  149. package/crates/team-agent/src/provider/types.rs +4 -2
  150. package/crates/team-agent/src/provider/wire.rs +23 -1
  151. package/crates/team-agent/src/state/persist.rs +301 -8
  152. package/crates/team-agent/src/state/repository.rs +5 -0
  153. package/crates/team-agent/src/tmux_backend/tests.rs +1737 -42
  154. package/crates/team-agent/src/tmux_backend.rs +1064 -75
  155. package/crates/team-agent/src/transport/tests/wire.rs +28 -2
  156. package/crates/team-agent/src/transport.rs +248 -1
  157. package/npm/install.mjs +129 -73
  158. package/package.json +4 -4
  159. package/skills/team-agent/SKILL.md +33 -238
  160. package/skills/team-agent/command-coverage.json +31 -0
  161. package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +0 -59
  162. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +0 -488
  163. package/crates/team-agent/src/lifecycle/launch/fork_pending.rs +0 -109
  164. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +0 -447
@@ -0,0 +1,538 @@
1
+ //! ---
2
+ //! purpose: 捕获 ~/.cursor/chats/ 存档:pending chatId 或 workspace hex 下唯一新目录
3
+ //! contract:
4
+ //! provides:
5
+ //! - name: cursor_session_dir
6
+ //! what: 由 HOME + chatId 派生磁盘存档目录(直达或一层 hex 子目录)
7
+ //! - name: cursor_session_archive_present
8
+ //! what: 只凭 marker 文件存在性判定 backing,不打开正文
9
+ //! - name: scan_session_store
10
+ //! what: pending 命中则返回该 id;否则仅当 spawn_cwd 的 md5 hex 下唯一新 marker 目录才捕获
11
+ //! requires:
12
+ //! - name: workspace-hex-or-pending
13
+ //! what: 无 pending 时只看 ~/.cursor/chats/<md5(canonical cwd)>/,不扫全库
14
+ //! boundary:
15
+ //! - 只服务 Provider::CursorAgent
16
+ //! - 不发明 --session-id;不读 store.db/meta.json 正文
17
+ //! - 0 或 ≥2 个新目录 → 空向量,不拾取邻席
18
+ //! - 不改 grok/claude/codex/copilot 扫描
19
+ //! maturity: wired
20
+ //! ---
21
+
22
+ use std::path::{Path, PathBuf};
23
+
24
+ use crate::provider::types::{CaptureVia, CapturedSession, Confidence, RolloutPath, SessionId};
25
+
26
+ use super::{CaptureSessionContext, CapturedSessionCandidate};
27
+
28
+ fn session_id_legal(session_id: &str) -> bool {
29
+ !session_id.is_empty()
30
+ && !session_id.contains('/')
31
+ && !session_id.contains('\\')
32
+ && !session_id.contains('\0')
33
+ }
34
+
35
+ /// Marker names from 2026-08-21 U-02 `ls` of `~/.cursor/chats/<hex>/<uuid>/`
36
+ /// (names only). Existence is backing; contents are not read.
37
+ const MARKERS: &[&str] = &["store.db", "meta.json"];
38
+
39
+ /// ---
40
+ /// purpose: 只凭 marker 文件存在性判断 cursor 会话存档是否可 resume
41
+ /// params:
42
+ /// dir: cursor_session_dir 给出的目录
43
+ /// returns: 目录存在且含 store.db 或 meta.json
44
+ /// contract:
45
+ /// provides:
46
+ /// - name: cursor_session_archive_present
47
+ /// what: 用 is_file 判 backing,不打开正文
48
+ /// boundary:
49
+ /// - 不读 json / sqlite 内容
50
+ /// ---
51
+ pub(crate) fn cursor_session_archive_present(dir: &Path) -> bool {
52
+ if !dir.is_dir() {
53
+ return false;
54
+ }
55
+ MARKERS.iter().any(|name| dir.join(name).is_file())
56
+ }
57
+
58
+ fn first_present_dir(candidates: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {
59
+ candidates.into_iter().find(|dir| cursor_session_archive_present(dir))
60
+ }
61
+
62
+ /// ---
63
+ /// purpose: 由 chatId 派生 ~/.cursor/chats 下的存档目录
64
+ /// params:
65
+ /// session_id: CLI chatId;空或含路径分隔则拒绝
66
+ /// returns: HOME 下含 marker 的目录;HOME 缺失或 id 非法则 None
67
+ /// contract:
68
+ /// provides:
69
+ /// - name: cursor_session_dir
70
+ /// what: 只派生并核 marker 存在性,不创建目录、不读存档
71
+ /// boundary:
72
+ /// - 无 pending 时不枚举其它 chatId
73
+ /// ---
74
+ pub(crate) fn cursor_session_dir(session_id: &str) -> Option<PathBuf> {
75
+ if !session_id_legal(session_id) {
76
+ return None;
77
+ }
78
+ let home = std::env::var_os("HOME").map(PathBuf::from)?;
79
+ let chats = home.join(".cursor").join("chats");
80
+ let direct = chats.join(session_id);
81
+ if let Some(dir) = first_present_dir([direct.clone()]) {
82
+ return Some(dir);
83
+ }
84
+ // U-02 layout: ~/.cursor/chats/<hex32>/<uuid>/store.db
85
+ if direct.is_dir() {
86
+ if let Ok(entries) = std::fs::read_dir(&direct) {
87
+ for entry in entries.flatten() {
88
+ let child = entry.path();
89
+ if cursor_session_archive_present(&child) {
90
+ return Some(child);
91
+ }
92
+ }
93
+ }
94
+ }
95
+ if let Ok(entries) = std::fs::read_dir(&chats) {
96
+ for entry in entries.flatten() {
97
+ let nested = entry.path().join(session_id);
98
+ if cursor_session_archive_present(&nested) {
99
+ return Some(nested);
100
+ }
101
+ }
102
+ }
103
+ None
104
+ }
105
+
106
+ /// ---
107
+ /// purpose: 由 spawn_cwd 的 md5 hex + chatId 直达存档,避免全库枚举失败
108
+ /// params:
109
+ /// session_id: CLI chatId;spawn_cwd: 席位物理 workspace
110
+ /// returns: hex/uuid 含 marker 则该目录,否则回落 cursor_session_dir
111
+ /// contract:
112
+ /// provides:
113
+ /// - name: cursor_session_dir_for_cwd
114
+ /// what: 优先 ~/.cursor/chats/<md5(cwd)>/<chatId>/,再全库回落
115
+ /// boundary:
116
+ /// - 不读正文;id 非法则 None
117
+ /// ---
118
+ pub(crate) fn cursor_session_dir_for_cwd(session_id: &str, spawn_cwd: &Path) -> Option<PathBuf> {
119
+ if !session_id_legal(session_id) {
120
+ return None;
121
+ }
122
+ let home = std::env::var_os("HOME").map(PathBuf::from)?;
123
+ let hex = workspace_chats_hex(spawn_cwd);
124
+ let nested = home
125
+ .join(".cursor")
126
+ .join("chats")
127
+ .join(hex)
128
+ .join(session_id);
129
+ if cursor_session_archive_present(&nested) {
130
+ return Some(nested);
131
+ }
132
+ cursor_session_dir(session_id)
133
+ }
134
+
135
+ fn candidate_for(
136
+ session_id: SessionId,
137
+ dir: PathBuf,
138
+ spawn_cwd: PathBuf,
139
+ ) -> CapturedSessionCandidate {
140
+ CapturedSessionCandidate {
141
+ captured: CapturedSession {
142
+ session_id: Some(session_id),
143
+ rollout_path: Some(RolloutPath::new(dir)),
144
+ captured_via: CaptureVia::FsWatch,
145
+ attribution_confidence: Confidence::High,
146
+ spawn_cwd,
147
+ },
148
+ embedded_agent_id: None,
149
+ positive_agent_id_match: false,
150
+ agent_path_match: false,
151
+ }
152
+ }
153
+
154
+ fn workspace_chats_hex(spawn_cwd: &Path) -> String {
155
+ let canonical = std::fs::canonicalize(spawn_cwd).unwrap_or_else(|_| spawn_cwd.to_path_buf());
156
+ format!("{:x}", md5::compute(canonical.to_string_lossy().as_bytes()))
157
+ }
158
+
159
+ fn dir_mtime_passes(dir: &Path, spawned_at: Option<&str>) -> bool {
160
+ let Some(raw) = spawned_at else {
161
+ return true;
162
+ };
163
+ let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(raw) else {
164
+ return true;
165
+ };
166
+ let threshold = parsed.with_timezone(&chrono::Utc) - chrono::Duration::seconds(2);
167
+ let Ok(meta) = dir.metadata() else {
168
+ return false;
169
+ };
170
+ let Ok(mtime) = meta.modified() else {
171
+ return false;
172
+ };
173
+ chrono::DateTime::<chrono::Utc>::from(mtime) >= threshold
174
+ }
175
+
176
+ /// ---
177
+ /// purpose: 无 pending 时按 spawn_cwd 的 md5 hex 捕获唯一新 cursor 存档
178
+ /// params:
179
+ /// context: spawn_cwd + 可选 spawned_at
180
+ /// returns: hex 下恰好一个通过时间窗的 marker 目录则一条候选,否则空
181
+ /// contract:
182
+ /// provides:
183
+ /// - name: scan_unique_workspace_chat
184
+ /// what: 只枚举 ~/.cursor/chats/<md5(cwd)>/,0 或 ≥2 不拾取
185
+ /// boundary:
186
+ /// - 不扫其它 hex;不读 marker 正文
187
+ /// ---
188
+ fn scan_unique_workspace_chat(context: &CaptureSessionContext) -> Vec<CapturedSessionCandidate> {
189
+ let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
190
+ return Vec::new();
191
+ };
192
+ let hex = workspace_chats_hex(&context.spawn_cwd);
193
+ if hex.len() != 32 {
194
+ return Vec::new();
195
+ }
196
+ let root = home.join(".cursor").join("chats").join(&hex);
197
+ if !root.is_dir() {
198
+ return Vec::new();
199
+ }
200
+ let mut hits: Vec<(String, PathBuf)> = Vec::new();
201
+ let Ok(entries) = std::fs::read_dir(&root) else {
202
+ return Vec::new();
203
+ };
204
+ for entry in entries.flatten() {
205
+ let child = entry.path();
206
+ if !child.is_dir() {
207
+ continue;
208
+ }
209
+ let Some(name) = child.file_name().and_then(|n| n.to_str()) else {
210
+ continue;
211
+ };
212
+ if !session_id_legal(name) {
213
+ continue;
214
+ }
215
+ if !cursor_session_archive_present(&child) {
216
+ continue;
217
+ }
218
+ if !dir_mtime_passes(&child, context.spawned_at.as_deref()) {
219
+ continue;
220
+ }
221
+ hits.push((name.to_string(), child));
222
+ }
223
+ if hits.len() != 1 {
224
+ return Vec::new();
225
+ }
226
+ let (sid, dir) = hits.remove(0);
227
+ vec![candidate_for(
228
+ SessionId::new(sid),
229
+ dir,
230
+ context.spawn_cwd.clone(),
231
+ )]
232
+ }
233
+
234
+ /// ---
235
+ /// purpose: 按 pending chatId 或 workspace hex 唯一新目录捕获 cursor 存档
236
+ /// params:
237
+ /// context: 含 spawn_cwd;可选 expected_session_id / spawned_at
238
+ /// returns: 命中则一条 FsWatch 候选,否则空向量
239
+ /// contract:
240
+ /// provides:
241
+ /// - name: scan_session_store
242
+ /// what: pending 优先;否则唯一新 hex 目录
243
+ /// boundary:
244
+ /// - 非唯一不拾取既有 hex 会话
245
+ /// ---
246
+ pub(super) fn scan_session_store(context: &CaptureSessionContext) -> Vec<CapturedSessionCandidate> {
247
+ if let Some(expected) = context.expected_session_id.as_ref() {
248
+ let Some(dir) = cursor_session_dir(expected.as_str()) else {
249
+ return Vec::new();
250
+ };
251
+ return vec![candidate_for(
252
+ expected.clone(),
253
+ dir,
254
+ context.spawn_cwd.clone(),
255
+ )];
256
+ }
257
+ scan_unique_workspace_chat(context)
258
+ }
259
+
260
+ #[cfg(test)]
261
+ mod tests {
262
+ #![allow(clippy::unwrap_used)]
263
+
264
+ use super::*;
265
+ use crate::provider::types::SessionId;
266
+ use std::sync::atomic::{AtomicU64, Ordering};
267
+
268
+ struct HomeGuard {
269
+ prev: Option<std::ffi::OsString>,
270
+ }
271
+
272
+ impl HomeGuard {
273
+ fn set(home: &Path) -> Self {
274
+ let prev = std::env::var_os("HOME");
275
+ std::env::set_var("HOME", home);
276
+ Self { prev }
277
+ }
278
+ }
279
+
280
+ impl Drop for HomeGuard {
281
+ fn drop(&mut self) {
282
+ match &self.prev {
283
+ Some(v) => std::env::set_var("HOME", v),
284
+ None => std::env::remove_var("HOME"),
285
+ }
286
+ }
287
+ }
288
+
289
+ fn tmp_root(tag: &str) -> PathBuf {
290
+ static CTR: AtomicU64 = AtomicU64::new(0);
291
+ let dir = std::env::temp_dir().join(format!(
292
+ "ta-d115-cursor-scan-{}-{}-{}",
293
+ tag,
294
+ std::process::id(),
295
+ CTR.fetch_add(1, Ordering::Relaxed)
296
+ ));
297
+ let _ = std::fs::remove_dir_all(&dir);
298
+ std::fs::create_dir_all(&dir).unwrap();
299
+ std::fs::canonicalize(&dir).unwrap()
300
+ }
301
+
302
+ fn seed_direct(home: &Path, session_id: &str) -> PathBuf {
303
+ let dir = home.join(".cursor").join("chats").join(session_id);
304
+ std::fs::create_dir_all(&dir).unwrap();
305
+ std::fs::write(dir.join("store.db"), b"").unwrap();
306
+ dir
307
+ }
308
+
309
+ fn seed_nested(home: &Path, outer: &str, session_id: &str) -> PathBuf {
310
+ let dir = home
311
+ .join(".cursor")
312
+ .join("chats")
313
+ .join(outer)
314
+ .join(session_id);
315
+ std::fs::create_dir_all(&dir).unwrap();
316
+ std::fs::write(dir.join("meta.json"), b"THIS-BODY-MUST-NOT-BE-PARSED\n").unwrap();
317
+ dir
318
+ }
319
+
320
+ fn ctx(cwd: &Path, expected: Option<&str>) -> CaptureSessionContext {
321
+ CaptureSessionContext {
322
+ agent_id: "w1".to_string(),
323
+ spawn_cwd: cwd.to_path_buf(),
324
+ pane_id: None,
325
+ pane_pid: None,
326
+ spawned_at: None,
327
+ expected_session_id: expected.map(SessionId::new),
328
+ provider_projects_root: None,
329
+ }
330
+ }
331
+
332
+ #[test]
333
+ #[serial_test::serial(env)]
334
+ fn cursor_unique_workspace_hex_dir_captures_without_pending() {
335
+ let base = tmp_root("unique-hex");
336
+ let home = base.join("home");
337
+ let cwd = base.join("ws");
338
+ std::fs::create_dir_all(&home).unwrap();
339
+ std::fs::create_dir_all(&cwd).unwrap();
340
+ let cwd = std::fs::canonicalize(&cwd).unwrap();
341
+ let _guard = HomeGuard::set(&home);
342
+ let sid = "09d2aeaa-33ef-4148-a2e6-83a7c3775caa";
343
+ let hex = workspace_chats_hex(&cwd);
344
+ assert_eq!(hex.len(), 32);
345
+ let dir = seed_nested(&home, &hex, sid);
346
+ let out = scan_session_store(&ctx(&cwd, None));
347
+ assert_eq!(out.len(), 1, "unique hex uuid must capture; got {out:?}");
348
+ assert_eq!(out[0].captured.session_id.as_ref().unwrap().as_str(), sid);
349
+ assert_eq!(
350
+ out[0]
351
+ .captured
352
+ .rollout_path
353
+ .as_ref()
354
+ .map(|p| p.as_path().to_path_buf()),
355
+ Some(dir)
356
+ );
357
+ let _ = std::fs::remove_dir_all(&base);
358
+ }
359
+
360
+ #[test]
361
+ #[serial_test::serial(env)]
362
+ fn cursor_two_workspace_hex_dirs_do_not_steal() {
363
+ let base = tmp_root("two-hex");
364
+ let home = base.join("home");
365
+ let cwd = base.join("ws");
366
+ std::fs::create_dir_all(&home).unwrap();
367
+ std::fs::create_dir_all(&cwd).unwrap();
368
+ let cwd = std::fs::canonicalize(&cwd).unwrap();
369
+ let _guard = HomeGuard::set(&home);
370
+ let hex = workspace_chats_hex(&cwd);
371
+ seed_nested(&home, &hex, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
372
+ seed_nested(&home, &hex, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
373
+ let out = scan_session_store(&ctx(&cwd, None));
374
+ assert!(
375
+ out.is_empty(),
376
+ "two marker dirs under cwd hex must not bind; got {out:?}"
377
+ );
378
+ let _ = std::fs::remove_dir_all(&base);
379
+ }
380
+
381
+ #[test]
382
+ #[serial_test::serial(env)]
383
+ fn cursor_session_dir_for_cwd_hits_hex_layout() {
384
+ let base = tmp_root("for-cwd");
385
+ let home = base.join("home");
386
+ let cwd = base.join("ws");
387
+ std::fs::create_dir_all(&home).unwrap();
388
+ std::fs::create_dir_all(&cwd).unwrap();
389
+ let cwd = std::fs::canonicalize(&cwd).unwrap();
390
+ let _guard = HomeGuard::set(&home);
391
+ let sid = "09d2aeaa-33ef-4148-a2e6-83a7c3775caa";
392
+ let hex = workspace_chats_hex(&cwd);
393
+ let dir = seed_nested(&home, &hex, sid);
394
+ assert_eq!(
395
+ cursor_session_dir_for_cwd(sid, &cwd).as_deref(),
396
+ Some(dir.as_path())
397
+ );
398
+ let _ = std::fs::remove_dir_all(&base);
399
+ }
400
+
401
+ #[test]
402
+ #[serial_test::serial(env)]
403
+ fn cursor_foreign_hex_is_ignored_when_cwd_hex_empty() {
404
+ let base = tmp_root("foreign-hex");
405
+ let home = base.join("home");
406
+ let cwd = base.join("ws");
407
+ std::fs::create_dir_all(&home).unwrap();
408
+ std::fs::create_dir_all(&cwd).unwrap();
409
+ let cwd = std::fs::canonicalize(&cwd).unwrap();
410
+ let _guard = HomeGuard::set(&home);
411
+ seed_nested(
412
+ &home,
413
+ "ffffffffffffffffffffffffffffffff",
414
+ "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
415
+ );
416
+ let out = scan_session_store(&ctx(&cwd, None));
417
+ assert!(
418
+ out.is_empty(),
419
+ "foreign hex must not bind empty cwd hex; got {out:?}"
420
+ );
421
+ let _ = std::fs::remove_dir_all(&base);
422
+ }
423
+
424
+ #[test]
425
+ #[serial_test::serial(env)]
426
+ fn cursor_expected_id_captures_store_db_without_reading_body() {
427
+ let base = tmp_root("hit");
428
+ let home = base.join("home");
429
+ let cwd = base.join("ws");
430
+ std::fs::create_dir_all(&home).unwrap();
431
+ std::fs::create_dir_all(&cwd).unwrap();
432
+ let sid = "00a437742b92089861da7821a62f232a";
433
+ let _guard = HomeGuard::set(&home);
434
+ let dir = seed_direct(&home, sid);
435
+ std::fs::write(dir.join("store.db"), b"THIS-BODY-MUST-NOT-BE-PARSED\n").unwrap();
436
+ let out = scan_session_store(&ctx(&cwd, Some(sid)));
437
+ assert_eq!(out.len(), 1);
438
+ assert_eq!(out[0].captured.session_id.as_ref().unwrap().as_str(), sid);
439
+ assert_eq!(
440
+ out[0]
441
+ .captured
442
+ .rollout_path
443
+ .as_ref()
444
+ .map(|p| p.as_path().to_path_buf()),
445
+ Some(dir)
446
+ );
447
+ let _ = std::fs::remove_dir_all(&base);
448
+ }
449
+
450
+ #[test]
451
+ #[serial_test::serial(env)]
452
+ fn cursor_nested_hex_uuid_layout_captures_expected_id() {
453
+ let base = tmp_root("nested");
454
+ let home = base.join("home");
455
+ let cwd = base.join("ws");
456
+ std::fs::create_dir_all(&home).unwrap();
457
+ std::fs::create_dir_all(&cwd).unwrap();
458
+ let outer = "00a437742b92089861da7821a62f232a";
459
+ let sid = "502896a1-72ba-4c53-9a86-b2da28780806";
460
+ let _guard = HomeGuard::set(&home);
461
+ let dir = seed_nested(&home, outer, sid);
462
+ let out = scan_session_store(&ctx(&cwd, Some(sid)));
463
+ assert_eq!(out.len(), 1);
464
+ assert_eq!(out[0].captured.session_id.as_ref().unwrap().as_str(), sid);
465
+ assert_eq!(
466
+ out[0]
467
+ .captured
468
+ .rollout_path
469
+ .as_ref()
470
+ .map(|p| p.as_path().to_path_buf()),
471
+ Some(dir)
472
+ );
473
+ let _ = std::fs::remove_dir_all(&base);
474
+ }
475
+
476
+ #[test]
477
+ #[serial_test::serial(env)]
478
+ fn cursor_scan_without_expected_id_does_not_steal_existing_hex() {
479
+ let base = tmp_root("no-pending");
480
+ let home = base.join("home");
481
+ let cwd = base.join("ws");
482
+ std::fs::create_dir_all(&home).unwrap();
483
+ std::fs::create_dir_all(&cwd).unwrap();
484
+ let _guard = HomeGuard::set(&home);
485
+ seed_direct(&home, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
486
+ let out = scan_session_store(&ctx(&cwd, None));
487
+ assert!(
488
+ out.is_empty(),
489
+ "no pending must not pick existing chats; got {out:?}"
490
+ );
491
+ let _ = std::fs::remove_dir_all(&base);
492
+ }
493
+
494
+ #[test]
495
+ #[serial_test::serial(env)]
496
+ fn cursor_expected_id_does_not_steal_foreign_chat() {
497
+ let base = tmp_root("foreign");
498
+ let home = base.join("home");
499
+ let cwd = base.join("ws");
500
+ std::fs::create_dir_all(&home).unwrap();
501
+ std::fs::create_dir_all(&cwd).unwrap();
502
+ let _guard = HomeGuard::set(&home);
503
+ seed_direct(&home, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
504
+ let out = scan_session_store(&ctx(&cwd, Some("cccccccccccccccccccccccccccccccc")));
505
+ assert!(out.is_empty(), "foreign chat must not bind; got {out:?}");
506
+ let _ = std::fs::remove_dir_all(&base);
507
+ }
508
+
509
+ #[test]
510
+ #[serial_test::serial(env)]
511
+ fn cursor_empty_or_path_id_is_rejected() {
512
+ let base = tmp_root("illegal");
513
+ let home = base.join("home");
514
+ std::fs::create_dir_all(&home).unwrap();
515
+ let _guard = HomeGuard::set(&home);
516
+ assert!(cursor_session_dir("").is_none());
517
+ assert!(cursor_session_dir("../escape").is_none());
518
+ assert!(cursor_session_dir("a\\b").is_none());
519
+ let _ = std::fs::remove_dir_all(&base);
520
+ }
521
+
522
+ #[test]
523
+ #[serial_test::serial(env)]
524
+ fn cursor_empty_dir_without_marker_is_not_backing() {
525
+ let base = tmp_root("empty");
526
+ let home = base.join("home");
527
+ let cwd = base.join("ws");
528
+ std::fs::create_dir_all(&home).unwrap();
529
+ std::fs::create_dir_all(&cwd).unwrap();
530
+ let sid = "dddddddddddddddddddddddddddddddd";
531
+ let _guard = HomeGuard::set(&home);
532
+ let dir = home.join(".cursor").join("chats").join(sid);
533
+ std::fs::create_dir_all(&dir).unwrap();
534
+ assert!(!cursor_session_archive_present(&dir));
535
+ assert!(scan_session_store(&ctx(&cwd, Some(sid))).is_empty());
536
+ let _ = std::fs::remove_dir_all(&base);
537
+ }
538
+ }