digital-roster 0.3.0

Rent the intelligence, own the governance — a control plane for workers: software colleagues whose every action passes through a gateway you control (default-deny egress, injected credentials, budgets, approval gates, audit).
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
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
//! `roster worker chat <name>` — an interactive warm session: run one rpc box
//! and feed it messages from stdin, one per line. For working with (and
//! testing) the multi-message session without a channel in front.

use crate::run::boxed;
use crate::util::BErr;
use rustyline::ExternalPrinter as _;

pub async fn chat(worker: &str, idle: u64) -> Result<(), BErr> {
    crate::worker::require_worker(worker)?;

    let (tx, rx) = tokio::sync::mpsc::channel::<boxed::SessionMessage>(32);
    let reader = tokio::spawn(async move {
        use tokio::io::AsyncBufReadExt;
        let mut lines = tokio::io::BufReader::new(tokio::io::stdin()).lines();
        while let Ok(Some(l)) = lines.next_line().await {
            if l.trim().is_empty() {
                continue;
            }
            let msg = boxed::SessionMessage {
                text: l,
                author_label: "stdin".into(),
                context: crate::worker::memory::RunContext::default(),
            };
            if tx.send(msg).await.is_err() {
                break;
            }
        }
        // stdin EOF → drop tx → the session drains and idles out.
    });

    let run_id = boxed::new_run_id();
    boxed::run_session(
        worker,
        &run_id,
        crate::worker::context::RunSurface::DirectBox,
        crate::worker::memory::RunContext::default(),
        rx,
        idle,
        None,
    )
    .await?;
    reader.abort();
    println!(
        "session ended — transcript: {}",
        crate::paths::run_dir(&run_id)
            .join("stdout.jsonl")
            .display()
    );
    Ok(())
}

/// Talk needs the daemon (gateway, dispatch). When it isn't running, offer to
/// start it — always asking first, never silently — then wait for the port to
/// answer. Non-interactive callers just get the hint.
async fn ensure_server(interactive: bool) -> Result<(), BErr> {
    if crate::cli::server::gateway_up().await {
        return Ok(());
    }
    if !interactive {
        return Err("the server isn't running — start it: roster server start".into());
    }
    eprint!("the server isn't running — start it now? [y/N] ");
    use std::io::Write;
    let _ = std::io::stderr().flush();
    let mut answer = String::new();
    std::io::stdin().read_line(&mut answer)?;
    if !matches!(answer.trim(), "y" | "Y" | "yes") {
        return Err("talk needs the server — start it: roster server start".into());
    }

    let log_path = crate::paths::state_root().join("server.log");
    std::fs::create_dir_all(crate::paths::state_root())?;
    let log = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&log_path)?;
    // Its own process group: Ctrl-C in talk must not take the daemon down.
    use std::os::unix::process::CommandExt;
    std::process::Command::new(std::env::current_exe()?)
        .args(["server", "start"])
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::from(log.try_clone()?))
        .stderr(std::process::Stdio::from(log))
        .process_group(0)
        .spawn()?;
    eprintln!("starting server (log: {}) …", log_path.display());
    for _ in 0..40 {
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
        if crate::cli::server::gateway_up().await {
            return Ok(());
        }
    }
    Err(format!("the server did not come up — check {}", log_path.display()).into())
}

/// Readline glue for slash-command tab completion: the grammar and live ids
/// come from channel::slash::complete; a unique match gets a trailing space
/// so the operator can keep typing. Everything else (hints, highlighting,
/// validation) stays default.
struct SlashHelper {
    worker: String,
}

impl rustyline::completion::Completer for SlashHelper {
    type Candidate = rustyline::completion::Pair;
    fn complete(
        &self,
        line: &str,
        pos: usize,
        _ctx: &rustyline::Context<'_>,
    ) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
        let (from, candidates) = crate::channel::slash::complete(line, pos, &self.worker);
        let space = if candidates.len() == 1 { " " } else { "" };
        Ok((
            from,
            candidates
                .into_iter()
                .map(|c| rustyline::completion::Pair {
                    replacement: format!("{c}{space}"),
                    display: c,
                })
                .collect(),
        ))
    }
}
impl rustyline::hint::Hinter for SlashHelper {
    type Hint = String;
}
impl rustyline::highlight::Highlighter for SlashHelper {}
impl rustyline::validate::Validator for SlashHelper {}
impl rustyline::Helper for SlashHelper {}

/// The terminal line discipline, snapshotted before the editor thread starts:
/// that thread may sit inside readline's raw mode when the session ends
/// elsewhere (idle timeout), and exiting through it would leave the shell raw.
fn stdin_termios() -> Option<libc::termios> {
    unsafe {
        let mut t: libc::termios = std::mem::zeroed();
        (libc::tcgetattr(libc::STDIN_FILENO, &mut t) == 0).then_some(t)
    }
}

/// Drain a channel of display text into a writer — the writer differs by
/// surface (readline's above-prompt printer, or plain stdout when piped).
fn spawn_sink<F: FnMut(String) + Send + 'static>(
    mut rx: tokio::sync::mpsc::Receiver<String>,
    mut write: F,
) -> tokio::task::JoinHandle<()> {
    tokio::spawn(async move {
        while let Some(text) = rx.recv().await {
            write(text);
        }
    })
}

/// One warm session behind the terminal conversation — the Discord model: a
/// message wakes a session, quiet winds it down, and the next message wakes
/// another. The watcher note tells the operator the session ended without
/// ending the conversation.
fn spawn_session(
    worker: &str,
    idle: u64,
    context: crate::worker::memory::RunContext,
    reply_tx: tokio::sync::mpsc::Sender<String>,
    info_tx: tokio::sync::mpsc::Sender<String>,
) -> (
    tokio::sync::mpsc::Sender<boxed::SessionMessage>,
    tokio::task::JoinHandle<()>,
) {
    let (tx, rx) = tokio::sync::mpsc::channel::<boxed::SessionMessage>(32);
    let run_id = boxed::new_run_id();
    let worker = worker.to_string();
    let handle = tokio::spawn(async move {
        let note = match boxed::run_session(
            &worker,
            &run_id,
            crate::worker::context::RunSurface::TermSession,
            context,
            rx,
            idle,
            Some(reply_tx),
        )
        .await
        {
            Ok(()) => format!("(session {run_id} wound down — the conversation stays open)"),
            Err(e) => format!("(session {run_id} failed: {e})"),
        };
        let _ = info_tx.send(note).await;
    });
    (tx, handle)
}

/// `roster talk <worker>` — the terminal as a first-class channel. One
/// long-running conversation with the Discord interaction model behind it:
/// a durable channel id, recorded history, a purpose, channel/user memory
/// scopes, DM-grade trust (it is the operator's own shell), and warm
/// sessions that wake on a message and wind down when quiet — without ever
/// closing the terminal. The conversation scrolls above a readline prompt
/// pinned at the bottom (arrow keys, history); replies print above whatever
/// the operator is mid-typing. `/…` lines are the same slash commands as
/// the chat channels (channel::slash), answered by the host, never sent to
/// the model.
pub async fn talk(worker: &str, idle: u64) -> Result<(), BErr> {
    crate::worker::require_worker(worker)?;
    let interactive = unsafe { libc::isatty(libc::STDIN_FILENO) } == 1;
    ensure_server(interactive).await?;
    let user = std::env::var("USER").unwrap_or_else(|_| "operator".into());
    let channel_id = format!("term-{user}-{worker}");
    // The operator's own shell is trusted like a DM (1:1, sought-out).
    if let Err(e) = crate::channel::discord::set_channel_trust(&channel_id, true) {
        eprintln!("talk: could not mark {channel_id} trusted: {e}");
    }

    let context = crate::worker::memory::RunContext {
        provider: "term".into(),
        channel_id: Some(channel_id.clone()),
        user_id: Some(user.clone()),
        message_id: None,
        thread_ts: None,
        role: "host-op".into(),
        is_dm: true,
        inbound: false,
    };

    let (reply_tx, reply_rx) = tokio::sync::mpsc::channel::<String>(32);

    eprintln!(
        "talking to {worker} — terminal channel {channel_id} (trusted); quiet sessions wind down and wake on your next message; /help for commands, Ctrl-D leaves"
    );

    // Task runs deliver results to this channel while nobody is watching
    // (term_send). Surface whatever arrived since the operator's last turn.
    let history = crate::channel::discord::recent_messages(&channel_id, 200);
    let last_human = history
        .iter()
        .rposition(|m| m.get("role").and_then(|v| v.as_str()) == Some("host-op"));
    let missed: Vec<&serde_json::Value> = history
        .iter()
        .skip(last_human.map(|i| i + 1).unwrap_or(0))
        .filter(|m| m.get("role").and_then(|v| v.as_str()) == Some("worker"))
        .collect();
    if !missed.is_empty() {
        println!("while you were away:");
        for m in missed {
            println!(
                "{worker}> {}\n",
                m.get("content").and_then(|v| v.as_str()).unwrap_or("")
            );
        }
    }

    let saved_termios = stdin_termios();
    let (line_tx, mut line_rx) = tokio::sync::mpsc::channel::<String>(32);
    let (info_tx, info_rx) = tokio::sync::mpsc::channel::<String>(32);

    let (reply_sink, info_sink) = if interactive {
        let mut rl =
            rustyline::Editor::<SlashHelper, rustyline::history::DefaultHistory>::with_config(
                rustyline::Config::builder()
                    .completion_type(rustyline::CompletionType::List)
                    .build(),
            )?;
        rl.set_helper(Some(SlashHelper {
            worker: worker.to_string(),
        }));
        let _ = rl.load_history(&crate::paths::talk_history_file());
        let mut reply_printer = rl.create_external_printer()?;
        let mut info_printer = rl.create_external_printer()?;

        // The editor owns stdin on its own thread (readline blocks);
        // submitted lines flow to the async handler below. Ctrl-D/Ctrl-C
        // drop line_tx, which drains the handler, drops tx, and lets the
        // session end. Not joined: it may still be blocked in readline
        // when the session ends elsewhere.
        std::thread::spawn(move || {
            while let Ok(line) = rl.readline("you> ") {
                if line.trim().is_empty() {
                    continue;
                }
                let _ = rl.add_history_entry(line.as_str());
                if line_tx.blocking_send(line).is_err() {
                    break;
                }
            }
            let _ = rl.save_history(&crate::paths::talk_history_file());
        });

        // Output inserts above the prompt; readline redraws half-typed input.
        let worker_name = worker.to_string();
        (
            spawn_sink(reply_rx, move |text| {
                let _ = reply_printer.print(format!("{worker_name}> {text}\n"));
            }),
            spawn_sink(info_rx, move |text| {
                let _ = info_printer.print(format!("{text}\n"));
            }),
        )
    } else {
        // Piped stdin (scripts): plain line IO, no editor, no prompt.
        tokio::spawn(async move {
            use tokio::io::AsyncBufReadExt;
            let mut lines = tokio::io::BufReader::new(tokio::io::stdin()).lines();
            while let Ok(Some(l)) = lines.next_line().await {
                if l.trim().is_empty() {
                    continue;
                }
                if line_tx.send(l).await.is_err() {
                    break;
                }
            }
        });
        let worker_name = worker.to_string();
        (
            spawn_sink(reply_rx, move |text| println!("{worker_name}> {text}")),
            spawn_sink(info_rx, |text| println!("{text}")),
        )
    };

    // Live deliveries: task runs term_send into this channel's history while
    // the conversation is open. Tail the file so results surface as they
    // land, not just at the next session open. (Worker-role entries come
    // only from term_send — live session replies are never persisted — so
    // nothing prints twice.)
    let delivery_watch = {
        let channel_id = channel_id.clone();
        let reply_tx = reply_tx.clone();
        tokio::spawn(async move {
            let path = crate::paths::channel_dir(&channel_id).join("messages.jsonl");
            let mut seen = std::fs::read_to_string(&path)
                .map(|s| s.lines().count())
                .unwrap_or(0);
            loop {
                tokio::time::sleep(std::time::Duration::from_secs(2)).await;
                let Ok(text) = std::fs::read_to_string(&path) else {
                    continue;
                };
                let lines: Vec<&str> = text.lines().collect();
                if lines.len() < seen {
                    seen = lines.len(); // rotated/truncated: adopt
                    continue;
                }
                for l in &lines[seen..] {
                    if let Ok(v) = serde_json::from_str::<serde_json::Value>(l) {
                        if v.get("role").and_then(|r| r.as_str()) == Some("worker") {
                            if let Some(content) = v.get("content").and_then(|c| c.as_str()) {
                                let _ = reply_tx.send(content.to_string()).await;
                            }
                        }
                    }
                }
                seen = lines.len();
            }
        })
    };

    // One conversation, many sessions. A message routes to the live session
    // if there is one; otherwise it wakes a fresh one — exactly how the
    // Discord listener treats a channel. Session death never ends this loop;
    // only the operator leaving (Ctrl-D) does.
    let mut live: Option<(
        tokio::sync::mpsc::Sender<boxed::SessionMessage>,
        tokio::task::JoinHandle<()>,
    )> = None;

    while let Some(l) = line_rx.recv().await {
        // Slash commands are operator↔host — answered here, never
        // persisted, never sent to the model.
        if l.trim_start().starts_with('/') {
            let reply = match crate::channel::slash::parse(l.trim()) {
                Ok(call) => {
                    crate::channel::slash::run(
                        worker,
                        &call,
                        &channel_id,
                        &context,
                        "host-op",
                        &format!("term:{user}"),
                    )
                    .await
                }
                Err(e) => e,
            };
            let _ = info_tx.send(reply).await;
            continue;
        }
        // Only the human side is persisted, like the other channels
        // (continuity inside a session comes from the session itself).
        crate::channel::discord::persist_message(
            &channel_id,
            &serde_json::json!({
                "ts": crate::util::now_rfc3339(),
                "author_id": user, "author": user, "role": "host-op",
                "content": l, "attachments": [],
            }),
        );
        let mut msg = Some(boxed::SessionMessage {
            text: l,
            author_label: user.clone(),
            context: context.clone(),
        });
        if let Some((tx, _)) = live.as_ref() {
            if let Err(back) = tx.send(msg.take().unwrap()).await {
                // The session wound down (its watcher said so); the message
                // comes back out of the failed send and wakes the next one.
                msg = Some(back.0);
                live = None;
            }
        }
        if let Some(m) = msg {
            let _ = info_tx.send(format!("(waking {worker}…)")).await;
            let (tx, handle) = spawn_session(
                worker,
                idle,
                context.clone(),
                reply_tx.clone(),
                info_tx.clone(),
            );
            if tx.send(m).await.is_err() {
                let _ = info_tx
                    .send("(message not delivered — the session failed to start; try again)".into())
                    .await;
            }
            live = Some((tx, handle));
        }
    }

    // Ctrl-D / EOF: let a live session finish its turns and wind down.
    if let Some((tx, handle)) = live.take() {
        drop(tx);
        let _ = handle.await;
    }
    delivery_watch.abort();
    drop(reply_tx);
    drop(info_tx);
    let _ = reply_sink.await;
    let _ = info_sink.await;
    // The editor thread may still hold readline's raw mode; hand the shell
    // back its line discipline before leaving through it.
    if let Some(t) = saved_termios {
        unsafe {
            libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &t);
        }
    }
    eprintln!("\nleft the conversation — resume: roster talk {worker}");
    Ok(())
}