csift 0.12.0

ripgrep for Claude Code session transcripts: fast regex list/search over ~/.claude/projects/**/*.jsonl
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
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
//! The two endpoints of a send: the caller csift is running as, and the receiver lane it
//! resolved.
//!
//! CALLER. Claude Code exports `CLAUDE_CODE_SESSION_ID` into every Bash child, in every lane,
//! and it always names the TOP-LEVEL session - a subagent's own id is withheld. So the
//! variable answers "am I inside Claude Code" definitively and "which lane am I" only
//! approximately, which is exactly how it is reported: with no `--from`, the send is
//! attributed to the top-level session and says so. Outside Claude Code the caller is
//! EXTERNAL and carries a free label the operator chose; csift never fills that from the
//! environment, because a username in a message header is a privacy leak the sender did not
//! ask for.
//!
//! GATES. The two feature gates that decide whether an official transport exists at all are
//! read here too, because their inputs are the same endpoint facts: a settings env value with
//! the scope that set it, the team directories on disk, and the receiver's own teammate lanes.
//! A gate csift cannot see enabled is reported UNKNOWN with that evidence, never assumed off.
//!
//! RECEIVER. Everything the policy table needs about the receiving lane, read once: its kind
//! (from the on-disk shape and, for a teammate, its meta), its state (registry row plus pid
//! probe for a top-level session, the transcript tail for a lane), its Claude Code version and
//! first cwd (the head of its transcript), and the harness's own terminal word about it (a
//! background-agent launch whose completion notification says killed or stopped).

use std::collections::HashSet;
use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};

use super::policy::{ReceiverKind, ReceiverState};
use super::{is_lane_id, SenderKind};
use crate::live::{
    background_report, children_report, probe_pid, registry_row_for, tail_shape, BackgroundLens,
    BgKind, BgState, PidLiveness,
};

/// The environment variable Claude Code exports into a lane's Bash children. Read directly
/// rather than through the shared `@main` resolver: that one also honours a Codex companion
/// variable, and a Codex session is not a Claude Code lane - it holds no hook points, so
/// classifying it as one would promise a delivery nothing can make.
const SESSION_ENV: &str = "CLAUDE_CODE_SESSION_ID";

/// The label an external caller gets when it names none.
const DEFAULT_EXTERNAL_LABEL: &str = "unknown";

/// Who is running this send.
#[derive(Debug, Clone)]
pub(crate) struct Caller {
    pub(crate) kind: SenderKind,
    /// The top-level session uuid (a Claude Code lane only).
    pub(crate) session: Option<String>,
    /// The lane the message is attributed to: `--from @<lane>` when given, else the session.
    pub(crate) lane: Option<String>,
    pub(crate) label: Option<String>,
    /// False when the lane was ASSUMED to be the top-level session because no `--from` named
    /// it. The receipt says so rather than presenting the assumption as a fact.
    pub(crate) lane_exact: bool,
}

/// Classify the caller from the environment and `--from`.
pub(crate) fn classify(from: Option<&str>) -> Result<Caller> {
    classify_with(
        from,
        std::env::var(SESSION_ENV)
            .ok()
            .filter(|v| !v.trim().is_empty()),
    )
}

/// [`classify`] with the session variable supplied. The environment read is split off so the
/// rule can be tested without mutating a process-global the whole test binary shares.
pub(crate) fn classify_with(from: Option<&str>, session: Option<String>) -> Result<Caller> {
    match session {
        Some(session) => classify_lane(from, session),
        None => classify_external(from),
    }
}

fn classify_lane(from: Option<&str>, session: String) -> Result<Caller> {
    let (lane, exact) = match from {
        None => (session.clone(), false),
        Some(raw) => {
            let Some(id) = raw.strip_prefix('@') else {
                bail!(
                    "--from `{raw}`: inside Claude Code the sender is a LANE, so --from takes \
                     the `@<lane>` form (`@main`, or the `a...` id `csift agents` prints). A \
                     bare label is the external-caller form and would misattribute a real lane."
                );
            };
            if id == "main" {
                (session.clone(), true)
            } else if is_lane_id(id) {
                (id.to_string(), true)
            } else {
                bail!(
                    "--from `@{id}` is not a lane id: a lane is a top-level session uuid, a bare \
                     `a<16 hex>` agent id, or a teammate id `a<Name>-<16 hex>` - exactly what \
                     `csift agents` prints. `@main` names the calling top-level session."
                );
            }
        }
    };
    Ok(Caller {
        kind: SenderKind::Lane,
        session: Some(session),
        lane: Some(lane),
        label: None,
        lane_exact: exact,
    })
}

fn classify_external(from: Option<&str>) -> Result<Caller> {
    let label = match from {
        None => DEFAULT_EXTERNAL_LABEL.to_string(),
        Some(raw) if raw.starts_with('@') => bail!(
            "--from `{raw}`: outside Claude Code there is no lane to claim, so --from is a free \
             LABEL for the receipt (`--from ci-runner`), never an `@<lane>` id. csift cannot \
             verify a lane claim from a process it did not spawn."
        ),
        Some(raw) if raw.trim().is_empty() => DEFAULT_EXTERNAL_LABEL.to_string(),
        Some(raw) => raw.to_string(),
    };
    Ok(Caller {
        kind: SenderKind::External,
        session: None,
        lane: None,
        label: Some(label),
        lane_exact: true,
    })
}

/// The line printed on stderr when the lane was assumed. Kept beside the classifier so the
/// wording and the assumption cannot drift apart.
pub(crate) const LANE_ASSUMED_NOTE: &str =
    "csift: lane unknown, sending as the top-level session; pass --from @<your lane id> for an \
     exact sender (a subagent's own id is withheld from its environment - `csift whoami \
     @trap:<marker>` recovers it).";

/// One gate's verdict in the settings-model grammar: a gate is either enabled by a value csift
/// can READ, or unknown - because the shell environment and the CLI flags that also enable it
/// leave no trace on disk. An unknown gate is reported with the evidence that bears on it,
/// never resolved by assumption.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GateVerdict {
    pub(crate) name: &'static str,
    pub(crate) verdict: String,
    pub(crate) enabled: bool,
}

impl GateVerdict {
    /// The teams gate. `scope` is the settings scope that set the env key, when one did;
    /// otherwise the two evidence counts a reader can weigh instead.
    pub(crate) fn teams(scope: Option<&str>, teams_dirs: usize, teammate_lanes: usize) -> Self {
        match scope {
            Some(s) => GateVerdict {
                name: "teams",
                verdict: format!("enabled via settings env ({s})"),
                enabled: true,
            },
            None => GateVerdict {
                name: "teams",
                verdict: format!(
                    "no settings-level enable; shell env and CLI flags are not observable -> \
                     unknown; use evidence: teams directories {teams_dirs}, teammate lanes \
                     {teammate_lanes}"
                ),
                enabled: false,
            },
        }
    }

    /// The harbor (cross-session messaging) gate. The registry's socket path is the one
    /// on-disk consequence of the gate being on AND bound, so it is the verdict.
    pub(crate) fn harbor(socket_present: bool) -> Self {
        GateVerdict {
            name: "harbor",
            verdict: if socket_present {
                "registry messagingSocketPath present -> on and bound".to_string()
            } else {
                "registry messagingSocketPath absent -> unknown".to_string()
            },
            enabled: socket_present,
        }
    }
}

/// The settings cascade a verdict rested on, reduced to what a receipt may print.
///
/// A gate verdict, a slot census and every risk line about hooks are all read off ONE fold of
/// the receiver's settings scopes, so a receipt that names the verdict without naming its
/// sources is asking to be trusted rather than checked. `sources` is the reader's own inventory
/// in fold order - which file it tried, whether that file contributed, and why it did not when
/// it did not - and `unobservable` is the list of inputs that change the outcome and leave
/// nothing on disk, carried through verbatim so "unknown" always arrives with its reason.
#[derive(Debug, Clone)]
pub(crate) struct SettingsDisclosure {
    pub(crate) sources: Vec<crate::path::settings::SourceReport>,
    pub(crate) unobservable: Vec<&'static str>,
}

impl SettingsDisclosure {
    pub(crate) fn of(m: &crate::path::settings::Merged) -> Self {
        SettingsDisclosure {
            sources: m.sources.clone(),
            unobservable: m.unobservable.clone(),
        }
    }

    /// The scope labels that CONTRIBUTED, deduplicated, in fold order. A scope can be read
    /// from more than one file (the policy drop-ins, one entry per plugin manifest), and the
    /// text line answers "which scopes", not "which files".
    pub(crate) fn read_scopes(&self) -> Vec<&'static str> {
        self.scopes(true)
    }

    /// The scope labels the reader tried and did NOT get: a missing file, an unreadable one,
    /// or one the policy tier's first-wins rule left out. Named as absent rather than omitted,
    /// so a reader can tell "no project settings" from "csift did not look".
    pub(crate) fn absent_scopes(&self) -> Vec<&'static str> {
        let read = self.scopes(true);
        self.scopes(false)
            .into_iter()
            .filter(|s| !read.contains(s))
            .collect()
    }

    fn scopes(&self, read: bool) -> Vec<&'static str> {
        let mut out: Vec<&'static str> = Vec::new();
        for s in self.sources.iter().filter(|s| s.read == read) {
            if !out.contains(&s.scope) {
                out.push(s.scope);
            }
        }
        out
    }

    /// The notes, one per source that carried one, prefixed by its scope. A note is the only
    /// place a BROKEN settings file is visible at all: it never contributed, so nothing else
    /// in the fold records that it exists.
    pub(crate) fn notes(&self) -> Vec<String> {
        self.sources
            .iter()
            .filter_map(|s| s.note.as_ref().map(|n| format!("{} - {n}", s.scope)))
            .collect()
    }

    /// The one text line, and its continuations. The caller prints the first under its own
    /// label and the rest under the label's indent, exactly as the gates section does.
    pub(crate) fn lines(&self) -> Vec<String> {
        let mut out = vec![format!(
            "read: {}  ·  absent: {}",
            join_or_none(&self.read_scopes()),
            join_or_none(&self.absent_scopes())
        )];
        for note in self.notes() {
            out.push(format!("note: {note}"));
        }
        out.push(format!("unobservable: {}", self.unobservable.join("; ")));
        out
    }

    /// The machine echo of [`Self::lines`]: the per-FILE inventory plus the unobservable list.
    /// The path is included because a caller checking a verdict needs the file, not a scope
    /// name it would then have to guess the location of.
    pub(crate) fn json(&self) -> serde_json::Value {
        let sources: Vec<serde_json::Value> = self
            .sources
            .iter()
            .map(|s| {
                serde_json::json!({
                    "scope": s.scope,
                    "path": s.path.to_string_lossy(),
                    "read": s.read,
                    "note": s.note,
                })
            })
            .collect();
        serde_json::json!({"sources": sources, "unobservable": self.unobservable})
    }
}

fn join_or_none(scopes: &[&'static str]) -> String {
    if scopes.is_empty() {
        "none".to_string()
    } else {
        scopes.join(", ")
    }
}

/// Team directories under the Claude Code home: the evidence half of the teams gate verdict
/// when no settings scope enables it.
pub(crate) fn teams_dirs() -> usize {
    let Ok(home) = crate::path::claude_home() else {
        return 0;
    };
    std::fs::read_dir(home.join("teams"))
        .map(|rd| rd.flatten().filter(|e| e.path().is_dir()).count())
        .unwrap_or(0)
}

/// Everything one send needs to know about the receiving lane.
#[derive(Debug, Clone)]
pub(crate) struct Receiver {
    /// The transcript-form lane id (a session uuid, or the agent id for a subagent lane).
    pub(crate) lane: String,
    /// The owning top-level session uuid (== `lane` for a top-level receiver).
    pub(crate) session: String,
    /// The top-level transcript of that session: where the sidecar directory hangs.
    pub(crate) session_path: PathBuf,
    pub(crate) kind: ReceiverKind,
    pub(crate) state: ReceiverState,
    pub(crate) version: Option<String>,
    /// The first `cwd` the transcript records: the receiver's project root, and therefore the
    /// root its project and local settings scopes are read from.
    pub(crate) cwd: Option<String>,
    pub(crate) routing_id: Option<String>,
    pub(crate) socket_present: bool,
    pub(crate) headless: bool,
    /// Teammate lanes in the receiver's session: the evidence half of the teams gate verdict.
    pub(crate) teammate_lanes: usize,
}

/// Read the receiver's facts off disk.
pub(crate) fn probe_receiver(path: &Path) -> Result<Receiver> {
    let lane = crate::subagent::session_id_from_path(path);
    let is_sub = crate::subagent::is_subagent_path(path);
    let session_path = session_transcript_for(path);
    let session = crate::subagent::session_id_from_path(&session_path);
    let (version, cwd) = head_facts(path)?;

    let (kind, routing_id, teammate_lanes) = classify_receiver(&session_path, &lane, is_sub)?;
    let row = if is_sub {
        None
    } else {
        registry_row_for(&session)?
    };
    let extras = if is_sub {
        RegistryExtras::default()
    } else {
        registry_extras(&session)?
    };
    let state = receiver_state(path, &session_path, &lane, is_sub, row.as_ref())?;

    Ok(Receiver {
        lane,
        session,
        session_path,
        kind,
        state,
        version,
        cwd,
        routing_id,
        socket_present: extras.socket_present,
        headless: extras.headless,
        teammate_lanes,
    })
}

/// The top-level transcript that owns a lane: the lane itself for a top-level session, else
/// the session file beside the `subagents/` directory the lane sits under.
pub(crate) fn session_transcript_for(path: &Path) -> PathBuf {
    let mut dir = path.parent();
    while let Some(d) = dir {
        if d.file_name().and_then(|n| n.to_str()) == Some("subagents") {
            if let Some(session_dir) = d.parent() {
                return session_dir.with_extension("jsonl");
            }
        }
        dir = d.parent();
    }
    path.to_path_buf()
}

/// The agent id that SPAWNED each of two lanes of one session, from ONE topology build.
///
/// The on-disk layout is flat - every subagent of a session sits in the same directory - so
/// the spawn link exists nowhere but the reconstructed topology, which prefers the harness's
/// own `parentAgentId` meta field and falls back to the tool_use spawn graph. Both lanes are
/// answered together because a caller asking "did either of these spawn the other" would
/// otherwise rebuild the same topology twice. `None` is UNKNOWN, not "no parent": an
/// unbuildable topology, a lane with no discoverable node, or a parent the harness never
/// recorded all land here, and a caller must keep its unresolved reading instead of guessing
/// an ancestry.
pub(crate) fn parent_agents_of(
    session_path: &Path,
    first: &str,
    second: &str,
) -> (Option<String>, Option<String>) {
    let Ok(nodes) = crate::subagent::build_topology(session_path, false) else {
        return (None, None);
    };
    let parent_of = |lane: &str| {
        nodes
            .iter()
            .find(|n| n.agent_id == lane)
            .and_then(|n| n.parent_agent_id.clone())
    };
    (parent_of(first), parent_of(second))
}

/// The receiver's kind, its routing form when it is a teammate, and how many teammate lanes
/// the session holds. All three come from ONE meta walk, which is also the only place a
/// teammate can be told apart from a built-in subagent (they share an on-disk location).
fn classify_receiver(
    session_path: &Path,
    lane: &str,
    is_sub: bool,
) -> Result<(ReceiverKind, Option<String>, usize)> {
    if !is_sub {
        return Ok((ReceiverKind::TopLevel, None, 0));
    }
    let subs = crate::subagent::discover_subagents(session_path).unwrap_or_default();
    let teammate_lanes = subs
        .iter()
        .filter(|s| s.kind == crate::subagent::SubagentKind::Teammate)
        .count();
    let Some(me) = subs.iter().find(|s| s.agent_id == lane) else {
        // A subagent transcript with no discoverable meta: still a lane, still deliverable,
        // just not classifiable beyond "not a teammate we can name".
        return Ok((ReceiverKind::UnnamedSubagent, None, teammate_lanes));
    };
    let kind = match me.kind {
        crate::subagent::SubagentKind::Teammate => ReceiverKind::Teammate,
        crate::subagent::SubagentKind::Workflow => ReceiverKind::WorkflowLane,
        crate::subagent::SubagentKind::BuiltinTask => ReceiverKind::UnnamedSubagent,
    };
    let routing =
        crate::subagent::routing_id(me.name.as_deref(), me.team_name.as_deref()).filter(|_| {
            matches!(kind, ReceiverKind::Teammate) // only a teammate has an official routing form
        });
    Ok((kind, routing, teammate_lanes))
}

/// The receiver's Claude Code version and first cwd, from the head of its transcript.
fn head_facts(path: &Path) -> Result<(Option<String>, Option<String>)> {
    let mut version = None;
    let mut cwd = None;
    crate::parse::head_records(path, |rec| {
        if version.is_none() {
            version.clone_from(&rec.version);
        }
        if cwd.is_none() {
            cwd.clone_from(&rec.cwd);
        }
        version.is_none() || cwd.is_none()
    })
    .with_context(|| format!("reading the head of {}", path.display()))?;
    Ok((version, cwd))
}

/// What the lane is doing. A top-level session answers from the registry plus a pid probe (the
/// only surface that knows a session is alive); a subagent lane answers from its own tail,
/// with the harness's terminal word about it outranking both.
fn receiver_state(
    path: &Path,
    session_path: &Path,
    lane: &str,
    is_sub: bool,
    row: Option<&crate::live::RegistryRow>,
) -> Result<ReceiverState> {
    if stopped_by_user(session_path, lane)? {
        return Ok(ReceiverState::StoppedByUser);
    }
    let shape = tail_shape(path)?;
    if shape.records_seen == 0 {
        return Ok(ReceiverState::Unknown);
    }
    if shape.unreturned_use.is_some() {
        return Ok(ReceiverState::Frozen);
    }
    if is_sub {
        // A lane is done when its own tail ended cleanly AND nothing it spawned is still
        // running: a parent that is waiting on a child has hook points left.
        let clean = shape.last_stop_reason.as_deref() == Some("end_turn");
        let children = children_report(path, &HashSet::new())
            .map(|r| r.live_count)
            .unwrap_or(0);
        return Ok(if clean && children == 0 {
            ReceiverState::Completed
        } else {
            ReceiverState::Running
        });
    }
    let Some(row) = row else {
        // No registry row: an old build, a non-interactive session, or one that has exited.
        // Never a claim of liveness either way.
        return Ok(ReceiverState::Unknown);
    };
    let Some(pid) = row.pid else {
        return Ok(ReceiverState::Unknown);
    };
    Ok(
        match probe_pid(pid, row.proc_start.as_deref(), row.pid_domain.as_deref()) {
            PidLiveness::Alive { .. } => ReceiverState::Running,
            PidLiveness::Dead | PidLiveness::Reused => ReceiverState::Dead,
            PidLiveness::ForeignDomain(_) | PidLiveness::Unavailable => ReceiverState::Unknown,
        },
    )
}

/// True when the harness's own record says this lane was killed or stopped. The instrument is
/// the background scan over the owning session: an async agent launch carries the lane's id,
/// and its completion notification carries the terminal status. The plain-text agents-stopped
/// notice names no id, so it cannot answer this and is deliberately not consulted.
fn stopped_by_user(session_path: &Path, lane: &str) -> Result<bool> {
    if !session_path.is_file() {
        return Ok(false);
    }
    let lens = BackgroundLens::from_args(None, &[])?;
    let report = background_report(session_path, false, &lens)?;
    Ok(report.tasks.iter().any(|t| {
        t.kind == BgKind::Agent
            && t.id.as_deref() == Some(lane)
            && matches!(t.state, BgState::Killed | BgState::Stopped)
    }))
}

/// The two registry fields the shared row does not model.
#[derive(Debug, Clone, Default)]
struct RegistryExtras {
    socket_present: bool,
    headless: bool,
}

/// Read `messagingSocketPath` and `entrypoint` for one session.
///
/// They are read here rather than by widening the shared registry row: that row is the
/// liveness surface the live-truth commands share, and these two fields answer a question only
/// the channel asks - whether an official cross-session arm exists at all, and whether the
/// receiver is a headless run csift must never promise delivery to.
fn registry_extras(session_id: &str) -> Result<RegistryExtras> {
    let dir = crate::path::claude_home()?.join("sessions");
    if !dir.is_dir() {
        return Ok(RegistryExtras::default());
    }
    for entry in std::fs::read_dir(&dir)?.flatten() {
        let p = entry.path();
        if p.extension().and_then(|e| e.to_str()) != Some("json") {
            continue;
        }
        let Ok(raw) = std::fs::read_to_string(&p) else {
            continue;
        };
        let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
            continue;
        };
        if v.get("sessionId").and_then(serde_json::Value::as_str) != Some(session_id) {
            continue;
        }
        let socket = v
            .get("messagingSocketPath")
            .and_then(serde_json::Value::as_str)
            .is_some_and(|s| !s.trim().is_empty());
        let headless = v.get("entrypoint").and_then(serde_json::Value::as_str) == Some("sdk-cli");
        return Ok(RegistryExtras {
            socket_present: socket,
            headless,
        });
    }
    Ok(RegistryExtras::default())
}