1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
//! `astrid who` — list connected clients with per-agent attribution.
//!
//! The daemon's `KernelRequest::GetStatus` exposes both a total
//! `connected_clients` count and a `connections_by_principal`
//! breakdown sourced from `Kernel::active_connections` (incremented
//! on socket handshake, decremented on disconnect, keyed by the
//! claimed principal). One row is emitted per `(principal,
//! connection)` pair so the operator sees a real per-agent picture
//! instead of a fabricated `default`-only roster.
//!
//! Platform attribution (CLI vs Discord vs Telegram) still needs the
//! `admin.agent.link` IPC tracked in #657. Until that lands, the
//! `PLATFORM` column reads `cli` for every row — the only uplink
//! shipping today — and the info-line footer points at the tracking
//! issue so operators know it's a placeholder.
use std::process::ExitCode;
use anyhow::Result;
use clap::Args;
use colored::Colorize;
use serde::Serialize;
use uuid::Uuid;
use crate::commands::daemon;
use crate::socket_client::SocketClient;
use crate::theme::Theme;
use crate::value_formatter::{ValueFormat, emit_structured};
#[derive(Args, Debug, Clone)]
pub(crate) struct WhoArgs {
/// Output format.
#[arg(long, default_value = "pretty")]
pub format: String,
}
/// JSON/YAML/TOML emission shape.
#[derive(Debug, Clone, Serialize)]
pub(crate) struct Connection {
/// Agent principal.
pub agent: String,
/// Platform descriptor — `cli`, `discord`, `telegram` once the
/// link IPC ships; `unknown` until then.
pub platform: String,
}
/// Entry point for `astrid who`.
pub(crate) async fn run(args: WhoArgs) -> Result<ExitCode> {
let format = ValueFormat::parse(&args.format);
let socket_path = crate::socket_client::proxy_socket_path();
if !socket_path.exists() {
if format.is_pretty() {
println!("{}", Theme::info("No Astrid daemon is running."));
} else {
emit_structured(&Vec::<Connection>::new(), format)?;
}
return Ok(ExitCode::SUCCESS);
}
let session = astrid_core::SessionId::from_uuid(Uuid::new_v4());
let Ok(mut client) = SocketClient::connect(session).await else {
eprintln!("{}", Theme::error("Failed to connect to daemon"));
return Ok(ExitCode::from(1));
};
let req = astrid_core::kernel_api::KernelRequest::GetStatus;
let val = serde_json::to_value(req)?;
let msg = astrid_types::ipc::IpcMessage::new(
"astrid.v1.request.status",
astrid_types::ipc::IpcPayload::RawJson(val),
Uuid::nil(),
);
client.send_message(msg).await?;
let raw = client
.read_until_topic(
"astrid.v1.response.status",
std::time::Duration::from_secs(10),
)
.await?;
// Reuse the shared envelope extractor so this command tracks any
// future change to the IPC response wrapper without re-implementing
// the `{type, value}` unwrap inline (matches `ps` / `daemon` usage).
let status = match crate::socket_client::SocketClient::extract_kernel_response(&raw) {
Some(astrid_core::kernel_api::KernelResponse::Status(s)) => Some(s),
_ => None,
};
let connections: Vec<Connection> = match status {
Some(s) if !s.connections_by_principal.is_empty() => s
.connections_by_principal
.iter()
.flat_map(|pc| {
(0..pc.count).map(move |_| Connection {
agent: pc.principal.clone(),
platform: "cli".into(),
})
})
.collect(),
// Daemon doesn't expose per-principal yet (older build) — fall
// back to the bare count attributed to `default`. Matches the
// pre-#22 behaviour so a CLI/daemon version skew degrades
// gracefully instead of returning an empty roster.
Some(s) => {
let principal = astrid_core::PrincipalId::default();
(0..s.connected_clients)
.map(|_| Connection {
agent: principal.to_string(),
platform: "cli".into(),
})
.collect()
},
None => Vec::new(),
};
if !format.is_pretty() {
emit_structured(&connections, format)?;
return Ok(ExitCode::SUCCESS);
}
if connections.is_empty() {
println!("{}", Theme::info("No clients connected."));
return Ok(ExitCode::SUCCESS);
}
println!(
"{:<24} {:<12} {}",
"AGENT".bold(),
"PLATFORM".bold(),
"STATE".bold()
);
for c in &connections {
println!("{:<24} {:<12} {}", c.agent, c.platform, "active".green());
}
println!(
"\n{}",
Theme::info(
"Per-client identity attribution (idle time, platform user) needs `admin.agent.link` IPC — tracking #657."
)
);
// Use daemon helper to avoid unused warning until we add idle-time
// attribution.
let _ = daemon::format_uptime;
Ok(ExitCode::SUCCESS)
}