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 cargento/skills/cargento/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ Paths 2 and 3 are complementary and can both be installed. Keep `Notification` o
- **"This request" ETA** = per-session current-turn estimate shown while Working. Estimated total = median of that session's past turns that lasted at least as long as the current one has so far. Turn boundaries: user prompt → last event before the next prompt (Claude, Gemini, Droid); active-branch user messages for Pi; explicit start/end events (Codex `task_started`/`task_complete`, Copilot `user.message`/`session.task_complete`); or DB message timestamps (OpenCode, Goose). JSONL harnesses use an incremental whole-file scanner (survives turns longer than the transcript tail). Pi retains the latest 50 completed durations. No ETA for Cursor. "running longer than recent turns" = no past turn was this long. Naive by design. A ⚠️ appears when elapsed or estimated total ≥ 15 min (`LONG_TURN_WARN_SEC`). Elapsed measures generation, not waiting: a mid-turn quiet stretch longer than 5 minutes (`TURN_GAP_RESET_SEC` — permission prompt, open question, sleep) re-anchors the clock at the post-gap event.
- **Loop detection** = four tool calls in a row (`LOOP_ERROR_RUN_THRESHOLD`) coming back as errors inside one request. **Claude Code only**, because Claude is the only harness that records whether a tool call failed: Codex's tool-output records carry no error field, Copilot writes no tool-end record at all, and Droid's records look right but no failing Droid call has been captured, so nothing is inferred for any of them and their rows never carry the signal. It reads the transcript, never the tool's input, so no command text is held anywhere. Four is measured rather than picked: across the 25 most recent local transcripts, runs of three and runs of four each appeared in the same single session, and five in none — so four costs no detections and keeps clear of the benign runs (an `ls` that found nothing, a `git` in a deleted worktree) that filled the sample. The count is the longest run inside the current request and it clears at your next prompt, not when the failures stop, since a loop that has just gone quiet is what you walked back to the machine to find. It raises no flag of its own — the `long turn` chip and the ⚠️ still fire on duration alone, and a loop only changes what they say when you reach for them, which is the difference between "it is slow" and "it is repeating a failure". Where the request is not long enough for either, the working card and the calm detail panel say it anyway. The pattern is honest but not proof: iterating on a failing test looks the same from outside.
- **Est. remaining** (per tracked-task session) = average duration of that session's completed tasks × open task count. Naive by design; "no estimate" until a session has a completed task that took ≥30s.
- **Spacedock stage strip** = one line per in-flight entity, showing that workflow's stages in declaration order with the entity's current stage highlighted. A bold entity name means a worker for it is running now; a plain one is read from the entity's state file or, failing that, the boot snapshot. Only Claude reports workers, so a Pi strip is never bold. In flight means moving: an entity resting on the initial stage or a terminal one is left out unless boot called it dispatchable, so a queue of thirty waiting on `intake` does not crowd out the two being worked. Entities whose state file has not been touched inside the freshness window are history, not work, and are skipped — that is what keeps a long-retired workflow off the card of a first officer that merely discovered it. A long workflow is windowed around the current stage, with `…` standing in for the stages elided. An over-long entity name is elided in the **middle**, never the tail — entities in one workflow share a long prefix and differ only at the end — and hovering it shows the full slug. No strip appears when the boot output is outside the scanned head of a long transcript, when the workflow README cannot be read, or when its frontmatter uses a construct the reader does not model — it renders nothing rather than a guess.
- **Spacedock stage strip** = one line per in-flight entity, showing that workflow's stages in declaration order with the entity's current stage highlighted. A bold entity name means a worker for it is running now; a plain one is read from the entity's state file or, failing that, the boot snapshot. Only Claude reports workers, so a Pi strip is never bold. In flight means moving: an entity resting on the initial stage or a terminal one is left out unless boot called it dispatchable, so a queue of thirty waiting on `intake` does not crowd out the two being worked. Entities whose state file has not been touched inside the freshness window are history, not work, and are skipped — that is what keeps a long-retired workflow off the card of a first officer that merely discovered it. A long workflow is windowed around the current stage, with `…` standing in for the stages elided. An over-long entity name is elided in the **middle**, never the tail — entities in one workflow share a long prefix and differ only at the end — and hovering it shows the full slug. A strip can lag rather than vanish when the boot output sits deep in a long transcript: the scan reads a bounded slice per refresh and walks forward, so a first officer that boots late in its transcript gets its strip a refresh or two after the session appears. No strip appears when the workflow README cannot be read, or when its frontmatter uses a construct the reader does not model — it renders nothing rather than a guess.
- **Session title** = the harness's own generated title where it writes one (Claude records these, and they read like "Debug Spacedock workflow steps not displaying"), otherwise a prompt from the session itself — the **opening** one on Claude and Pi, which is what the great majority of titled rows fall back to, and the **newest** one only on Codex, which writes no generated title at all. That difference is why the line beneath the title exists: an opening prompt names the session durably and goes stale as work moves on. It is cleaned up rather than shown raw: a slash command reads as `/plugin` instead of the markup the harness wrapped it in, a dispatched worker's prompt shows the instruction instead of the envelope, absolute paths collapse to their last segment so the path does not eat the whole line, and an over-long title is cut on a word boundary. Relative paths and URLs are left whole, because the repo and PR number in a link are the informative part. Nothing is summarized by a model, so no session text leaves the machine.
- **The line beneath the title** (on the opt-in `http://127.0.0.1:4553/?next=true` UI) answers a different question from the title, which is why it is a second line and not a better first one. A title names the session; this names what it is working on now. It always carries a label and the age of the record it came from, because that is what tells you how much to trust it: `asked, 4m:` is the newest thing you told it, `agent, 4m:` is the agent's own statement of what it was starting this turn, and `earlier, 2h:` is an older instruction shown because the newest one was a bare "proceed" that names no work. Where none of those can be established honestly the line is absent and the row shows its title alone, which is the old behaviour: a confident wrong line would mask the project name underneath it for as long as the session lives. Claude keeps its generated title on line 1 even when that title has gone stale, because it is how you recognise the row. Codex shows no second line when its title is already that same newest instruction — not always in full, since 167 of 280 local Codex titles are cut at the title width and 144 of those suppress the line while holding back a median 56 further characters. Repeating a prompt under itself costs a row two lines to say one thing, and the fuller reading is a keystroke away in the session panel.
- **`…REDACTED` in a title, in the line beneath it, or in a task row** means the text there matched a credential shape and was replaced before it reached the page. The marker keeps the prefix that names the kind (`sk-ant-…REDACTED`, `AKIA…REDACTED`, `ghp_…REDACTED`) and the words around it, because the instruction is the useful part of the line. Read it as a finding rather than a rendering quirk: the value is still in the harness's own transcript on disk, where the next scan will read it again and so will anything else with access to that store, so the answer is to rotate the credential. It is shape matching over what a person typed, so it is neither complete nor infallible — a format nobody has measured goes through unmarked, and a long identifier that happens to look like a key is marked when it is not.
Expand Down
155 changes: 138 additions & 17 deletions cargento/skills/cargento/cargento_runtime/spacedock.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
# the transcript's first records carry an ``agentSetting``. That alone proves
# the session is Spacedock, and costs nothing: it is in the head bytes the
# subagent classifier already reads.
# 2. The first officer runs `spacedock status --boot` at startup and the JSON
# 2. The first officer runs `spacedock status --boot` — measured here at 69%
# and 73% of the way through a transcript, not at startup — and the
# envelope lands in the transcript as a tool result, carrying the ABSOLUTE
# workflow directory and the ABSOLUTE entity-state directory, so nothing has
# to be discovered by scanning.
Expand Down Expand Up @@ -298,8 +299,87 @@ def _usable_dir(value: object) -> TypeGuard[str]:
return True


def rendered_pair(raw: str) -> tuple[str, str] | None:
"""``(key, value)`` from one line of a rendered boot envelope, or None.

Only the shape ``key: value`` is assumed. The key may carry JSON's quotes
or none and the value may carry quotes and a trailing comma, because what
reaches the transcript is whatever the session chose to print. Partitioning
on the FIRST colon keeps a Windows value (``C:\\...``) intact.
"""
line = raw.strip()
head, sep, tail = line.partition(":")
if not sep:
return None
key = head.strip().strip("\"'")
if not key:
return None
return (key, tail.strip().rstrip(",").strip().strip("\"'"))


def rendered_envelope(config: RuntimeConfig, text: str) -> dict[str, Any] | None:
"""The boot envelope a session PRINTED rather than pasted, or None.

The first officer is told to consume ``status --boot --json``, and nothing
tells it to echo that JSON verbatim. Measured on this machine: every real
first-officer session piped the envelope through a formatter, so the
transcript carried an indented rendering and the raw object never appeared
once in 120 transcripts over 21 days — the JSON branch above has never
matched a genuine session, only Cargento's own source and fixtures catted
into a tool result. The fields the strip needs survive any such rendering,
so they are read line by line rather than decoded.

Gated on a top-level ``command: boot`` for the same reason the JSON branch
checks ``envelope["command"] == "boot"``: without it, a tool result that
merely prints these key names — this module's own source — nominates a path.
Only top-level keys are read, so a nested decoy cannot supply one either.

No line bound is imposed here: the text is already a slice of the at most
``spacedock_boot_scan_bytes`` the caller read, and this scan is linear with
trivial per-line work, unlike the ``raw_decode`` the candidate cap protects.
"""
out: dict[str, Any] = {}
dispatchable: list[dict[str, str]] = []
item: dict[str, str] = {}
booted = False
in_dispatchable = False
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
pair = rendered_pair(raw)
if pair is None:
continue
key, value = pair
if not raw[:1].isspace():
in_dispatchable = key == "dispatchable"
if key == "command":
booted = booted or value == "boot"
elif key in ("definition_dir", "entity_dir") and value and key not in out:
# First occurrence wins, so a decoy in a trailing blob cannot
# displace the envelope the session actually booted from.
out[key] = value
continue
if not in_dispatchable or len(dispatchable) >= config.spacedock_max_entities:
continue
# Paired on `slug` rather than on the list syntax, so both the
# `-`-delimited and inline renderings of an item read the same.
if key == "slug" and value:
if item:
dispatchable.append(item)
item = {"slug": value}
elif key == "current" and value and item and "current" not in item:
item["current"] = value
if item and len(dispatchable) < config.spacedock_max_entities:
dispatchable.append(item)
if not booted or "definition_dir" not in out:
return None
out["command"] = "boot"
out["dispatchable"] = [
entry for entry in dispatchable if entry.get("slug") and entry.get("current")
]
return out


def boot_records(config: RuntimeConfig, data: bytes) -> list[dict[str, Any]]:
"""Every ``spacedock status --boot`` envelope in a transcript head.
"""Every ``spacedock status --boot`` envelope in a slice of a transcript.

Decoded line by line as the JSONL it is, so the JSON decoder does the
unescaping and each envelope is located inside already-plain text. An
Expand All @@ -319,6 +399,7 @@ def boot_records(config: RuntimeConfig, data: bytes) -> list[dict[str, Any]]:
if not isinstance(record, dict):
continue
for text in tool_result_text(record):
found = len(out)
position = 0
for _ in range(config.spacedock_max_boot_candidates):
begin = text.find('{"command"', position)
Expand All @@ -343,6 +424,13 @@ def boot_records(config: RuntimeConfig, data: bytes) -> list[dict[str, Any]]:
out.append(envelope)
if len(out) >= config.spacedock_max_boot_records:
return out
if len(out) > found:
continue
rendered = rendered_envelope(config, text)
if rendered is not None:
out.append(rendered)
if len(out) >= config.spacedock_max_boot_records:
return out
return out


Expand Down Expand Up @@ -410,34 +498,67 @@ def boot_entity_dir(envelopes: list[dict[str, Any]], workflow_dir: str) -> str:


def transcript_boot(config: RuntimeConfig, state: RuntimeState, path: str) -> list[dict[str, Any]]:
"""Boot envelopes from a transcript's head, cached per (path, size).

Boot output is written once at session start and never rewritten, so the
scan is amortised: keying on size lets a still-growing session pick the
envelope up on a later refresh without rescanning an unchanged prefix.
"""Boot envelopes from a transcript, scanned forward across refreshes.

An earlier version read only the first ``spacedock_boot_scan_bytes``, on the
reasoning that boot output is written once at session start and never
rewritten. Measured against real first-officer sessions on this machine, it
is not: two of them booted 69% and 73% of the way through their transcripts,
at bytes 803,503 and 821,199, because a first officer greets and discovers
before it boots and Claude Code writes records up to 109 KB apiece. A
head-only window found neither.

So the window walks rather than sits. Each pass reads at most
``spacedock_boot_scan_bytes`` of not-yet-scanned bytes and remembers how far
it reached, which holds the per-refresh cost the head scan had while the
whole file is covered eventually. Progress is cached per path rather than
per ``(path, size)`` so a growing session keeps its place instead of
restarting the walk on every write.
"""
try:
size = os.path.getsize(path)
except OSError:
return []
key = (path, min(size, config.spacedock_boot_scan_bytes))
with state.cache_lock:
cached = state.spacedock_boot_cache.get(key)
if cached is not None:
return cached
envelope_records: list[dict[str, Any]] = []
cached = state.spacedock_boot_cache.get(path)
records: list[dict[str, Any]]
scanned: int
records, scanned = cached if cached is not None else ([], 0)
if scanned > size:
# The file is shorter than the walk already covered, so it is not the
# one that progress was recorded against. Start it over.
records, scanned = [], 0
if scanned >= size or len(records) >= config.spacedock_max_boot_records:
return records
try:
with open(path, "rb") as handle:
handle.seek(scanned)
blob = handle.read(config.spacedock_boot_scan_bytes)
if b"definition_dir" in blob:
envelope_records = boot_records(config, blob)
except OSError:
return []
return records
chunk = blob
if scanned + len(blob) >= size:
consumed = len(blob)
else:
cut = blob.rfind(b"\n") + 1
if cut:
# Stop on a line boundary so an envelope straddling the window edge
# is read whole on the next pass instead of halved on this one.
chunk, consumed = blob[:cut], cut
else:
# One record longer than the whole window. Nothing in it can be
# read as a line, so step over it rather than stall here forever.
chunk, consumed = b"", len(blob)
if b"definition_dir" in chunk:
records = (records + boot_records(config, chunk))[: config.spacedock_max_boot_records]
with state.cache_lock:
runtime_state.bounded_put(
state.spacedock_boot_cache, key, envelope_records, limit=config.max_cache_entries
state.spacedock_boot_cache,
path,
(records, scanned + consumed),
limit=config.max_cache_entries,
)
return envelope_records
return records


def open_regular(path: str) -> int | None:
Expand Down
2 changes: 1 addition & 1 deletion cargento/skills/cargento/cargento_runtime/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ class RuntimeState:
turn_scan: dict[str, Any] = field(default_factory=dict)
agent_class_cache: dict[str, tuple[bool, str, str]] = field(default_factory=dict)
spacedock_role_cache: dict[str, str] = field(default_factory=dict)
spacedock_boot_cache: dict[tuple[str, int], list[dict[str, Any]]] = field(default_factory=dict)
spacedock_boot_cache: dict[str, tuple[list[dict[str, Any]], int]] = field(default_factory=dict)
spacedock_workflow_cache: dict[tuple[str, int, int], dict[str, Any] | None] = field(
default_factory=dict
)
Expand Down
Loading