Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ navigations, and answers 503 when the process runs without a coordinator.
The same route serves the bounded record of state disputes, where an event overruled a session the
dashboard had read as waiting. A record holds the same fields plus the two activity timestamps the
reducer compared, and no more: the row's title and its state detail are deliberately absent, because
a state detail can carry a permission prompt's own text.
a state detail can carry a permission prompt's own text, an open question's, or a plan's first line.

## Known and accepted

Expand Down
4 changes: 2 additions & 2 deletions cargento/skills/cargento/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Cargento is an agnostic agent cartography and visualization tool: a local web da
Store locations are resolved per platform, and the documented relocation variables are honored: `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `GEMINI_CLI_HOME` (the CLI creates `.gemini` inside it; relocates the Antigravity store with it), `COPILOT_HOME`, `PI_CODING_AGENT_DIR`, and `PI_CODING_AGENT_SESSION_DIR`. When one is set it is authoritative — no fallback to the default location. Run `--diagnose` to see every path searched.

Data sources (read-only, no external calls; all parsing is defensive — a broken harness store is skipped, never fatal):
- `~/.claude/projects/*/<session>.jsonl` — Claude transcript tails: session discovery, titles, token usage, pending AskUserQuestion detection
- `~/.claude/projects/*/<session>.jsonl` — Claude transcript tails: session discovery, titles, token usage, pending AskUserQuestion detection and the text of the question it is asking
- Claude subagents, two generations: modern harnesses write each subagent as a top-level `~/.claude/projects/*/<uuid>.jsonl` whose records carry `agentName` + `teamName: "session-<parent prefix>"` — these fold into the parent session (named pill, freshness, output rate) and never appear as standalone sessions; legacy `<session-uuid>/subagents/agent-*.jsonl` + `.meta.json` files are still recognized (fresh mtime = running), including the `subagents/workflows/<run-id>/` directory a workflow fan-out nests its agents in. A bare `agentName` from a top-level `--agent` launch has no parent relation and remains a standalone session; `agentSetting`, not `agentName`, supplies any Spacedock role. Agent writes count as parent activity in their own right, so a session parked on a long background workflow reads Working rather than Idle
- Spacedock workflows: a Claude session launched by Spacedock carries an `agentSetting` of `spacedock:first-officer` or `spacedock:ensign` in its first transcript records, which is how the role badge appears. A first officer also records its `spacedock status --boot` output — counted only when it arrives as command output, never as ordinary conversation text — which names each workflow directory and each entity-state directory absolutely. The ordered stage list comes from the workflow `README.md` frontmatter, and each entity's current stage from the `status` in its own state-file frontmatter; boot's `dispatchable` list is only a snapshot of what was ready to move at boot, so it fills in behind the state directory rather than standing in for it. Those two kinds of frontmatter are the only project files Cargento reads — see the repository's SECURITY.md for the contract, and `--no-spacedock` to disable it
- `~/.claude/tasks/<session-id>/N.json` — tracked task state (subject, status, activeForm); current bare-UUID and older `session-<id>` directories are supported
Expand Down Expand Up @@ -140,7 +140,7 @@ Both layers notify on the *transition* into needs-input, not on every refresh a

**Known gap:** idle nudges (`idle_prompt`) pop without marking the session blocked. The server delivers those on macOS, but the page only notifies on a needs-input transition — so on Linux and Windows an idle nudge produces no popup today. Closing it needs a one-shot event channel in `/api/data`; it is tracked alongside the native Linux and Windows backends rather than bolted on here.

1. **Transcript detection** — an open `AskUserQuestion` or `ExitPlanMode` flips the session to Needs input on the next collection, *when the record has reached disk*. Claude Code buffers it and may not write it until the gate is answered, so treat this as an opportunistic early signal rather than a source to rely on (an open dashboard tab is what drives collections, so keep one open).
1. **Transcript detection** — an open `AskUserQuestion` or `ExitPlanMode` flips the session to Needs input on the next collection, *when the record has reached disk*. Claude Code buffers it and may not write it until the gate is answered, so treat this as an opportunistic early signal rather than a source to rely on (an open dashboard tab is what drives collections, so keep one open). When the record is there, the row shows the question itself, or a plan's first line, rather than the tool's name; when it is not, the row still says a question is open but cannot say which. Both readings are normal for the same session. There is also a window of up to 90 seconds after a turn starts where a live event overlay reports Working and the question does not show at all, even though it was parsed.
2. **Lifecycle hooks** — `Notification` and `SessionEnd` hooks in user settings (`~/.claude/settings.json`) POSTing their payloads to `http://127.0.0.1:4553/api/notify`. Notifications cover permission prompts and idle waits, even with no browser tab open. The structured `notification_type` decides whether a notification is actionable. Idle nudges (`idle_prompt`, message "Claude is waiting for your input") pop once but never mark the session blocked; authentication, completion and computer-use status notifications do neither; permission prompts, MCP elicitation dialogs and a worker's permission or network request create Needs-input state. A type not on either list is treated as actionable, so a notification kind added upstream surfaces rather than disappearing. `SessionEnd` clears a standing hook when Claude exits cleanly. These hooks are NOT installed by the plugin — if the user wants path 2, offer to add them to their `~/.claude/settings.json`:

Use the bundled `notify_hook.py` (next to `server.py`) rather than a `curl` one-liner. The one-liner is POSIX-only end to end — single-quoting, `/dev/null`, `|| true`, and `--data-binary @-` all fail in `cmd.exe`, and Windows PowerShell 5.1 aliases `curl` to `Invoke-WebRequest` and has no `||`. One interpreter invocation behaves the same in every shell, exits 0 even when the dashboard is not running, and refuses to POST anywhere but loopback.
Expand Down
23 changes: 22 additions & 1 deletion cargento/skills/cargento/cargento_runtime/aggregate.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,27 @@
from .state import RuntimeState


def _keep_wait_detail(session: Session, patch: Mapping[str, Any]) -> Mapping[str, Any]:
"""The patch, minus a `state_detail` that would blank a standing wait.

No overlay constructor sets `detail`, so every needs-input patch carries
None, and applying it erased whatever the collector had found. For a Claude
row that is the open question itself, which is the one thing a person
stopped at a gate wants to read.

Narrow on purpose. It applies only when the row was already Needs input and
stays Needs input, because the overlay is then agreeing about the state and
disagreeing about nothing. Working and Idle must keep clearing the field, or
a working detail such as `running Bash` follows the row into a wait, and a
question that has been answered outlives the overlay that retired it.
"""
if session.get("state") != "needs_input" or patch.get("state") != "needs_input":
return patch
if patch.get("state_detail") is not None or not session.get("state_detail"):
return patch
return {key: value for key, value in patch.items() if key != "state_detail"}


class OverlaySource(Protocol):
"""The narrow view of the coordinator that a collection needs.

Expand Down Expand Up @@ -358,7 +379,7 @@ def _apply_overlays(self, out_sessions: list[Session], *, now: float) -> None:
activity_grace_sec=self.config.overlay_wait_activity_grace_sec,
)
self._note_dispute(session, patch, overlays, now=now)
runtime_events.apply_patch(session, patch)
runtime_events.apply_patch(session, _keep_wait_detail(session, patch))
else:
# No ledger for this row means nothing can be disagreeing with it.
self._clear_dispute(harness, sid)
Expand Down
52 changes: 49 additions & 3 deletions cargento/skills/cargento/cargento_runtime/claude_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,54 @@
from cargento_runtime import state as runtime_state

if TYPE_CHECKING:
from collections.abc import Mapping

from cargento_runtime.config import RuntimeConfig
from cargento_runtime.state import RuntimeState

# Tools that mean Claude is blocked on the human, not just running long.
INPUT_TOOLS = {"AskUserQuestion", "ExitPlanMode"}


def input_summary(block: Mapping[str, Any], *, limit: int) -> str:
"""What an input tool is actually asking, in one bounded line, or "".

Summarised at parse time rather than kept raw, so the question is the only
thing that survives the read. `ExitPlanMode` carries a whole plan, which can
run to thousands of words and has no business sitting in a session row or in
the caches one is built from.

The two shapes are Claude Code's, read off 160 real records rather than a
page: `AskUserQuestion` carries `questions`, a list of objects with a
`question`, and `ExitPlanMode` carries `plan`. Both are treated as untrusted,
because a transcript is a file on disk that Cargento does not write.

A plan is reduced to its first line, which is the plan's own title in
practice. Anything longer is a document, and a row is not where a document
goes.
"""
payload = records.as_dict(block.get("input"))
if block.get("name") == "ExitPlanMode":
# Split before scrubbing, not after: `safe_text` turns a newline into a
# space, so a plan scrubbed first has no line to take the first of.
# The first *usable* line rather than the first line, because a plan can
# open with a blank, a fence or a bullet, and none of those name it.
for raw_line in str(payload.get("plan") or "").split("\n"):
line = records.safe_text(raw_line, limit).strip().lstrip("#*->` ").strip()
if line:
return line[:limit]
return ""
questions = [
text
for item in records.as_list(payload.get("questions"))
if (text := records.safe_text(records.as_dict(item).get("question"), limit).strip())
]
if not questions:
return ""
extra = len(questions) - 1
return f"{questions[0]} (+{extra} more)"[:limit] if extra else questions[0][:limit]


# Claude's own sentinel for an assistant record it generated locally, without
# the request ever reaching the API: a cancellation notice, an error banner, a
# tool-limit message. It is not a model, and it is common — one top-level
Expand Down Expand Up @@ -180,7 +222,7 @@ def analyze_transcript(config: RuntimeConfig, state: RuntimeState, path: str) ->
"model": None,
"model_sidechain": None,
"usage_events": [], # (epoch, output_tokens)
"pending_input_tool": None, # {"name", "ts"} awaiting the human
"pending_input_tool": None, # {"name", "ts", "asks"} awaiting the human
"last_tool": None,
"last_event_ts": 0,
# Newest record the *agent* wrote, which is not the same as the newest
Expand All @@ -190,7 +232,7 @@ def analyze_transcript(config: RuntimeConfig, state: RuntimeState, path: str) ->
"last_assistant_ts": 0,
"last_user_event": last_user_event(config, state, path),
}
pending: dict[Any, Any] = {} # tool_use id -> {"name", "ts"} for INPUT_TOOLS only
pending: dict[Any, Any] = {} # tool_use id -> {"name", "ts", "asks"} for INPUT_TOOLS only
for line in runtime_io.read_tail(config, path):
if not line or line[0] != "{":
continue
Expand Down Expand Up @@ -221,7 +263,11 @@ def analyze_transcript(config: RuntimeConfig, state: RuntimeState, path: str) ->
if isinstance(c, dict) and c.get("type") == "tool_use":
info["last_tool"] = c.get("name")
if c.get("name") in INPUT_TOOLS:
pending[c.get("id")] = {"name": c.get("name"), "ts": ep}
pending[c.get("id")] = {
"name": c.get("name"),
"ts": ep,
"asks": input_summary(c, limit=config.input_summary_cap_chars),
}
elif t == "user":
for c in records.as_list(records.message_dict(d).get("content")):
if isinstance(c, dict) and c.get("type") == "tool_result":
Expand Down
16 changes: 15 additions & 1 deletion cargento/skills/cargento/cargento_runtime/collectors/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,21 @@ def collect(
p = info["pending_input_tool"]
session_state = "needs_input"
blocked_since = p["ts"] or last_activity
state_detail = f"open question ({p['name']}), waiting {runtime_sessions.fmt_duration(runtime_sessions.age(config, now, p['ts'])) if p['ts'] else '?'}"
waited = (
runtime_sessions.fmt_duration(runtime_sessions.age(config, now, p["ts"]))
if p["ts"]
else "?"
)
# The question itself when the record carried it, the tool's name when
# it did not. Both happen: the record reaches disk on no schedule, so
# this reads as one or the other rather than appearing and vanishing
# for the same session. See docs/design-needs-input.md (N-4).
asks = p.get("asks") or ""
state_detail = (
f"{asks}, waiting {waited}"
if asks
else f"open question ({p['name']}), waiting {waited}"
)
# Fresh activity in the session's *own* transcript still beats a hook:
# Claude Code emits "waiting for your input" notifications for sessions
# that keep running via background tasks and will resume on their own.
Expand Down
5 changes: 5 additions & 0 deletions cargento/skills/cargento/cargento_runtime/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ class RuntimeConfig:
prompt_path_collapse_min_length: int
first_line_json_cap_bytes: int
notification_body_cap_bytes: int
# What an open question may occupy on a row. Characters, not bytes: this one
# is measured where it is read, and a plan's first line is prose rather than
# a payload.
input_summary_cap_chars: int
# The quota fetch (SECURITY.md, "Usage quota reads"): the contract's
# five-minute floor between requests to one vendor, the request timeout,
# and the read caps on the credential file and the response body.
Expand Down Expand Up @@ -375,6 +379,7 @@ def build_runtime_config(
prompt_path_collapse_min_length=25,
first_line_json_cap_bytes=200_000,
notification_body_cap_bytes=65_536,
input_summary_cap_chars=160,
usage_poll_floor_sec=300,
usage_fetch_timeout_sec=10,
usage_credentials_cap_bytes=65_536,
Expand Down
18 changes: 17 additions & 1 deletion cargento/skills/cargento/cargento_runtime/records.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,26 @@
from datetime import UTC, datetime
from typing import Any

# C0 and DEL, the zero-width space, the two directional marks, and the bidi
# embedding and isolate ranges. Listed one by one across U+200B to U+200F rather
# than as a range, because U+200C and U+200D are inside it and must survive:
# ZWNJ is orthographic in Persian and several Indic scripts, sitting inside
# words, and ZWJ is what composes an emoji sequence. Neither can reorder text,
# so keeping them costs no protection, and stripping them would break a title in
# those scripts anywhere in the product, not only on the row that prompted this.
_UNSAFE_CHARS = re.compile("[\\x00-\\x1f\\x7f\\u200b\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069]+")


def safe_text(value: Any, limit: int) -> str:
"""Untrusted text, safe to put on a row: no control characters, bounded.

The bidi and isolate ranges are stripped alongside the C0 set, and not for
tidiness: those characters reorder how the text after them renders, so a
harness record could make a row read as something it does not say. Legitimate
right-to-left text does not need them, since bidi resolves implicitly.
"""
text = str(value or "").encode("utf-8", "replace").decode("utf-8")
text = re.sub(r"[\x00-\x1f\x7f]+", " ", text)
text = _UNSAFE_CHARS.sub(" ", text)
return text[:limit]


Expand Down
Loading