agentpal 0.1.0

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.
@@ -0,0 +1,2486 @@
1
+ use std::{
2
+ collections::{HashMap, HashSet},
3
+ env,
4
+ path::{Path, PathBuf},
5
+ process::Stdio,
6
+ sync::Arc,
7
+ time::{Duration, Instant},
8
+ };
9
+
10
+ use agentpal_protocol::{
11
+ AgentKind, AgentPalEnvelope, ClientCommand, ClientCommandKind, DiffFileSummary, FilePreview,
12
+ FilePreviewRequest, HistoryRequest, HostStatus, PairCreateRequest, PairingPayload,
13
+ PickerExecuteMode, PickerItemKind, PickerRegistry, PickerRegistryItem, PickerTrigger,
14
+ ProjectEntryKind, ProjectTreeEntry, RelayClientMessage, RelayClientRole, RelayServerMessage,
15
+ RiskLevel, SessionEvent, SessionState, SessionSummary, WorkspaceSnapshot, WorktreeSummary,
16
+ };
17
+ use clap::Args;
18
+ use futures_util::{SinkExt, StreamExt, stream::SplitSink};
19
+ use local_ip_address::local_ip;
20
+ use qrcode::{QrCode, render::unicode};
21
+ use serde::{Deserialize, Serialize};
22
+ use serde_json::{Value, json};
23
+ use time::{Duration as TimeDuration, OffsetDateTime, format_description::well_known::Rfc3339};
24
+ use tokio::{
25
+ net::TcpStream,
26
+ process::{Child, Command},
27
+ sync::{Mutex, mpsc},
28
+ time::{sleep, timeout},
29
+ };
30
+ use tokio_tungstenite::{
31
+ MaybeTlsStream, WebSocketStream, connect_async,
32
+ tungstenite::{Error as WsError, Message},
33
+ };
34
+ use uuid::Uuid;
35
+
36
+ use crate::normalize_workspace;
37
+
38
+ const DEFAULT_PUBLIC_RELAY_URL: &str = "wss://openagentpal-production.up.railway.app/ws";
39
+
40
+ #[derive(Debug, Args)]
41
+ pub struct CodexProbeArgs {
42
+ #[arg(long, default_value = ".")]
43
+ pub workspace: PathBuf,
44
+
45
+ #[arg(long)]
46
+ pub prompt: Option<String>,
47
+
48
+ #[arg(long, default_value = "127.0.0.1")]
49
+ pub host: String,
50
+
51
+ #[arg(long, default_value_t = 37941)]
52
+ pub port: u16,
53
+
54
+ #[arg(long, default_value = "/")]
55
+ pub path: String,
56
+
57
+ #[arg(long, default_value = "codex")]
58
+ pub codex_bin: String,
59
+
60
+ #[arg(long, default_value_t = 10)]
61
+ pub timeout_seconds: u64,
62
+
63
+ #[arg(long, default_value_t = false)]
64
+ pub start_turn: bool,
65
+ }
66
+
67
+ #[derive(Debug, Args)]
68
+ pub struct CodexConnectArgs {
69
+ #[arg(long, default_value = ".")]
70
+ pub workspace: PathBuf,
71
+
72
+ #[arg(long, default_value = DEFAULT_PUBLIC_RELAY_URL)]
73
+ pub relay_url: String,
74
+
75
+ #[arg(long)]
76
+ pub host_id: Option<String>,
77
+
78
+ #[arg(long)]
79
+ pub host_name: Option<String>,
80
+
81
+ #[arg(long, default_value = "agentpal-codex-local")]
82
+ pub session_id: String,
83
+
84
+ #[arg(long, default_value = "127.0.0.1")]
85
+ pub codex_host: String,
86
+
87
+ #[arg(long, default_value_t = 37941)]
88
+ pub codex_port: u16,
89
+
90
+ #[arg(long, default_value = "/")]
91
+ pub codex_path: String,
92
+
93
+ #[arg(long, default_value = "codex")]
94
+ pub codex_bin: String,
95
+
96
+ #[arg(long)]
97
+ pub once_prompt: Option<String>,
98
+
99
+ #[arg(long, default_value_t = 0)]
100
+ pub timeout_seconds: u64,
101
+
102
+ #[arg(long, default_value_t = false)]
103
+ pub create_pair: bool,
104
+
105
+ #[arg(long, default_value_t = 10)]
106
+ pub pair_expires_minutes: u64,
107
+
108
+ #[arg(long, default_value_t = false)]
109
+ pub no_qr: bool,
110
+ }
111
+
112
+ #[derive(Debug, Args)]
113
+ pub struct CodexPairArgs {
114
+ #[arg(long)]
115
+ pub host_id: Option<String>,
116
+
117
+ #[arg(long)]
118
+ pub host_name: Option<String>,
119
+
120
+ #[arg(long)]
121
+ pub relay_url: Option<String>,
122
+
123
+ #[arg(long, default_value_t = 8790)]
124
+ pub relay_port: u16,
125
+
126
+ #[arg(long, default_value = "/ws")]
127
+ pub relay_path: String,
128
+
129
+ #[arg(long, default_value_t = 10)]
130
+ pub expires_minutes: u64,
131
+
132
+ #[arg(long, default_value_t = false)]
133
+ pub no_qr: bool,
134
+ }
135
+
136
+ #[derive(Debug, Serialize, Deserialize)]
137
+ #[serde(rename_all = "camelCase")]
138
+ pub struct CodexProbeReport {
139
+ pub ok: bool,
140
+ pub phase: ProbePhase,
141
+ pub codex_bin: String,
142
+ pub codex_version: Option<String>,
143
+ pub workspace: String,
144
+ pub listen_url: String,
145
+ pub websocket_url: String,
146
+ pub elapsed_ms: u128,
147
+ pub initialize_response: Option<Value>,
148
+ pub thread_id: Option<String>,
149
+ pub events: Vec<Value>,
150
+ pub error: Option<String>,
151
+ }
152
+
153
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
154
+ #[serde(rename_all = "snake_case")]
155
+ pub enum ProbePhase {
156
+ ResolveWorkspace,
157
+ ResolveCodexBinary,
158
+ CodexVersion,
159
+ SpawnAppServer,
160
+ WebsocketConnect,
161
+ Initialize,
162
+ ThreadStart,
163
+ TurnStart,
164
+ Completed,
165
+ }
166
+
167
+ type ThreadMap = Arc<Mutex<HashMap<String, String>>>;
168
+ type PendingThreadStarts = Arc<Mutex<HashMap<u64, String>>>;
169
+ type PendingThreadLoads = Arc<Mutex<HashMap<u64, String>>>;
170
+ type PickerItems = Arc<Mutex<Vec<PickerRegistryItem>>>;
171
+
172
+ pub fn pair(args: CodexPairArgs) -> anyhow::Result<()> {
173
+ let host_name = args.host_name.clone().unwrap_or_else(default_host_name);
174
+ let host_id = args.host_id.unwrap_or_else(default_host_id);
175
+ let relay_url = match args.relay_url {
176
+ Some(url) => normalize_ws_url(&url),
177
+ None => default_lan_relay_url(args.relay_port, &args.relay_path)?,
178
+ };
179
+ let expires_at = if args.expires_minutes == 0 {
180
+ None
181
+ } else {
182
+ Some(OffsetDateTime::now_utc() + TimeDuration::minutes(args.expires_minutes as i64))
183
+ };
184
+ let payload = PairingPayload {
185
+ version: 1,
186
+ relay_url,
187
+ pair_id: None,
188
+ host_id,
189
+ host_name,
190
+ pair_token: Uuid::new_v4().to_string(),
191
+ device_id: None,
192
+ device_token: None,
193
+ expires_at,
194
+ };
195
+ print_pairing_payload(&payload, args.no_qr)?;
196
+
197
+ Ok(())
198
+ }
199
+
200
+ pub async fn probe(args: CodexProbeArgs) -> CodexProbeReport {
201
+ let started = Instant::now();
202
+ let timeout_duration = Duration::from_secs(args.timeout_seconds);
203
+ let mut report = CodexProbeReport {
204
+ ok: false,
205
+ phase: ProbePhase::ResolveWorkspace,
206
+ codex_bin: args.codex_bin.clone(),
207
+ codex_version: None,
208
+ workspace: String::new(),
209
+ listen_url: format!("ws://{}:{}", args.host, args.port),
210
+ websocket_url: websocket_url(&args.host, args.port, &args.path),
211
+ elapsed_ms: 0,
212
+ initialize_response: None,
213
+ thread_id: None,
214
+ events: Vec::new(),
215
+ error: None,
216
+ };
217
+
218
+ let workspace = match normalize_workspace(args.workspace.clone()) {
219
+ Ok(path) => path,
220
+ Err(error) => return fail(report, started, ProbePhase::ResolveWorkspace, error),
221
+ };
222
+ report.workspace = workspace.display().to_string();
223
+
224
+ report.phase = ProbePhase::ResolveCodexBinary;
225
+ let codex_bin = match resolve_command(&args.codex_bin) {
226
+ Ok(path) => path,
227
+ Err(error) => return fail(report, started, ProbePhase::ResolveCodexBinary, error),
228
+ };
229
+ report.codex_bin = codex_bin.display().to_string();
230
+
231
+ report.phase = ProbePhase::CodexVersion;
232
+ report.codex_version = codex_version(&codex_bin).await.ok();
233
+
234
+ report.phase = ProbePhase::SpawnAppServer;
235
+ let mut child = match Command::new(&codex_bin)
236
+ .arg("app-server")
237
+ .arg("--listen")
238
+ .arg(&report.listen_url)
239
+ .current_dir(&workspace)
240
+ .stdin(Stdio::null())
241
+ .stdout(Stdio::null())
242
+ .stderr(Stdio::piped())
243
+ .spawn()
244
+ {
245
+ Ok(child) => child,
246
+ Err(error) => return fail(report, started, ProbePhase::SpawnAppServer, error),
247
+ };
248
+
249
+ let connect_result = async {
250
+ report.phase = ProbePhase::WebsocketConnect;
251
+ tokio::time::sleep(Duration::from_millis(600)).await;
252
+ let (mut socket, _) = connect_async(report.websocket_url.as_str()).await?;
253
+
254
+ report.phase = ProbePhase::Initialize;
255
+ let initialize = json!({
256
+ "jsonrpc": "2.0",
257
+ "id": 1,
258
+ "method": "initialize",
259
+ "params": {
260
+ "clientInfo": {
261
+ "name": "agentpal-host",
262
+ "title": "AgentPal Host",
263
+ "version": env!("CARGO_PKG_VERSION")
264
+ },
265
+ "capabilities": {
266
+ "experimentalApi": true,
267
+ "requestAttestation": false
268
+ }
269
+ }
270
+ });
271
+ socket
272
+ .send(Message::Text(initialize.to_string().into()))
273
+ .await?;
274
+ let init_response = read_until_response(&mut socket, 1, &mut report.events).await?;
275
+ report.initialize_response = Some(init_response);
276
+
277
+ report.phase = ProbePhase::ThreadStart;
278
+ let thread_start = json!({
279
+ "jsonrpc": "2.0",
280
+ "id": 2,
281
+ "method": "thread/start",
282
+ "params": {
283
+ "cwd": report.workspace,
284
+ "runtimeWorkspaceRoots": [report.workspace],
285
+ "approvalPolicy": "on-request",
286
+ "sandbox": "workspace-write",
287
+ "threadSource": "user",
288
+ "baseInstructions": "You are running under AgentPal probe. Keep the response short."
289
+ }
290
+ });
291
+ socket
292
+ .send(Message::Text(thread_start.to_string().into()))
293
+ .await?;
294
+ let thread_response = read_until_response(&mut socket, 2, &mut report.events).await?;
295
+ report.thread_id = extract_thread_id(&thread_response)
296
+ .or_else(|| extract_thread_id_from_events(&report.events));
297
+
298
+ if args.start_turn {
299
+ report.phase = ProbePhase::TurnStart;
300
+ let thread_id = report.thread_id.clone().ok_or_else(|| {
301
+ anyhow::anyhow!("thread/start response did not include a thread id")
302
+ })?;
303
+ let prompt = args
304
+ .prompt
305
+ .as_deref()
306
+ .unwrap_or("AgentPal probe: reply with ok.");
307
+ let turn_start = json!({
308
+ "jsonrpc": "2.0",
309
+ "id": 3,
310
+ "method": "turn/start",
311
+ "params": {
312
+ "threadId": thread_id,
313
+ "input": [{
314
+ "type": "text",
315
+ "text": prompt,
316
+ "text_elements": []
317
+ }],
318
+ "cwd": report.workspace,
319
+ "runtimeWorkspaceRoots": [report.workspace],
320
+ "approvalPolicy": "on-request"
321
+ }
322
+ });
323
+ socket
324
+ .send(Message::Text(turn_start.to_string().into()))
325
+ .await?;
326
+ let _ = read_until_response(&mut socket, 3, &mut report.events).await?;
327
+ }
328
+
329
+ anyhow::Ok(())
330
+ };
331
+
332
+ let result = timeout(timeout_duration, connect_result).await;
333
+
334
+ if let Err(error) = child.start_kill() {
335
+ report.events.push(json!({
336
+ "type": "agentpal.kill-warning",
337
+ "message": error.to_string()
338
+ }));
339
+ }
340
+ let _ = timeout(Duration::from_secs(2), child.wait()).await;
341
+
342
+ match result {
343
+ Ok(Ok(())) => {
344
+ report.ok = true;
345
+ report.phase = ProbePhase::Completed;
346
+ report.elapsed_ms = started.elapsed().as_millis();
347
+ report
348
+ }
349
+ Ok(Err(error)) => {
350
+ let phase = report.phase;
351
+ fail(report, started, phase, error)
352
+ }
353
+ Err(error) => {
354
+ let phase = report.phase;
355
+ fail(report, started, phase, error)
356
+ }
357
+ }
358
+ }
359
+
360
+ pub async fn connect(args: CodexConnectArgs) -> anyhow::Result<()> {
361
+ let workspace = normalize_workspace(args.workspace.clone())?;
362
+ let workspace_text = workspace.display().to_string();
363
+ let host_name = args.host_name.clone().unwrap_or_else(default_host_name);
364
+ let codex_bin = resolve_command(&args.codex_bin)?;
365
+ let codex_version = codex_version(&codex_bin).await.ok();
366
+ let codex_listen_url = format!("ws://{}:{}", args.codex_host, args.codex_port);
367
+ let codex_websocket_url = websocket_url(&args.codex_host, args.codex_port, &args.codex_path);
368
+
369
+ let mut child = Command::new(&codex_bin)
370
+ .arg("app-server")
371
+ .arg("--listen")
372
+ .arg(&codex_listen_url)
373
+ .current_dir(&workspace)
374
+ .stdin(Stdio::null())
375
+ .stdout(Stdio::null())
376
+ .stderr(Stdio::piped())
377
+ .spawn()?;
378
+
379
+ let result = run_connect_loop(
380
+ &args,
381
+ &workspace_text,
382
+ &host_name,
383
+ codex_version,
384
+ &codex_websocket_url,
385
+ )
386
+ .await;
387
+
388
+ stop_child(&mut child).await;
389
+ result
390
+ }
391
+
392
+ async fn run_connect_loop(
393
+ args: &CodexConnectArgs,
394
+ workspace: &str,
395
+ host_name: &str,
396
+ codex_version: Option<String>,
397
+ codex_websocket_url: &str,
398
+ ) -> anyhow::Result<()> {
399
+ tokio::time::sleep(Duration::from_millis(600)).await;
400
+ let relay_url = normalize_ws_url(&args.relay_url);
401
+ let (relay_socket, _) = connect_async(relay_url.as_str()).await?;
402
+ let (codex_socket, _) = connect_async(codex_websocket_url).await?;
403
+ let (relay_write, mut relay_read) = relay_socket.split();
404
+ let (codex_write, codex_read) = codex_socket.split();
405
+ let relay_write = Arc::new(Mutex::new(relay_write));
406
+ let codex_write = Arc::new(Mutex::new(codex_write));
407
+ let (codex_tx, mut codex_rx) = mpsc::unbounded_channel::<Value>();
408
+ let host_id = args.host_id.clone().unwrap_or_else(default_host_id);
409
+ let session_id = args.session_id.clone();
410
+ let workspace_owned = workspace.to_owned();
411
+ let thread_ids = Arc::new(Mutex::new(HashMap::<String, String>::new()));
412
+ let pending_thread_starts = Arc::new(Mutex::new(HashMap::<u64, String>::new()));
413
+ let pending_thread_loads = Arc::new(Mutex::new(HashMap::<u64, String>::new()));
414
+ let picker_items = Arc::new(Mutex::new(default_picker_items()));
415
+ let seq = Arc::new(Mutex::new(0_u64));
416
+
417
+ relay_send(
418
+ &relay_write,
419
+ &RelayClientMessage::Register {
420
+ role: RelayClientRole::Host,
421
+ client_id: format!("{host_id}-host"),
422
+ host_id: Some(host_id.clone()),
423
+ device_id: None,
424
+ device_token: None,
425
+ },
426
+ )
427
+ .await?;
428
+ if args.create_pair {
429
+ let expires_in_seconds = if args.pair_expires_minutes == 0 {
430
+ None
431
+ } else {
432
+ Some(args.pair_expires_minutes.saturating_mul(60))
433
+ };
434
+ relay_send(
435
+ &relay_write,
436
+ &RelayClientMessage::PairCreate {
437
+ request: PairCreateRequest {
438
+ host_id: host_id.clone(),
439
+ host_name: host_name.to_owned(),
440
+ relay_url: relay_url.clone(),
441
+ pair_id: None,
442
+ pair_token: None,
443
+ expires_in_seconds,
444
+ },
445
+ },
446
+ )
447
+ .await?;
448
+ }
449
+ publish_host_status(&relay_write, &host_id, host_name, workspace, 1).await?;
450
+
451
+ send_codex_initialize(&codex_write).await?;
452
+ request_codex_picker_sources(&codex_write, &seq, workspace).await?;
453
+ publish_picker_registry(&relay_write, &host_id, &session_id, &picker_items).await?;
454
+ publish_recent_codex_threads(
455
+ &relay_write,
456
+ &codex_write,
457
+ &host_id,
458
+ &session_id,
459
+ &workspace_owned,
460
+ &seq,
461
+ )
462
+ .await?;
463
+ publish_workspace_snapshot(
464
+ &relay_write,
465
+ &host_id,
466
+ &session_id,
467
+ &workspace_owned,
468
+ "workspace-initial",
469
+ 3,
470
+ 220,
471
+ )
472
+ .await?;
473
+
474
+ let relay_writer_for_codex = Arc::clone(&relay_write);
475
+ let host_for_codex = host_id.clone();
476
+ let seq_for_codex = Arc::clone(&seq);
477
+ let threads_for_codex = Arc::clone(&thread_ids);
478
+ let pending_for_codex = Arc::clone(&pending_thread_starts);
479
+ let pending_loads_for_codex = Arc::clone(&pending_thread_loads);
480
+ let picker_items_for_codex = Arc::clone(&picker_items);
481
+ let picker_session_for_codex = session_id.clone();
482
+ let workspace_for_codex = workspace_owned.clone();
483
+ tokio::spawn(async move {
484
+ read_codex_events(
485
+ codex_read,
486
+ codex_tx,
487
+ relay_writer_for_codex,
488
+ host_for_codex,
489
+ workspace_for_codex,
490
+ seq_for_codex,
491
+ threads_for_codex,
492
+ pending_for_codex,
493
+ pending_loads_for_codex,
494
+ picker_items_for_codex,
495
+ picker_session_for_codex,
496
+ )
497
+ .await;
498
+ });
499
+
500
+ let relay_writer_for_commands = Arc::clone(&relay_write);
501
+ let codex_writer_for_commands = Arc::clone(&codex_write);
502
+ let host_for_commands = host_id.clone();
503
+ let session_for_commands = session_id.clone();
504
+ let seq_for_commands = Arc::clone(&seq);
505
+ let threads_for_commands = Arc::clone(&thread_ids);
506
+ let pending_for_commands = Arc::clone(&pending_thread_starts);
507
+ let pending_loads_for_commands = Arc::clone(&pending_thread_loads);
508
+ let workspace_for_commands = workspace_owned.clone();
509
+ let no_qr_for_commands = args.no_qr;
510
+ let mut command_task = tokio::spawn(async move {
511
+ while let Some(message) = relay_read.next().await {
512
+ let message = match message {
513
+ Ok(Message::Text(text)) => text,
514
+ Ok(Message::Close(_)) => break,
515
+ Ok(_) => continue,
516
+ Err(_) => break,
517
+ };
518
+ let Ok(server_message) = serde_json::from_str::<RelayServerMessage>(&message) else {
519
+ continue;
520
+ };
521
+ match server_message {
522
+ RelayServerMessage::PairCreated { pairing } => {
523
+ if pairing.host_id == host_for_commands {
524
+ if let Err(error) = print_pairing_payload(&pairing, no_qr_for_commands) {
525
+ eprintln!("Failed to render pairing payload: {error}");
526
+ }
527
+ }
528
+ }
529
+ RelayServerMessage::PairClaimed { claim } => {
530
+ if claim.host_id == host_for_commands {
531
+ eprintln!(
532
+ "AgentPal mobile paired: {} ({})",
533
+ claim.mobile_client_id, claim.device_id
534
+ );
535
+ }
536
+ }
537
+ RelayServerMessage::ClientCommand { command } => {
538
+ if command.host_id != host_for_commands {
539
+ continue;
540
+ }
541
+ if let Err(error) = handle_client_command(
542
+ command,
543
+ &relay_writer_for_commands,
544
+ &codex_writer_for_commands,
545
+ &host_for_commands,
546
+ &session_for_commands,
547
+ &workspace_for_commands,
548
+ &seq_for_commands,
549
+ &threads_for_commands,
550
+ &pending_for_commands,
551
+ &pending_loads_for_commands,
552
+ )
553
+ .await
554
+ {
555
+ let _ = publish_session_event(
556
+ &relay_writer_for_commands,
557
+ &host_for_commands,
558
+ &session_for_commands,
559
+ &seq_for_commands,
560
+ SessionEvent::Error {
561
+ message: error.to_string(),
562
+ phase: Some("client-command".to_owned()),
563
+ },
564
+ )
565
+ .await;
566
+ }
567
+ }
568
+ RelayServerMessage::HistoryRequest { request } => {
569
+ if request.host_id != host_for_commands {
570
+ continue;
571
+ }
572
+ if let Err(error) = handle_history_request(
573
+ request,
574
+ &codex_writer_for_commands,
575
+ &workspace_for_commands,
576
+ &seq_for_commands,
577
+ &threads_for_commands,
578
+ &pending_loads_for_commands,
579
+ )
580
+ .await
581
+ {
582
+ let _ = publish_session_event(
583
+ &relay_writer_for_commands,
584
+ &host_for_commands,
585
+ &session_for_commands,
586
+ &seq_for_commands,
587
+ SessionEvent::Error {
588
+ message: error.to_string(),
589
+ phase: Some("history-request".to_owned()),
590
+ },
591
+ )
592
+ .await;
593
+ }
594
+ }
595
+ RelayServerMessage::WorkspaceRequest { request } => {
596
+ if request.host_id != host_for_commands {
597
+ continue;
598
+ }
599
+ let workspace = request
600
+ .workspace
601
+ .as_deref()
602
+ .unwrap_or(&workspace_for_commands);
603
+ if let Err(error) = publish_workspace_snapshot(
604
+ &relay_writer_for_commands,
605
+ &host_for_commands,
606
+ request
607
+ .session_id
608
+ .as_deref()
609
+ .unwrap_or(&session_for_commands),
610
+ workspace,
611
+ &request.request_id,
612
+ request.max_depth,
613
+ request.max_entries,
614
+ )
615
+ .await
616
+ {
617
+ let _ = publish_session_event(
618
+ &relay_writer_for_commands,
619
+ &host_for_commands,
620
+ &session_for_commands,
621
+ &seq_for_commands,
622
+ SessionEvent::Error {
623
+ message: error.to_string(),
624
+ phase: Some("workspace-request".to_owned()),
625
+ },
626
+ )
627
+ .await;
628
+ }
629
+ }
630
+ RelayServerMessage::FilePreviewRequest { request } => {
631
+ if request.host_id != host_for_commands {
632
+ continue;
633
+ }
634
+ if let Err(error) =
635
+ publish_file_preview(&relay_writer_for_commands, request).await
636
+ {
637
+ let _ = publish_session_event(
638
+ &relay_writer_for_commands,
639
+ &host_for_commands,
640
+ &session_for_commands,
641
+ &seq_for_commands,
642
+ SessionEvent::Error {
643
+ message: error.to_string(),
644
+ phase: Some("file-preview-request".to_owned()),
645
+ },
646
+ )
647
+ .await;
648
+ }
649
+ }
650
+ RelayServerMessage::Error { message } => {
651
+ eprintln!("AgentPal Relay error: {message}");
652
+ }
653
+ _ => {}
654
+ }
655
+ }
656
+ anyhow::Result::<()>::Err(anyhow::anyhow!("relay websocket closed"))
657
+ });
658
+
659
+ if let Some(prompt) = &args.once_prompt {
660
+ let command = ClientCommand::input_submit(
661
+ format!("host-once-{}", Uuid::new_v4()),
662
+ host_id.clone(),
663
+ args.session_id.clone(),
664
+ prompt.clone(),
665
+ );
666
+ handle_client_command(
667
+ command,
668
+ &relay_write,
669
+ &codex_write,
670
+ &host_id,
671
+ &session_id,
672
+ workspace,
673
+ &seq,
674
+ &thread_ids,
675
+ &pending_thread_starts,
676
+ &pending_thread_loads,
677
+ )
678
+ .await?;
679
+ }
680
+
681
+ let loop_future = async {
682
+ while let Some(event) = codex_rx.recv().await {
683
+ if args.once_prompt.is_some()
684
+ && event.get("method").and_then(Value::as_str) == Some("turn/completed")
685
+ {
686
+ break;
687
+ }
688
+ }
689
+ };
690
+ let loop_result = if args.timeout_seconds > 0 {
691
+ tokio::select! {
692
+ result = timeout(Duration::from_secs(args.timeout_seconds), loop_future) => {
693
+ result.map(|_| ()).map_err(anyhow::Error::from)
694
+ }
695
+ result = &mut command_task => relay_task_result(result),
696
+ }
697
+ } else {
698
+ tokio::select! {
699
+ _ = loop_future => Ok(()),
700
+ result = tokio::signal::ctrl_c() => result.map_err(anyhow::Error::from),
701
+ result = &mut command_task => relay_task_result(result),
702
+ }
703
+ };
704
+ if !command_task.is_finished() {
705
+ command_task.abort();
706
+ }
707
+
708
+ publish_session_event(
709
+ &relay_write,
710
+ &host_id,
711
+ &session_id,
712
+ &seq,
713
+ SessionEvent::StateChanged {
714
+ state: SessionState::Offline,
715
+ },
716
+ )
717
+ .await?;
718
+ publish_host_status(&relay_write, &host_id, host_name, workspace, 0).await?;
719
+
720
+ if args.once_prompt.is_some() {
721
+ loop_result?;
722
+ } else if args.timeout_seconds > 0 {
723
+ loop_result?;
724
+ }
725
+
726
+ if let Some(version) = codex_version {
727
+ eprintln!("Codex connected through AgentPal Host ({version})");
728
+ }
729
+ Ok(())
730
+ }
731
+
732
+ fn relay_task_result(
733
+ result: Result<anyhow::Result<()>, tokio::task::JoinError>,
734
+ ) -> anyhow::Result<()> {
735
+ match result {
736
+ Ok(Ok(())) => Ok(()),
737
+ Ok(Err(error)) => Err(error),
738
+ Err(error) => Err(anyhow::Error::from(error)),
739
+ }
740
+ }
741
+
742
+ async fn handle_client_command(
743
+ command: ClientCommand,
744
+ relay_write: &Arc<Mutex<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
745
+ codex_write: &Arc<Mutex<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
746
+ host_id: &str,
747
+ fallback_session_id: &str,
748
+ workspace: &str,
749
+ seq: &Arc<Mutex<u64>>,
750
+ thread_ids: &ThreadMap,
751
+ pending_thread_starts: &PendingThreadStarts,
752
+ pending_thread_loads: &PendingThreadLoads,
753
+ ) -> anyhow::Result<()> {
754
+ if command.kind != ClientCommandKind::InputSubmit {
755
+ return Ok(());
756
+ }
757
+ let text = command
758
+ .payload
759
+ .get("text")
760
+ .and_then(Value::as_str)
761
+ .unwrap_or_default()
762
+ .trim()
763
+ .to_owned();
764
+ if text.is_empty() {
765
+ return Ok(());
766
+ }
767
+ let target_session_id = if command.session_id.trim().is_empty() {
768
+ fallback_session_id
769
+ } else {
770
+ command.session_id.as_str()
771
+ };
772
+
773
+ publish_session_event(
774
+ relay_write,
775
+ host_id,
776
+ target_session_id,
777
+ seq,
778
+ SessionEvent::UserMessage { text: text.clone() },
779
+ )
780
+ .await?;
781
+ publish_session_event(
782
+ relay_write,
783
+ host_id,
784
+ target_session_id,
785
+ seq,
786
+ SessionEvent::StateChanged {
787
+ state: SessionState::Running,
788
+ },
789
+ )
790
+ .await?;
791
+
792
+ let current_thread = thread_ids.lock().await.get(target_session_id).cloned();
793
+ let thread = match current_thread {
794
+ Some(thread) => {
795
+ if target_session_id == fallback_session_id {
796
+ thread
797
+ } else {
798
+ resume_codex_thread(
799
+ codex_write,
800
+ workspace,
801
+ seq,
802
+ pending_thread_loads,
803
+ target_session_id,
804
+ &thread,
805
+ false,
806
+ )
807
+ .await?
808
+ }
809
+ }
810
+ None => {
811
+ let request_id = next_request_id(seq).await;
812
+ pending_thread_starts
813
+ .lock()
814
+ .await
815
+ .insert(request_id, target_session_id.to_owned());
816
+ let thread_start = json!({
817
+ "jsonrpc": "2.0",
818
+ "id": request_id,
819
+ "method": "thread/start",
820
+ "params": {
821
+ "cwd": workspace,
822
+ "runtimeWorkspaceRoots": [workspace],
823
+ "approvalPolicy": "on-request",
824
+ "sandbox": "workspace-write",
825
+ "threadSource": "user",
826
+ "baseInstructions": "You are controlled by AgentPal mobile UI. Keep mobile-visible updates concise and structured."
827
+ }
828
+ });
829
+ codex_send(codex_write, &thread_start).await?;
830
+ wait_for_thread_id(thread_ids, target_session_id).await?
831
+ }
832
+ };
833
+
834
+ let request_id = next_request_id(seq).await;
835
+ let turn_start = json!({
836
+ "jsonrpc": "2.0",
837
+ "id": request_id,
838
+ "method": "turn/start",
839
+ "params": {
840
+ "threadId": thread,
841
+ "input": [{
842
+ "type": "text",
843
+ "text": text,
844
+ "text_elements": []
845
+ }],
846
+ "cwd": workspace,
847
+ "runtimeWorkspaceRoots": [workspace],
848
+ "approvalPolicy": "on-request"
849
+ }
850
+ });
851
+ codex_send(codex_write, &turn_start).await?;
852
+ Ok(())
853
+ }
854
+
855
+ async fn handle_history_request(
856
+ request: HistoryRequest,
857
+ codex_write: &Arc<Mutex<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
858
+ workspace: &str,
859
+ seq: &Arc<Mutex<u64>>,
860
+ thread_ids: &ThreadMap,
861
+ pending_thread_loads: &PendingThreadLoads,
862
+ ) -> anyhow::Result<()> {
863
+ let session_id = request.session_id.trim();
864
+ if session_id.is_empty() {
865
+ return Ok(());
866
+ }
867
+
868
+ let known_thread = thread_ids.lock().await.get(session_id).cloned();
869
+ let Some(thread_id) = known_thread.or_else(|| thread_id_from_session_id(session_id)) else {
870
+ return Ok(());
871
+ };
872
+
873
+ resume_codex_thread(
874
+ codex_write,
875
+ workspace,
876
+ seq,
877
+ pending_thread_loads,
878
+ session_id,
879
+ &thread_id,
880
+ true,
881
+ )
882
+ .await?;
883
+ Ok(())
884
+ }
885
+
886
+ async fn read_codex_events(
887
+ mut codex_read: futures_util::stream::SplitStream<WebSocketStream<MaybeTlsStream<TcpStream>>>,
888
+ codex_tx: mpsc::UnboundedSender<Value>,
889
+ relay_write: Arc<Mutex<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
890
+ host_id: String,
891
+ workspace: String,
892
+ seq: Arc<Mutex<u64>>,
893
+ thread_ids: ThreadMap,
894
+ pending_thread_starts: PendingThreadStarts,
895
+ pending_thread_loads: PendingThreadLoads,
896
+ picker_items: PickerItems,
897
+ picker_session_id: String,
898
+ ) {
899
+ while let Some(message) = codex_read.next().await {
900
+ let Ok(Message::Text(text)) = message else {
901
+ continue;
902
+ };
903
+ let Ok(value) = serde_json::from_str::<Value>(&text) else {
904
+ continue;
905
+ };
906
+ let _ = codex_tx.send(value.clone());
907
+ if let Some(items) = picker_items_from_codex_response(&value) {
908
+ update_picker_items(&picker_items, items).await;
909
+ let _ =
910
+ publish_picker_registry(&relay_write, &host_id, &picker_session_id, &picker_items)
911
+ .await;
912
+ }
913
+ let request_id = value.get("id").and_then(Value::as_u64);
914
+ let event_thread_id =
915
+ extract_thread_id(&value).or_else(|| extract_thread_id_from_events(&[value.clone()]));
916
+ if let Some(id) = &event_thread_id {
917
+ let pending_session_id = match request_id {
918
+ Some(request_id) => pending_thread_starts.lock().await.remove(&request_id),
919
+ None => None,
920
+ };
921
+ let session_id = pending_session_id.unwrap_or_else(|| session_id_for_thread(id));
922
+ thread_ids.lock().await.insert(session_id, id.clone());
923
+ }
924
+ if let Some(request_id) = request_id {
925
+ if let Some(session_id) = pending_thread_loads.lock().await.remove(&request_id) {
926
+ if let Some(thread_id) = value.pointer("/result/thread/id").and_then(Value::as_str)
927
+ {
928
+ thread_ids
929
+ .lock()
930
+ .await
931
+ .insert(session_id, thread_id.to_owned());
932
+ }
933
+ }
934
+ }
935
+ let thread_id_snapshot = thread_ids.lock().await.clone();
936
+ for (history_session_id, event) in
937
+ codex_response_to_history_events(&value, &workspace, &thread_id_snapshot)
938
+ {
939
+ let _ = publish_session_event(&relay_write, &host_id, &history_session_id, &seq, event)
940
+ .await;
941
+ }
942
+ for (history_session_id, thread_id) in codex_threads_in_response(&value) {
943
+ thread_ids
944
+ .lock()
945
+ .await
946
+ .insert(history_session_id, thread_id);
947
+ }
948
+
949
+ let (mapped_event_session_id, fallback_session_id) = {
950
+ let thread_ids = thread_ids.lock().await;
951
+ let mapped_event_session_id = event_thread_id
952
+ .as_deref()
953
+ .and_then(|thread_id| session_id_for_known_thread(&thread_ids, thread_id));
954
+ let fallback_session_id = thread_ids
955
+ .keys()
956
+ .next()
957
+ .cloned()
958
+ .unwrap_or_else(|| "agentpal-codex-local".to_owned());
959
+ (mapped_event_session_id, fallback_session_id)
960
+ };
961
+ let session_id = {
962
+ let thread_ids = thread_ids.lock().await;
963
+ session_id_from_codex_event(&value, &thread_ids)
964
+ }
965
+ .or(mapped_event_session_id)
966
+ .unwrap_or(fallback_session_id);
967
+ if let Some(error) = value.get("error") {
968
+ let _ = publish_session_event(
969
+ &relay_write,
970
+ &host_id,
971
+ &session_id,
972
+ &seq,
973
+ SessionEvent::Error {
974
+ message: error.to_string(),
975
+ phase: Some("codex-response".to_owned()),
976
+ },
977
+ )
978
+ .await;
979
+ }
980
+ if let Some(event) = codex_event_to_session_event(&value, &session_id, &workspace) {
981
+ let _ = publish_session_event(&relay_write, &host_id, &session_id, &seq, event).await;
982
+ }
983
+ }
984
+ }
985
+
986
+ fn codex_event_to_session_event(
987
+ value: &Value,
988
+ session_id: &str,
989
+ workspace_fallback: &str,
990
+ ) -> Option<SessionEvent> {
991
+ let method = value.get("method").and_then(Value::as_str)?;
992
+ match method {
993
+ "thread/started" => {
994
+ let thread = value.pointer("/params/thread")?;
995
+ let workspace = thread
996
+ .get("cwd")
997
+ .and_then(Value::as_str)
998
+ .unwrap_or(workspace_fallback)
999
+ .to_owned();
1000
+ Some(SessionEvent::SessionStarted {
1001
+ summary: SessionSummary {
1002
+ session_id: session_id.to_owned(),
1003
+ agent_kind: AgentKind::Codex,
1004
+ workspace,
1005
+ title: thread
1006
+ .get("name")
1007
+ .and_then(Value::as_str)
1008
+ .map(ToOwned::to_owned),
1009
+ state: SessionState::Idle,
1010
+ pending_approvals: 0,
1011
+ updated_at: OffsetDateTime::now_utc(),
1012
+ },
1013
+ })
1014
+ }
1015
+ "turn/started" => Some(SessionEvent::StateChanged {
1016
+ state: SessionState::Running,
1017
+ }),
1018
+ "turn/completed" => {
1019
+ let status = value
1020
+ .pointer("/params/turn/status")
1021
+ .and_then(Value::as_str)
1022
+ .unwrap_or("completed");
1023
+ let state = match status {
1024
+ "failed" => SessionState::Failed,
1025
+ "interrupted" => SessionState::Idle,
1026
+ _ => SessionState::Completed,
1027
+ };
1028
+ Some(SessionEvent::StateChanged { state })
1029
+ }
1030
+ "item/agentMessage/delta" => value
1031
+ .pointer("/params/delta")
1032
+ .and_then(Value::as_str)
1033
+ .filter(|delta| !delta.is_empty())
1034
+ .map(|delta| SessionEvent::AgentMessage {
1035
+ text: delta.to_owned(),
1036
+ complete: false,
1037
+ }),
1038
+ "turn/diff/updated" => {
1039
+ let diff = value
1040
+ .pointer("/params/diff")
1041
+ .and_then(Value::as_str)
1042
+ .unwrap_or("");
1043
+ let (files_changed, additions, deletions) = summarize_diff(diff);
1044
+ Some(SessionEvent::DiffUpdated {
1045
+ summary: agentpal_protocol::DiffSummary {
1046
+ files_changed,
1047
+ additions,
1048
+ deletions,
1049
+ files: Vec::new(),
1050
+ },
1051
+ })
1052
+ }
1053
+ "item/started" => value
1054
+ .pointer("/params/item/type")
1055
+ .and_then(Value::as_str)
1056
+ .map(|kind| SessionEvent::ToolStarted {
1057
+ name: kind.to_owned(),
1058
+ input: value
1059
+ .pointer("/params/item")
1060
+ .cloned()
1061
+ .unwrap_or(Value::Null),
1062
+ }),
1063
+ "item/completed" => {
1064
+ let item = value.pointer("/params/item")?;
1065
+ let kind = item.get("type").and_then(Value::as_str).unwrap_or("item");
1066
+ if kind == "agentMessage" {
1067
+ return item
1068
+ .get("text")
1069
+ .and_then(Value::as_str)
1070
+ .filter(|text| !text.trim().is_empty())
1071
+ .map(|text| SessionEvent::AgentMessage {
1072
+ text: text.to_owned(),
1073
+ complete: true,
1074
+ });
1075
+ }
1076
+ if kind == "commandExecution" {
1077
+ return Some(SessionEvent::CommandOutput {
1078
+ command: item
1079
+ .get("command")
1080
+ .and_then(Value::as_str)
1081
+ .unwrap_or("command")
1082
+ .to_owned(),
1083
+ exit_code: item
1084
+ .get("exitCode")
1085
+ .and_then(Value::as_i64)
1086
+ .map(|code| code as i32),
1087
+ summary: item
1088
+ .get("aggregatedOutput")
1089
+ .and_then(Value::as_str)
1090
+ .map(short_summary)
1091
+ .unwrap_or_default(),
1092
+ });
1093
+ }
1094
+ Some(SessionEvent::ToolFinished {
1095
+ name: kind.to_owned(),
1096
+ ok: !matches!(
1097
+ item.get("status").and_then(Value::as_str),
1098
+ Some("failed" | "error")
1099
+ ),
1100
+ summary: summarize_item(item),
1101
+ })
1102
+ }
1103
+ "error" => Some(SessionEvent::Error {
1104
+ message: value
1105
+ .pointer("/params/error/message")
1106
+ .or_else(|| value.pointer("/params/error"))
1107
+ .map(|v| {
1108
+ v.as_str()
1109
+ .map(ToOwned::to_owned)
1110
+ .unwrap_or_else(|| v.to_string())
1111
+ })
1112
+ .unwrap_or_else(|| "Codex app-server error".to_owned()),
1113
+ phase: Some("codex".to_owned()),
1114
+ }),
1115
+ _ => None,
1116
+ }
1117
+ }
1118
+
1119
+ fn summarize_diff(diff: &str) -> (u32, u32, u32) {
1120
+ let mut files = 0;
1121
+ let mut additions = 0;
1122
+ let mut deletions = 0;
1123
+ for line in diff.lines() {
1124
+ if line.starts_with("diff --git ") {
1125
+ files += 1;
1126
+ } else if line.starts_with('+') && !line.starts_with("+++") {
1127
+ additions += 1;
1128
+ } else if line.starts_with('-') && !line.starts_with("---") {
1129
+ deletions += 1;
1130
+ }
1131
+ }
1132
+ (files, additions, deletions)
1133
+ }
1134
+
1135
+ fn summarize_item(item: &Value) -> String {
1136
+ let kind = item.get("type").and_then(Value::as_str).unwrap_or("item");
1137
+ match kind {
1138
+ "commandExecution" => {
1139
+ let command = item
1140
+ .get("command")
1141
+ .and_then(Value::as_str)
1142
+ .unwrap_or("command");
1143
+ let status = item
1144
+ .get("status")
1145
+ .and_then(Value::as_str)
1146
+ .unwrap_or("completed");
1147
+ format!("{command} ({status})")
1148
+ }
1149
+ "fileChange" => {
1150
+ let count = item
1151
+ .get("changes")
1152
+ .and_then(Value::as_array)
1153
+ .map(|items| items.len())
1154
+ .unwrap_or(0);
1155
+ format!("{count} file changes")
1156
+ }
1157
+ _ => kind.to_owned(),
1158
+ }
1159
+ }
1160
+
1161
+ fn default_picker_items() -> Vec<PickerRegistryItem> {
1162
+ [
1163
+ ("/help", "帮助", "查看 Codex 可用命令和当前会话帮助"),
1164
+ ("/model", "模型", "切换或查看当前 Codex 模型"),
1165
+ ("/status", "状态", "查看当前会话、模型和权限状态"),
1166
+ ("/diff", "Diff", "查看当前工作区变更"),
1167
+ ("/review", "Review", "请求 Codex 审查当前变更"),
1168
+ ("/approvals", "审批", "查看或调整审批相关设置"),
1169
+ ("/new", "新会话", "开始一个新的 Codex 会话"),
1170
+ ("/resume", "恢复", "恢复历史 Codex 会话"),
1171
+ ("/compact", "压缩上下文", "压缩当前会话上下文"),
1172
+ ("/clear", "清屏", "清理当前终端显示"),
1173
+ ("/quit", "退出", "退出当前 Codex 终端会话"),
1174
+ ]
1175
+ .into_iter()
1176
+ .map(|(insert_text, label, description)| PickerRegistryItem {
1177
+ id: format!("slash:{insert_text}"),
1178
+ trigger: PickerTrigger::Slash,
1179
+ label: label.to_owned(),
1180
+ kind: PickerItemKind::SlashCommand,
1181
+ source: AgentKind::Codex,
1182
+ description: Some(description.to_owned()),
1183
+ insert_text: format!("{insert_text} "),
1184
+ execute_mode: PickerExecuteMode::Insert,
1185
+ })
1186
+ .collect()
1187
+ }
1188
+
1189
+ fn picker_items_from_codex_response(value: &Value) -> Option<Vec<PickerRegistryItem>> {
1190
+ let mut items = Vec::new();
1191
+ if let Some(entries) = value.pointer("/result/data").and_then(Value::as_array) {
1192
+ for entry in entries {
1193
+ for skill in entry
1194
+ .get("skills")
1195
+ .and_then(Value::as_array)
1196
+ .into_iter()
1197
+ .flatten()
1198
+ {
1199
+ let Some(name) = skill.get("name").and_then(Value::as_str) else {
1200
+ continue;
1201
+ };
1202
+ if skill.get("enabled").and_then(Value::as_bool) == Some(false) {
1203
+ continue;
1204
+ }
1205
+ let label = skill
1206
+ .pointer("/interface/displayName")
1207
+ .and_then(Value::as_str)
1208
+ .unwrap_or(name);
1209
+ let description = skill
1210
+ .pointer("/interface/shortDescription")
1211
+ .or_else(|| skill.get("shortDescription"))
1212
+ .or_else(|| skill.get("description"))
1213
+ .and_then(Value::as_str)
1214
+ .map(short_picker_description);
1215
+ items.push(PickerRegistryItem {
1216
+ id: format!("skill:{name}"),
1217
+ trigger: PickerTrigger::Dollar,
1218
+ label: label.to_owned(),
1219
+ kind: PickerItemKind::Skill,
1220
+ source: AgentKind::Codex,
1221
+ description,
1222
+ insert_text: format!("${name} "),
1223
+ execute_mode: PickerExecuteMode::Insert,
1224
+ });
1225
+ }
1226
+ }
1227
+ }
1228
+
1229
+ if let Some(marketplaces) = value
1230
+ .pointer("/result/marketplaces")
1231
+ .and_then(Value::as_array)
1232
+ {
1233
+ for marketplace in marketplaces {
1234
+ for plugin in marketplace
1235
+ .get("plugins")
1236
+ .and_then(Value::as_array)
1237
+ .into_iter()
1238
+ .flatten()
1239
+ {
1240
+ let Some(name) = plugin.get("name").and_then(Value::as_str) else {
1241
+ continue;
1242
+ };
1243
+ if plugin.get("installed").and_then(Value::as_bool) == Some(false)
1244
+ || plugin.get("enabled").and_then(Value::as_bool) == Some(false)
1245
+ {
1246
+ continue;
1247
+ }
1248
+ let label = plugin
1249
+ .pointer("/interface/displayName")
1250
+ .and_then(Value::as_str)
1251
+ .unwrap_or(name);
1252
+ let description = plugin
1253
+ .pointer("/interface/shortDescription")
1254
+ .or_else(|| plugin.pointer("/interface/longDescription"))
1255
+ .and_then(Value::as_str)
1256
+ .map(short_picker_description);
1257
+ items.push(PickerRegistryItem {
1258
+ id: format!("plugin:{name}"),
1259
+ trigger: PickerTrigger::Dollar,
1260
+ label: label.to_owned(),
1261
+ kind: PickerItemKind::Plugin,
1262
+ source: AgentKind::Codex,
1263
+ description,
1264
+ insert_text: format!("${name} "),
1265
+ execute_mode: PickerExecuteMode::Insert,
1266
+ });
1267
+ }
1268
+ }
1269
+ }
1270
+
1271
+ (!items.is_empty()).then_some(items)
1272
+ }
1273
+
1274
+ async fn update_picker_items(picker_items: &PickerItems, next_items: Vec<PickerRegistryItem>) {
1275
+ let mut items = picker_items.lock().await;
1276
+ for next in next_items {
1277
+ if let Some(existing) = items.iter_mut().find(|item| item.id == next.id) {
1278
+ *existing = next;
1279
+ } else {
1280
+ items.push(next);
1281
+ }
1282
+ }
1283
+ }
1284
+
1285
+ fn short_picker_description(value: &str) -> String {
1286
+ let trimmed = value.trim();
1287
+ if trimmed.chars().count() <= 120 {
1288
+ trimmed.to_owned()
1289
+ } else {
1290
+ format!("{}...", trimmed.chars().take(120).collect::<String>())
1291
+ }
1292
+ }
1293
+
1294
+ fn picker_kind_rank(kind: &PickerItemKind) -> u8 {
1295
+ match kind {
1296
+ PickerItemKind::SlashCommand => 0,
1297
+ PickerItemKind::Skill => 1,
1298
+ PickerItemKind::Plugin => 2,
1299
+ PickerItemKind::Preset => 3,
1300
+ }
1301
+ }
1302
+
1303
+ fn codex_response_to_history_events(
1304
+ value: &Value,
1305
+ workspace: &str,
1306
+ thread_ids: &HashMap<String, String>,
1307
+ ) -> Vec<(String, SessionEvent)> {
1308
+ let Some(id) = value.get("id").and_then(Value::as_u64) else {
1309
+ return Vec::new();
1310
+ };
1311
+ if id < 1_000 {
1312
+ return Vec::new();
1313
+ }
1314
+
1315
+ let mut events = Vec::new();
1316
+ if let Some(items) = value.pointer("/result/data").and_then(Value::as_array) {
1317
+ for thread in items {
1318
+ if let Some(summary) = thread_to_session_summary(thread, workspace) {
1319
+ events.push((
1320
+ summary.session_id.clone(),
1321
+ SessionEvent::SessionStarted { summary },
1322
+ ));
1323
+ }
1324
+ }
1325
+ }
1326
+ if let Some(thread) = value.pointer("/result/thread") {
1327
+ if let Some(mut summary) = thread_to_session_summary(thread, workspace) {
1328
+ if let Some(thread_id) = thread.get("id").and_then(Value::as_str) {
1329
+ if let Some(mapped_session_id) = session_id_for_known_thread(thread_ids, thread_id)
1330
+ {
1331
+ summary.session_id = mapped_session_id;
1332
+ }
1333
+ }
1334
+ let session_id = summary.session_id.clone();
1335
+ events.push((session_id.clone(), SessionEvent::SessionStarted { summary }));
1336
+ for item in thread
1337
+ .get("turns")
1338
+ .and_then(Value::as_array)
1339
+ .into_iter()
1340
+ .flatten()
1341
+ .filter_map(|turn| turn.get("items").and_then(Value::as_array))
1342
+ .flatten()
1343
+ {
1344
+ if let Some(event) = thread_item_to_session_event(item) {
1345
+ events.push((session_id.clone(), event));
1346
+ }
1347
+ }
1348
+ }
1349
+ }
1350
+ events
1351
+ }
1352
+
1353
+ fn codex_threads_in_response(value: &Value) -> Vec<(String, String)> {
1354
+ let mut items = Vec::new();
1355
+ if let Some(threads) = value.pointer("/result/data").and_then(Value::as_array) {
1356
+ for thread in threads {
1357
+ if let Some(thread_id) = thread.get("id").and_then(Value::as_str) {
1358
+ items.push((session_id_for_thread(thread_id), thread_id.to_owned()));
1359
+ }
1360
+ }
1361
+ }
1362
+ if let Some(thread_id) = value.pointer("/result/thread/id").and_then(Value::as_str) {
1363
+ items.push((session_id_for_thread(thread_id), thread_id.to_owned()));
1364
+ }
1365
+ items
1366
+ }
1367
+
1368
+ fn thread_to_session_summary(thread: &Value, workspace_fallback: &str) -> Option<SessionSummary> {
1369
+ let thread_id = thread.get("id").and_then(Value::as_str)?;
1370
+ let workspace = thread
1371
+ .get("cwd")
1372
+ .and_then(Value::as_str)
1373
+ .unwrap_or(workspace_fallback)
1374
+ .to_owned();
1375
+ Some(SessionSummary {
1376
+ session_id: session_id_for_thread(thread_id),
1377
+ agent_kind: AgentKind::Codex,
1378
+ workspace,
1379
+ title: thread
1380
+ .get("name")
1381
+ .and_then(Value::as_str)
1382
+ .or_else(|| thread.get("preview").and_then(Value::as_str))
1383
+ .filter(|title| !title.trim().is_empty())
1384
+ .map(|title| title.trim().chars().take(72).collect()),
1385
+ state: codex_thread_state(thread),
1386
+ pending_approvals: 0,
1387
+ updated_at: unix_seconds_to_offset_datetime(
1388
+ thread
1389
+ .get("updatedAt")
1390
+ .or_else(|| thread.get("updated_at"))
1391
+ .and_then(Value::as_i64),
1392
+ )
1393
+ .unwrap_or_else(OffsetDateTime::now_utc),
1394
+ })
1395
+ }
1396
+
1397
+ fn thread_item_to_session_event(item: &Value) -> Option<SessionEvent> {
1398
+ let kind = item.get("type").and_then(Value::as_str)?;
1399
+ match kind {
1400
+ "userMessage" => {
1401
+ let text = item
1402
+ .get("content")
1403
+ .and_then(Value::as_array)?
1404
+ .iter()
1405
+ .filter_map(|content| {
1406
+ if content.get("type").and_then(Value::as_str) == Some("text") {
1407
+ content.get("text").and_then(Value::as_str)
1408
+ } else {
1409
+ None
1410
+ }
1411
+ })
1412
+ .collect::<Vec<_>>()
1413
+ .join("\n");
1414
+ (!text.trim().is_empty()).then_some(SessionEvent::UserMessage { text })
1415
+ }
1416
+ "agentMessage" => item
1417
+ .get("text")
1418
+ .and_then(Value::as_str)
1419
+ .filter(|text| !text.trim().is_empty())
1420
+ .map(|text| SessionEvent::AgentMessage {
1421
+ text: text.to_owned(),
1422
+ complete: true,
1423
+ }),
1424
+ "commandExecution" => Some(SessionEvent::CommandOutput {
1425
+ command: item
1426
+ .get("command")
1427
+ .and_then(Value::as_str)
1428
+ .unwrap_or("command")
1429
+ .to_owned(),
1430
+ exit_code: item
1431
+ .get("exitCode")
1432
+ .and_then(Value::as_i64)
1433
+ .map(|code| code as i32),
1434
+ summary: item
1435
+ .get("aggregatedOutput")
1436
+ .and_then(Value::as_str)
1437
+ .map(short_summary)
1438
+ .unwrap_or_default(),
1439
+ }),
1440
+ "fileChange" | "mcpToolCall" | "dynamicToolCall" => Some(SessionEvent::ToolFinished {
1441
+ name: kind.to_owned(),
1442
+ ok: true,
1443
+ summary: summarize_item(item),
1444
+ }),
1445
+ _ => None,
1446
+ }
1447
+ }
1448
+
1449
+ fn codex_thread_state(thread: &Value) -> SessionState {
1450
+ let status = thread.pointer("/status/type").and_then(Value::as_str);
1451
+ match status {
1452
+ Some("active") => SessionState::Running,
1453
+ Some("systemError") => SessionState::Failed,
1454
+ Some("idle") | Some("notLoaded") | _ => SessionState::Idle,
1455
+ }
1456
+ }
1457
+
1458
+ fn unix_seconds_to_offset_datetime(seconds: Option<i64>) -> Option<OffsetDateTime> {
1459
+ seconds.and_then(|seconds| OffsetDateTime::from_unix_timestamp(seconds).ok())
1460
+ }
1461
+
1462
+ fn short_summary(text: &str) -> String {
1463
+ let trimmed = text.trim();
1464
+ if trimmed.chars().count() <= 600 {
1465
+ return trimmed.to_owned();
1466
+ }
1467
+ format!("{}...", trimmed.chars().take(600).collect::<String>())
1468
+ }
1469
+
1470
+ async fn publish_recent_codex_threads(
1471
+ relay_write: &Arc<Mutex<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
1472
+ codex_write: &Arc<Mutex<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
1473
+ host_id: &str,
1474
+ fallback_session_id: &str,
1475
+ workspace: &str,
1476
+ seq: &Arc<Mutex<u64>>,
1477
+ ) -> anyhow::Result<()> {
1478
+ let request_id = next_request_id(seq).await;
1479
+ let request = json!({
1480
+ "jsonrpc": "2.0",
1481
+ "id": request_id,
1482
+ "method": "thread/list",
1483
+ "params": {
1484
+ "limit": 8,
1485
+ "cwd": workspace,
1486
+ "sortKey": "updated_at",
1487
+ "sortDirection": "desc",
1488
+ "archived": false
1489
+ }
1490
+ });
1491
+ codex_send(codex_write, &request).await?;
1492
+
1493
+ let fallback_summary = SessionSummary {
1494
+ session_id: fallback_session_id.to_owned(),
1495
+ agent_kind: AgentKind::Codex,
1496
+ workspace: workspace.to_owned(),
1497
+ title: Some("新 Codex 会话".to_owned()),
1498
+ state: SessionState::Idle,
1499
+ pending_approvals: 0,
1500
+ updated_at: OffsetDateTime::now_utc(),
1501
+ };
1502
+ publish_session_event(
1503
+ relay_write,
1504
+ host_id,
1505
+ fallback_session_id,
1506
+ seq,
1507
+ SessionEvent::SessionStarted {
1508
+ summary: fallback_summary,
1509
+ },
1510
+ )
1511
+ .await?;
1512
+ publish_session_event(
1513
+ relay_write,
1514
+ host_id,
1515
+ fallback_session_id,
1516
+ seq,
1517
+ SessionEvent::StateChanged {
1518
+ state: SessionState::Idle,
1519
+ },
1520
+ )
1521
+ .await?;
1522
+ Ok(())
1523
+ }
1524
+
1525
+ async fn wait_for_thread_id(thread_ids: &ThreadMap, session_id: &str) -> anyhow::Result<String> {
1526
+ let deadline = Instant::now() + Duration::from_secs(20);
1527
+ loop {
1528
+ if let Some(thread) = thread_ids.lock().await.get(session_id).cloned() {
1529
+ if !thread.is_empty() {
1530
+ return Ok(thread);
1531
+ }
1532
+ }
1533
+ if Instant::now() > deadline {
1534
+ anyhow::bail!("timed out waiting for codex thread id");
1535
+ }
1536
+ sleep(Duration::from_millis(100)).await;
1537
+ }
1538
+ }
1539
+
1540
+ async fn resume_codex_thread(
1541
+ codex_write: &Arc<Mutex<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
1542
+ workspace: &str,
1543
+ seq: &Arc<Mutex<u64>>,
1544
+ pending_thread_loads: &PendingThreadLoads,
1545
+ session_id: &str,
1546
+ thread_id: &str,
1547
+ include_turns: bool,
1548
+ ) -> anyhow::Result<String> {
1549
+ let request_id = next_request_id(seq).await;
1550
+ pending_thread_loads
1551
+ .lock()
1552
+ .await
1553
+ .insert(request_id, session_id.to_owned());
1554
+ let resume = json!({
1555
+ "jsonrpc": "2.0",
1556
+ "id": request_id,
1557
+ "method": "thread/resume",
1558
+ "params": {
1559
+ "threadId": thread_id,
1560
+ "cwd": workspace,
1561
+ "runtimeWorkspaceRoots": [workspace],
1562
+ "approvalPolicy": "on-request",
1563
+ "sandbox": "workspace-write",
1564
+ "excludeTurns": !include_turns
1565
+ }
1566
+ });
1567
+ codex_send(codex_write, &resume).await?;
1568
+ Ok(thread_id.to_owned())
1569
+ }
1570
+
1571
+ async fn publish_host_status(
1572
+ relay_write: &Arc<Mutex<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
1573
+ host_id: &str,
1574
+ host_name: &str,
1575
+ workspace: &str,
1576
+ active_sessions: u32,
1577
+ ) -> Result<(), WsError> {
1578
+ let mut status = HostStatus::local_codex(host_id, host_name, workspace);
1579
+ status.active_sessions = active_sessions;
1580
+ relay_send(relay_write, &RelayClientMessage::HostStatus { status }).await
1581
+ }
1582
+
1583
+ async fn request_codex_picker_sources(
1584
+ codex_write: &Arc<Mutex<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
1585
+ seq: &Arc<Mutex<u64>>,
1586
+ workspace: &str,
1587
+ ) -> Result<(), WsError> {
1588
+ let skills_id = next_request_id(seq).await;
1589
+ let skills = json!({
1590
+ "jsonrpc": "2.0",
1591
+ "id": skills_id,
1592
+ "method": "skills/list",
1593
+ "params": {
1594
+ "cwds": [workspace],
1595
+ "forceReload": true
1596
+ }
1597
+ });
1598
+ codex_send(codex_write, &skills).await?;
1599
+
1600
+ let plugins_id = next_request_id(seq).await;
1601
+ let plugins = json!({
1602
+ "jsonrpc": "2.0",
1603
+ "id": plugins_id,
1604
+ "method": "plugin/list",
1605
+ "params": {
1606
+ "cwds": [workspace],
1607
+ "marketplaceKinds": ["local", "workspace-directory"]
1608
+ }
1609
+ });
1610
+ codex_send(codex_write, &plugins).await
1611
+ }
1612
+
1613
+ async fn publish_picker_registry(
1614
+ relay_write: &Arc<Mutex<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
1615
+ host_id: &str,
1616
+ session_id: &str,
1617
+ picker_items: &PickerItems,
1618
+ ) -> Result<(), WsError> {
1619
+ let mut items = picker_items.lock().await.clone();
1620
+ items.sort_by(|a, b| {
1621
+ picker_kind_rank(&a.kind)
1622
+ .cmp(&picker_kind_rank(&b.kind))
1623
+ .then_with(|| a.label.to_lowercase().cmp(&b.label.to_lowercase()))
1624
+ });
1625
+ let registry = PickerRegistry {
1626
+ host_id: host_id.to_owned(),
1627
+ session_id: session_id.to_owned(),
1628
+ items,
1629
+ updated_at: OffsetDateTime::now_utc(),
1630
+ };
1631
+ relay_send(
1632
+ relay_write,
1633
+ &RelayClientMessage::PickerRegistry { registry },
1634
+ )
1635
+ .await
1636
+ }
1637
+
1638
+ async fn publish_workspace_snapshot(
1639
+ relay_write: &Arc<Mutex<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
1640
+ host_id: &str,
1641
+ _session_id: &str,
1642
+ workspace: &str,
1643
+ request_id: &str,
1644
+ max_depth: u32,
1645
+ max_entries: u32,
1646
+ ) -> anyhow::Result<()> {
1647
+ let snapshot =
1648
+ build_workspace_snapshot(host_id, workspace, request_id, max_depth, max_entries).await;
1649
+ relay_send(
1650
+ relay_write,
1651
+ &RelayClientMessage::WorkspaceSnapshot { snapshot },
1652
+ )
1653
+ .await?;
1654
+ Ok(())
1655
+ }
1656
+
1657
+ async fn publish_file_preview(
1658
+ relay_write: &Arc<Mutex<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
1659
+ request: FilePreviewRequest,
1660
+ ) -> anyhow::Result<()> {
1661
+ let preview = build_file_preview(&request).await;
1662
+ relay_send(relay_write, &RelayClientMessage::FilePreview { preview }).await?;
1663
+ Ok(())
1664
+ }
1665
+
1666
+ async fn build_file_preview(request: &FilePreviewRequest) -> FilePreview {
1667
+ let workspace_root = canonical_workspace_path(&request.workspace);
1668
+ let requested_path = workspace_root.join(request.path.replace('\\', "/"));
1669
+ let generated_at = OffsetDateTime::now_utc();
1670
+ let name = requested_path
1671
+ .file_name()
1672
+ .and_then(|item| item.to_str())
1673
+ .unwrap_or(&request.path)
1674
+ .to_owned();
1675
+ let language = language_for_path(&request.path);
1676
+
1677
+ let mut preview = FilePreview {
1678
+ request_id: request.request_id.clone(),
1679
+ host_id: request.host_id.clone(),
1680
+ workspace: workspace_root.to_string_lossy().to_string(),
1681
+ path: request.path.clone(),
1682
+ name,
1683
+ language,
1684
+ size_bytes: 0,
1685
+ truncated: false,
1686
+ content: None,
1687
+ generated_at,
1688
+ error: None,
1689
+ };
1690
+
1691
+ let resolved_path = match requested_path.canonicalize() {
1692
+ Ok(path) => path,
1693
+ Err(error) => {
1694
+ preview.error = Some(format!("无法读取文件: {error}"));
1695
+ return preview;
1696
+ }
1697
+ };
1698
+
1699
+ if !resolved_path.starts_with(&workspace_root) {
1700
+ preview.error = Some("文件不在当前 workspace 内,已拒绝预览。".to_owned());
1701
+ return preview;
1702
+ }
1703
+
1704
+ let metadata = match std::fs::metadata(&resolved_path) {
1705
+ Ok(metadata) => metadata,
1706
+ Err(error) => {
1707
+ preview.error = Some(format!("无法读取文件信息: {error}"));
1708
+ return preview;
1709
+ }
1710
+ };
1711
+ preview.size_bytes = metadata.len();
1712
+
1713
+ if metadata.is_dir() {
1714
+ preview.error = Some("这是文件夹,不支持作为文件预览。".to_owned());
1715
+ return preview;
1716
+ }
1717
+
1718
+ let max_bytes = request.max_bytes.clamp(1024, 131_072) as usize;
1719
+ let bytes = match std::fs::read(&resolved_path) {
1720
+ Ok(bytes) => bytes,
1721
+ Err(error) => {
1722
+ preview.error = Some(format!("文件读取失败: {error}"));
1723
+ return preview;
1724
+ }
1725
+ };
1726
+
1727
+ if looks_binary(&bytes) {
1728
+ preview.error = Some("该文件像二进制内容,暂不在手机端预览。".to_owned());
1729
+ return preview;
1730
+ }
1731
+
1732
+ let truncated = bytes.len() > max_bytes;
1733
+ let visible_bytes = if truncated {
1734
+ &bytes[..max_bytes]
1735
+ } else {
1736
+ bytes.as_slice()
1737
+ };
1738
+ preview.content = Some(String::from_utf8_lossy(visible_bytes).to_string());
1739
+ preview.truncated = truncated;
1740
+ preview
1741
+ }
1742
+
1743
+ async fn build_workspace_snapshot(
1744
+ host_id: &str,
1745
+ workspace: &str,
1746
+ request_id: &str,
1747
+ max_depth: u32,
1748
+ max_entries: u32,
1749
+ ) -> WorkspaceSnapshot {
1750
+ let root = canonical_workspace_path(workspace);
1751
+ let workspace_display = root.to_string_lossy().to_string();
1752
+ let root_name = root
1753
+ .file_name()
1754
+ .and_then(|name| name.to_str())
1755
+ .unwrap_or("workspace")
1756
+ .to_owned();
1757
+ let (tree, tree_truncated, tree_error) =
1758
+ collect_project_tree(&root, max_depth.clamp(1, 6), max_entries.clamp(40, 800));
1759
+ let (worktrees, worktree_error) = collect_worktree_summaries(&root).await;
1760
+ let error = [tree_error, worktree_error]
1761
+ .into_iter()
1762
+ .flatten()
1763
+ .collect::<Vec<_>>()
1764
+ .join("; ");
1765
+
1766
+ WorkspaceSnapshot {
1767
+ request_id: request_id.to_owned(),
1768
+ host_id: host_id.to_owned(),
1769
+ workspace: workspace_display,
1770
+ root_name,
1771
+ generated_at: OffsetDateTime::now_utc(),
1772
+ tree,
1773
+ tree_truncated,
1774
+ worktrees,
1775
+ error: (!error.is_empty()).then_some(error),
1776
+ }
1777
+ }
1778
+
1779
+ fn canonical_workspace_path(workspace: &str) -> PathBuf {
1780
+ let input = PathBuf::from(workspace);
1781
+ input.canonicalize().unwrap_or(input)
1782
+ }
1783
+
1784
+ fn looks_binary(bytes: &[u8]) -> bool {
1785
+ bytes.iter().take(4096).any(|byte| *byte == 0)
1786
+ }
1787
+
1788
+ fn language_for_path(path: &str) -> Option<String> {
1789
+ let lower = path.to_lowercase();
1790
+ let extension = Path::new(&lower)
1791
+ .extension()
1792
+ .and_then(|item| item.to_str())?;
1793
+ let language = match extension {
1794
+ "bash" | "sh" | "zsh" => "bash",
1795
+ "css" => "css",
1796
+ "diff" | "patch" => "diff",
1797
+ "html" | "htm" | "xml" => "markup",
1798
+ "js" | "cjs" | "mjs" => "javascript",
1799
+ "json" => "json",
1800
+ "jsx" => "jsx",
1801
+ "md" | "markdown" => "markdown",
1802
+ "ps1" | "psm1" => "powershell",
1803
+ "py" => "python",
1804
+ "rs" => "rust",
1805
+ "ts" => "typescript",
1806
+ "tsx" => "tsx",
1807
+ "yaml" | "yml" => "yaml",
1808
+ _ => extension,
1809
+ };
1810
+ Some(language.to_owned())
1811
+ }
1812
+
1813
+ fn collect_project_tree(
1814
+ root: &Path,
1815
+ max_depth: u32,
1816
+ max_entries: u32,
1817
+ ) -> (Vec<ProjectTreeEntry>, bool, Option<String>) {
1818
+ if !root.exists() {
1819
+ return (
1820
+ Vec::new(),
1821
+ false,
1822
+ Some(format!("workspace path does not exist: {}", root.display())),
1823
+ );
1824
+ }
1825
+
1826
+ let mut entries = Vec::new();
1827
+ let mut truncated = false;
1828
+ let result = collect_project_tree_inner(
1829
+ root,
1830
+ root,
1831
+ 0,
1832
+ max_depth,
1833
+ max_entries as usize,
1834
+ &mut entries,
1835
+ &mut truncated,
1836
+ );
1837
+ (
1838
+ entries,
1839
+ truncated,
1840
+ result.err().map(|error| error.to_string()),
1841
+ )
1842
+ }
1843
+
1844
+ fn collect_project_tree_inner(
1845
+ root: &Path,
1846
+ current: &Path,
1847
+ depth: u32,
1848
+ max_depth: u32,
1849
+ max_entries: usize,
1850
+ entries: &mut Vec<ProjectTreeEntry>,
1851
+ truncated: &mut bool,
1852
+ ) -> anyhow::Result<()> {
1853
+ if entries.len() >= max_entries {
1854
+ *truncated = true;
1855
+ return Ok(());
1856
+ }
1857
+
1858
+ let mut children = Vec::new();
1859
+ for item in std::fs::read_dir(current)? {
1860
+ let item = item?;
1861
+ let file_name = item.file_name().to_string_lossy().to_string();
1862
+ if should_skip_tree_entry(&file_name) {
1863
+ continue;
1864
+ }
1865
+ let file_type = item.file_type()?;
1866
+ children.push((file_name, file_type.is_dir(), item.path()));
1867
+ }
1868
+ children.sort_by(|a, b| {
1869
+ b.1.cmp(&a.1)
1870
+ .then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase()))
1871
+ });
1872
+
1873
+ for (name, is_dir, path) in children {
1874
+ if entries.len() >= max_entries {
1875
+ *truncated = true;
1876
+ break;
1877
+ }
1878
+ let display_path = relative_path(root, &path);
1879
+ entries.push(ProjectTreeEntry {
1880
+ path: display_path,
1881
+ name,
1882
+ kind: if is_dir {
1883
+ ProjectEntryKind::Directory
1884
+ } else {
1885
+ ProjectEntryKind::File
1886
+ },
1887
+ depth,
1888
+ });
1889
+ if is_dir && depth + 1 < max_depth {
1890
+ collect_project_tree_inner(
1891
+ root,
1892
+ &path,
1893
+ depth + 1,
1894
+ max_depth,
1895
+ max_entries,
1896
+ entries,
1897
+ truncated,
1898
+ )?;
1899
+ }
1900
+ }
1901
+
1902
+ Ok(())
1903
+ }
1904
+
1905
+ fn should_skip_tree_entry(name: &str) -> bool {
1906
+ matches!(
1907
+ name,
1908
+ ".git"
1909
+ | ".agents"
1910
+ | ".codex"
1911
+ | ".coding-agent-harness"
1912
+ | ".expo"
1913
+ | ".gradle"
1914
+ | ".harness"
1915
+ | ".idea"
1916
+ | ".next"
1917
+ | ".turbo"
1918
+ | ".venv"
1919
+ | ".vscode"
1920
+ | "build"
1921
+ | "coding-agent-harness"
1922
+ | "dist"
1923
+ | "node_modules"
1924
+ | "target"
1925
+ | "tmp"
1926
+ | "ui"
1927
+ )
1928
+ }
1929
+
1930
+ async fn collect_worktree_summaries(root: &Path) -> (Vec<WorktreeSummary>, Option<String>) {
1931
+ let worktrees = match git_output(root, &["worktree", "list", "--porcelain"]).await {
1932
+ Ok(output) => parse_worktree_list(&output),
1933
+ Err(error) => {
1934
+ let summary = summarize_worktree(root, None, None).await;
1935
+ return (vec![summary], Some(error.to_string()));
1936
+ }
1937
+ };
1938
+ let targets = if worktrees.is_empty() {
1939
+ vec![WorktreeInfo {
1940
+ path: root.to_path_buf(),
1941
+ branch: None,
1942
+ head: None,
1943
+ }]
1944
+ } else {
1945
+ worktrees
1946
+ };
1947
+
1948
+ let mut summaries = Vec::new();
1949
+ for worktree in targets.into_iter().take(12) {
1950
+ summaries.push(summarize_worktree(&worktree.path, worktree.branch, worktree.head).await);
1951
+ }
1952
+ (summaries, None)
1953
+ }
1954
+
1955
+ #[derive(Debug)]
1956
+ struct WorktreeInfo {
1957
+ path: PathBuf,
1958
+ branch: Option<String>,
1959
+ head: Option<String>,
1960
+ }
1961
+
1962
+ fn parse_worktree_list(output: &str) -> Vec<WorktreeInfo> {
1963
+ let mut items = Vec::new();
1964
+ let mut current: Option<WorktreeInfo> = None;
1965
+
1966
+ for line in output.lines() {
1967
+ if let Some(path) = line.strip_prefix("worktree ") {
1968
+ if let Some(item) = current.take() {
1969
+ items.push(item);
1970
+ }
1971
+ current = Some(WorktreeInfo {
1972
+ path: PathBuf::from(path.trim()),
1973
+ branch: None,
1974
+ head: None,
1975
+ });
1976
+ continue;
1977
+ }
1978
+ if let Some(item) = current.as_mut() {
1979
+ if let Some(head) = line.strip_prefix("HEAD ") {
1980
+ item.head = Some(head.trim().to_owned());
1981
+ } else if let Some(branch) = line.strip_prefix("branch ") {
1982
+ item.branch = Some(branch.trim().trim_start_matches("refs/heads/").to_owned());
1983
+ }
1984
+ }
1985
+ }
1986
+ if let Some(item) = current {
1987
+ items.push(item);
1988
+ }
1989
+ items
1990
+ }
1991
+
1992
+ async fn summarize_worktree(
1993
+ path: &Path,
1994
+ branch: Option<String>,
1995
+ head: Option<String>,
1996
+ ) -> WorktreeSummary {
1997
+ let mut files: HashMap<String, DiffFileSummary> = HashMap::new();
1998
+ let mut error_messages = Vec::new();
1999
+
2000
+ for args in [
2001
+ &["diff", "--numstat", "--"][..],
2002
+ &["diff", "--cached", "--numstat", "--"][..],
2003
+ ] {
2004
+ match git_output(path, args).await {
2005
+ Ok(output) => merge_numstat(&mut files, &output),
2006
+ Err(error) => error_messages.push(error.to_string()),
2007
+ }
2008
+ }
2009
+
2010
+ let status_output = match git_output(path, &["status", "--porcelain=v1", "-uall"]).await {
2011
+ Ok(output) => output,
2012
+ Err(error) => {
2013
+ error_messages.push(error.to_string());
2014
+ String::new()
2015
+ }
2016
+ };
2017
+ merge_status_paths(&mut files, &status_output);
2018
+
2019
+ let mut file_list: Vec<DiffFileSummary> = files.into_values().collect();
2020
+ file_list.sort_by(|a, b| a.path.to_lowercase().cmp(&b.path.to_lowercase()));
2021
+ let files_changed = file_list.len() as u32;
2022
+ let additions = file_list.iter().map(|item| item.additions).sum();
2023
+ let deletions = file_list.iter().map(|item| item.deletions).sum();
2024
+ let diff_truncated = file_list.len() > 30;
2025
+ if diff_truncated {
2026
+ file_list.truncate(30);
2027
+ }
2028
+
2029
+ WorktreeSummary {
2030
+ path: path.to_string_lossy().to_string(),
2031
+ branch,
2032
+ head,
2033
+ dirty: files_changed > 0 || !status_output.trim().is_empty(),
2034
+ files_changed,
2035
+ additions,
2036
+ deletions,
2037
+ files: file_list,
2038
+ diff_truncated,
2039
+ error: (!error_messages.is_empty()).then_some(error_messages.join("; ")),
2040
+ }
2041
+ }
2042
+
2043
+ fn merge_numstat(files: &mut HashMap<String, DiffFileSummary>, output: &str) {
2044
+ for line in output.lines() {
2045
+ let mut parts = line.split('\t');
2046
+ let additions = parse_numstat_count(parts.next());
2047
+ let deletions = parse_numstat_count(parts.next());
2048
+ let Some(path) = parts.next().map(clean_git_path) else {
2049
+ continue;
2050
+ };
2051
+ let entry = files
2052
+ .entry(path.clone())
2053
+ .or_insert_with(|| DiffFileSummary {
2054
+ path: path.clone(),
2055
+ additions: 0,
2056
+ deletions: 0,
2057
+ risk: risk_for_path(&path),
2058
+ });
2059
+ entry.additions = entry.additions.saturating_add(additions);
2060
+ entry.deletions = entry.deletions.saturating_add(deletions);
2061
+ }
2062
+ }
2063
+
2064
+ fn parse_numstat_count(value: Option<&str>) -> u32 {
2065
+ value.and_then(|part| part.parse::<u32>().ok()).unwrap_or(0)
2066
+ }
2067
+
2068
+ fn merge_status_paths(files: &mut HashMap<String, DiffFileSummary>, output: &str) {
2069
+ let mut seen = HashSet::new();
2070
+ for line in output.lines() {
2071
+ if line.len() < 4 {
2072
+ continue;
2073
+ }
2074
+ let path = clean_git_path(&line[3..]);
2075
+ if path.is_empty() || !seen.insert(path.clone()) {
2076
+ continue;
2077
+ }
2078
+ files
2079
+ .entry(path.clone())
2080
+ .or_insert_with(|| DiffFileSummary {
2081
+ path: path.clone(),
2082
+ additions: 0,
2083
+ deletions: 0,
2084
+ risk: risk_for_path(&path),
2085
+ });
2086
+ }
2087
+ }
2088
+
2089
+ fn clean_git_path(path: &str) -> String {
2090
+ let cleaned = path
2091
+ .rsplit_once(" -> ")
2092
+ .map(|(_, next)| next)
2093
+ .unwrap_or(path)
2094
+ .trim()
2095
+ .trim_matches('"');
2096
+ cleaned.replace('\\', "/")
2097
+ }
2098
+
2099
+ fn risk_for_path(path: &str) -> RiskLevel {
2100
+ let lower = path.to_lowercase();
2101
+ if lower.contains("secret")
2102
+ || lower.contains("credential")
2103
+ || lower.ends_with(".env")
2104
+ || lower.contains("androidmanifest.xml")
2105
+ {
2106
+ RiskLevel::High
2107
+ } else if lower.ends_with(".lock")
2108
+ || lower.contains("package-lock.json")
2109
+ || lower.contains("cargo.lock")
2110
+ || lower.contains("gradle")
2111
+ {
2112
+ RiskLevel::Medium
2113
+ } else {
2114
+ RiskLevel::Low
2115
+ }
2116
+ }
2117
+
2118
+ async fn git_output(cwd: &Path, args: &[&str]) -> anyhow::Result<String> {
2119
+ let output = timeout(
2120
+ Duration::from_secs(8),
2121
+ Command::new("git")
2122
+ .args(args)
2123
+ .current_dir(cwd)
2124
+ .stdout(Stdio::piped())
2125
+ .stderr(Stdio::piped())
2126
+ .output(),
2127
+ )
2128
+ .await??;
2129
+ if !output.status.success() {
2130
+ let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
2131
+ anyhow::bail!("git {} failed: {}", args.join(" "), stderr);
2132
+ }
2133
+ Ok(String::from_utf8_lossy(&output.stdout).to_string())
2134
+ }
2135
+
2136
+ fn relative_path(root: &Path, path: &Path) -> String {
2137
+ path.strip_prefix(root)
2138
+ .unwrap_or(path)
2139
+ .to_string_lossy()
2140
+ .replace('\\', "/")
2141
+ }
2142
+
2143
+ async fn publish_session_event(
2144
+ relay_write: &Arc<Mutex<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
2145
+ host_id: &str,
2146
+ session_id: &str,
2147
+ seq: &Arc<Mutex<u64>>,
2148
+ payload: SessionEvent,
2149
+ ) -> Result<(), WsError> {
2150
+ let next = next_seq(seq).await;
2151
+ let envelope = AgentPalEnvelope::new(host_id, Some(session_id.to_owned()), next, payload);
2152
+ relay_send(relay_write, &RelayClientMessage::SessionEvent { envelope }).await
2153
+ }
2154
+
2155
+ async fn relay_send(
2156
+ relay_write: &Arc<Mutex<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
2157
+ payload: &RelayClientMessage,
2158
+ ) -> Result<(), WsError> {
2159
+ let text = serde_json::to_string(payload).expect("relay message serializes");
2160
+ relay_write
2161
+ .lock()
2162
+ .await
2163
+ .send(Message::Text(text.into()))
2164
+ .await
2165
+ }
2166
+
2167
+ async fn codex_send(
2168
+ codex_write: &Arc<Mutex<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
2169
+ payload: &Value,
2170
+ ) -> Result<(), WsError> {
2171
+ codex_write
2172
+ .lock()
2173
+ .await
2174
+ .send(Message::Text(payload.to_string().into()))
2175
+ .await
2176
+ }
2177
+
2178
+ async fn send_codex_initialize(
2179
+ codex_write: &Arc<Mutex<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
2180
+ ) -> Result<(), WsError> {
2181
+ let initialize = json!({
2182
+ "jsonrpc": "2.0",
2183
+ "id": 1,
2184
+ "method": "initialize",
2185
+ "params": {
2186
+ "clientInfo": {
2187
+ "name": "agentpal-host",
2188
+ "title": "AgentPal Host",
2189
+ "version": env!("CARGO_PKG_VERSION")
2190
+ },
2191
+ "capabilities": {
2192
+ "experimentalApi": true,
2193
+ "requestAttestation": false
2194
+ }
2195
+ }
2196
+ });
2197
+ codex_send(codex_write, &initialize).await
2198
+ }
2199
+
2200
+ async fn next_seq(seq: &Arc<Mutex<u64>>) -> u64 {
2201
+ let mut seq = seq.lock().await;
2202
+ *seq += 1;
2203
+ *seq
2204
+ }
2205
+
2206
+ async fn next_request_id(seq: &Arc<Mutex<u64>>) -> u64 {
2207
+ 1_000 + next_seq(seq).await
2208
+ }
2209
+
2210
+ async fn stop_child(child: &mut Child) {
2211
+ let _ = child.start_kill();
2212
+ let _ = timeout(Duration::from_secs(2), child.wait()).await;
2213
+ }
2214
+
2215
+ fn default_host_name() -> String {
2216
+ env::var("COMPUTERNAME")
2217
+ .or_else(|_| env::var("HOSTNAME"))
2218
+ .unwrap_or_else(|_| "AgentPal Host".to_owned())
2219
+ }
2220
+
2221
+ fn default_host_id() -> String {
2222
+ format!("agentpal-{}", Uuid::new_v4())
2223
+ }
2224
+
2225
+ async fn codex_version(codex_bin: &PathBuf) -> anyhow::Result<String> {
2226
+ let output = Command::new(codex_bin).arg("--version").output().await?;
2227
+ let text = if output.stdout.is_empty() {
2228
+ String::from_utf8_lossy(&output.stderr).trim().to_owned()
2229
+ } else {
2230
+ String::from_utf8_lossy(&output.stdout).trim().to_owned()
2231
+ };
2232
+ Ok(text)
2233
+ }
2234
+
2235
+ fn resolve_command(command: &str) -> anyhow::Result<PathBuf> {
2236
+ let input = PathBuf::from(command);
2237
+ if input.components().count() > 1 || input.is_absolute() {
2238
+ if input.exists() {
2239
+ return Ok(input);
2240
+ }
2241
+ anyhow::bail!("command path does not exist: {}", input.display());
2242
+ }
2243
+
2244
+ let path_var = env::var_os("PATH").ok_or_else(|| anyhow::anyhow!("PATH is not set"))?;
2245
+ let mut extensions = vec![String::new()];
2246
+ if cfg!(windows) {
2247
+ let pathext = env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_owned());
2248
+ extensions = pathext
2249
+ .split(';')
2250
+ .filter(|part| !part.is_empty())
2251
+ .map(|part| part.to_ascii_lowercase())
2252
+ .collect();
2253
+ if PathBuf::from(command).extension().is_some() {
2254
+ extensions = vec![String::new()];
2255
+ } else {
2256
+ extensions.push(String::new());
2257
+ }
2258
+ }
2259
+
2260
+ for dir in env::split_paths(&path_var) {
2261
+ for ext in &extensions {
2262
+ let candidate = dir.join(format!("{command}{ext}"));
2263
+ if candidate.is_file() {
2264
+ return Ok(candidate);
2265
+ }
2266
+ }
2267
+ }
2268
+
2269
+ anyhow::bail!("program not found on PATH: {command}");
2270
+ }
2271
+
2272
+ fn websocket_url(host: &str, port: u16, path: &str) -> String {
2273
+ let path = if path.starts_with('/') {
2274
+ path.to_owned()
2275
+ } else {
2276
+ format!("/{path}")
2277
+ };
2278
+ format!("ws://{host}:{port}{path}")
2279
+ }
2280
+
2281
+ fn normalize_ws_url(input: &str) -> String {
2282
+ let mut value = input.trim().to_owned();
2283
+ if !value.starts_with("ws://") && !value.starts_with("wss://") {
2284
+ value = format!("ws://{value}");
2285
+ }
2286
+ if !value.ends_with("/ws") && !value.ends_with("/ws/") {
2287
+ value = format!("{}/ws", value.trim_end_matches('/'));
2288
+ }
2289
+ value
2290
+ }
2291
+
2292
+ fn default_lan_relay_url(port: u16, path: &str) -> anyhow::Result<String> {
2293
+ let ip = local_ip()?;
2294
+ Ok(websocket_url(&ip.to_string(), port, path))
2295
+ }
2296
+
2297
+ fn print_pairing_payload(payload: &PairingPayload, no_qr: bool) -> anyhow::Result<()> {
2298
+ let pair_url = pair_url(payload);
2299
+
2300
+ println!("AgentPal pairing address:");
2301
+ println!("{pair_url}");
2302
+ println!();
2303
+ println!("Manual fields:");
2304
+ println!(" relay_url: {}", payload.relay_url);
2305
+ if let Some(pair_id) = &payload.pair_id {
2306
+ println!(" pair_id: {pair_id}");
2307
+ }
2308
+ println!(" host_id: {}", payload.host_id);
2309
+ println!(" host_name: {}", payload.host_name);
2310
+ println!(" pair_token: {}", payload.pair_token);
2311
+ if let Some(device_id) = &payload.device_id {
2312
+ println!(" device_id: {device_id}");
2313
+ }
2314
+ if payload.device_token.is_some() {
2315
+ println!(" device_token: issued");
2316
+ }
2317
+ if let Some(expires_at) = payload.expires_at {
2318
+ println!(" expires_at: {expires_at}");
2319
+ } else {
2320
+ println!(" expires_at: never");
2321
+ }
2322
+
2323
+ if !no_qr {
2324
+ let code = QrCode::new(pair_url.as_bytes())?;
2325
+ let qr = code
2326
+ .render::<unicode::Dense1x2>()
2327
+ .quiet_zone(true)
2328
+ .module_dimensions(2, 1)
2329
+ .build();
2330
+ println!();
2331
+ println!("{qr}");
2332
+ }
2333
+
2334
+ Ok(())
2335
+ }
2336
+
2337
+ fn pair_url(payload: &PairingPayload) -> String {
2338
+ let mut url = url::Url::parse("agentpal://pair").expect("static pair url parses");
2339
+ {
2340
+ let mut query = url.query_pairs_mut();
2341
+ query.append_pair("v", &payload.version.to_string());
2342
+ query.append_pair("relayUrl", &payload.relay_url);
2343
+ if let Some(pair_id) = &payload.pair_id {
2344
+ query.append_pair("pairId", pair_id);
2345
+ }
2346
+ query.append_pair("hostId", &payload.host_id);
2347
+ query.append_pair("hostName", &payload.host_name);
2348
+ query.append_pair("pairToken", &payload.pair_token);
2349
+ if let Some(device_id) = &payload.device_id {
2350
+ query.append_pair("deviceId", device_id);
2351
+ }
2352
+ if let Some(device_token) = &payload.device_token {
2353
+ query.append_pair("deviceToken", device_token);
2354
+ }
2355
+ if let Some(expires_at) = payload.expires_at {
2356
+ query.append_pair(
2357
+ "expiresAt",
2358
+ &expires_at
2359
+ .format(&Rfc3339)
2360
+ .unwrap_or_else(|_| expires_at.to_string()),
2361
+ );
2362
+ }
2363
+ }
2364
+ url.to_string()
2365
+ }
2366
+
2367
+ fn session_id_for_thread(thread_id: &str) -> String {
2368
+ format!("codex-{thread_id}")
2369
+ }
2370
+
2371
+ fn thread_id_from_session_id(session_id: &str) -> Option<String> {
2372
+ session_id
2373
+ .strip_prefix("codex-")
2374
+ .filter(|thread_id| !thread_id.trim().is_empty())
2375
+ .map(str::to_owned)
2376
+ }
2377
+
2378
+ fn session_id_from_codex_event(
2379
+ value: &Value,
2380
+ thread_ids: &HashMap<String, String>,
2381
+ ) -> Option<String> {
2382
+ value
2383
+ .pointer("/params/threadId")
2384
+ .or_else(|| value.pointer("/params/thread/id"))
2385
+ .or_else(|| value.pointer("/params/turn/threadId"))
2386
+ .and_then(Value::as_str)
2387
+ .map(|thread_id| {
2388
+ session_id_for_known_thread(thread_ids, thread_id)
2389
+ .unwrap_or_else(|| session_id_for_thread(thread_id))
2390
+ })
2391
+ }
2392
+
2393
+ fn session_id_for_known_thread(
2394
+ thread_ids: &HashMap<String, String>,
2395
+ thread_id: &str,
2396
+ ) -> Option<String> {
2397
+ thread_ids
2398
+ .iter()
2399
+ .find_map(|(session_id, known_thread_id)| {
2400
+ if known_thread_id == thread_id && !session_id.starts_with("codex-") {
2401
+ Some(session_id.clone())
2402
+ } else {
2403
+ None
2404
+ }
2405
+ })
2406
+ .or_else(|| {
2407
+ thread_ids.iter().find_map(|(session_id, known_thread_id)| {
2408
+ if known_thread_id == thread_id {
2409
+ Some(session_id.clone())
2410
+ } else {
2411
+ None
2412
+ }
2413
+ })
2414
+ })
2415
+ }
2416
+
2417
+ async fn read_until_response<S>(
2418
+ socket: &mut tokio_tungstenite::WebSocketStream<S>,
2419
+ id: u64,
2420
+ events: &mut Vec<Value>,
2421
+ ) -> anyhow::Result<Value>
2422
+ where
2423
+ S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
2424
+ {
2425
+ while let Some(message) = socket.next().await {
2426
+ let message = message?;
2427
+ match message {
2428
+ Message::Text(text) => {
2429
+ let value: Value = serde_json::from_str(&text)?;
2430
+ if value.get("id").and_then(Value::as_u64) == Some(id) {
2431
+ return Ok(value);
2432
+ }
2433
+ events.push(value);
2434
+ }
2435
+ Message::Binary(bytes) => {
2436
+ events.push(json!({
2437
+ "type": "agentpal.binary-message",
2438
+ "bytes": bytes.len()
2439
+ }));
2440
+ }
2441
+ Message::Close(frame) => {
2442
+ anyhow::bail!("websocket closed before response {id}: {frame:?}");
2443
+ }
2444
+ Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => {}
2445
+ }
2446
+ }
2447
+ anyhow::bail!("websocket ended before response {id}");
2448
+ }
2449
+
2450
+ fn extract_thread_id(response: &Value) -> Option<String> {
2451
+ response
2452
+ .pointer("/result/thread/id")
2453
+ .or_else(|| response.pointer("/result/thread/threadId"))
2454
+ .or_else(|| response.pointer("/result/id"))
2455
+ .and_then(Value::as_str)
2456
+ .map(ToOwned::to_owned)
2457
+ }
2458
+
2459
+ fn extract_thread_id_from_events(events: &[Value]) -> Option<String> {
2460
+ events.iter().find_map(|event| {
2461
+ if event.get("method").and_then(Value::as_str) != Some("thread/started") {
2462
+ return None;
2463
+ }
2464
+ event
2465
+ .pointer("/params/thread/id")
2466
+ .or_else(|| event.pointer("/params/thread/threadId"))
2467
+ .and_then(Value::as_str)
2468
+ .map(ToOwned::to_owned)
2469
+ })
2470
+ }
2471
+
2472
+ fn fail<E>(
2473
+ mut report: CodexProbeReport,
2474
+ started: Instant,
2475
+ phase: ProbePhase,
2476
+ error: E,
2477
+ ) -> CodexProbeReport
2478
+ where
2479
+ E: std::fmt::Display,
2480
+ {
2481
+ report.ok = false;
2482
+ report.phase = phase;
2483
+ report.elapsed_ms = started.elapsed().as_millis();
2484
+ report.error = Some(error.to_string());
2485
+ report
2486
+ }