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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ cargento/ # plugin root: Claude Code, Codex, Antigravi
│ ├── probe.py # the coarse store probe: a bounded stat sweep, a hint only
│ ├── quota.py # quota: per-vendor fetches, pushed receipts, and the cache
│ ├── reading.py # one reader-requested reading: the ledger, the rules, the refusals
│ ├── reach.py # off-machine nudge delivery: webhook resolver, payload format, POST
│ ├── records.py # untrusted-record parsing and normalization
│ ├── sessions.py # session identity, shape, and deterministic aggregation
│ ├── snapshot.py # the published response bytes and their restart-qualified revision
Expand Down
29 changes: 23 additions & 6 deletions HOW_TO_USE.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ Each flag belongs to the dashboard process, so changing one means restarting.
| `--no-annotations` | The goal and expected output you typed against a session. Nothing is shown or saved, and the page offers no field |
| `--no-focus` | Raising a session's terminal. No focus command runs, no terminal identity is recorded, and the page is offered no raise control. `--no-events` turns it off as well |
| `--no-observer-model` | Model goal summaries, and the readings that use the same lane. It overrides `--observer-model`, so nothing reaches the Codex CLI for this run |
| `--no-reach` | Off-machine reach nudges. Outbound webhook nudges are disabled for this run |

[SKILL.md](cargento/skills/cargento/SKILL.md#options) owns the full option reference.

Expand All @@ -486,18 +487,34 @@ afterwards brings nothing with it.

## Usage and quota

This is the only thing Cargento sends anywhere, and it does not send it until you say so. The
first time the dashboard opens on a machine where a harness could be asked, a banner beneath the
fleet counts explains that answering yes lets Cargento read the credential that harness already
stored and send it to that vendor for your usage numbers. Until you answer, nothing is read and
nothing is sent. The request carries the vendor's own token and nothing else, behind a five minute
floor.
Usage quota reads and operator-configured reach nudges are the outbound requests Cargento can make,
and neither sends anything until you configure it. The first time the dashboard opens on a machine
where a harness could be asked, a banner beneath the fleet counts explains that answering yes lets
Cargento read the credential that harness already stored and send it to that vendor for your usage
numbers. Until you answer, nothing is read and nothing is sent. The request carries the vendor's own
token and nothing else, behind a five minute floor.

Changing your mind takes one click: the capacity strip carries a switch that turns the fetch on or
off without a restart. `--no-usage` refuses it for a whole run whatever is stored in the page.
[SECURITY.md](SECURITY.md#usage-quota-reads-the-quota-fetcher) owns the contract, including what is
sent, what comes back, and what is never touched.

## Reach nudges away from the desk

When configured, Cargento can post a scalar count to a webhook URL you provide when sessions
need input or finish unread while you are away:

```bash
python3 "<skill-dir>/server.py" --reach-url "https://ntfy.sh/my-topic"
```

The URL can also be set via the `CARGENTO_REACH_URL` environment variable or saved in
`~/.cargento/reach_url`. The payload contains only two count integers (`needs_input` and
`finished_unread`) and never includes session IDs, paths, titles, or prompt text. Outbound
nudges are disabled by default and can be suppressed for any run with `--no-reach`. See
[SECURITY.md](SECURITY.md#off-machine-nudges-reaching-the-operator-away-from-the-desk) for the
full security contract.

## Stop a dashboard, and unstick a port

```bash
Expand Down
11 changes: 4 additions & 7 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -1111,9 +1111,9 @@ browser notification in a tab that is open, the board itself. DEC-4 ruled on 202
Cargento may reach further, in one shape and no other. The operator supplies one endpoint, and
Cargento posts a count to it.

This is the section to read before building that, and it grants nothing on its own. No shipped
feature posts to an endpoint the operator supplies; H2 (DRC-4034) is the first one that would.
Until it lands, the outbound surface is the quota poll and the explicitly enabled observer model.
This is the section to read before building that, and it grants nothing on its own. H2 (DRC-4034)
ships this capability. The outbound surface is the quota poll, the explicitly enabled observer
model, and the operator-configured reach endpoint.

Why this needs its own section rather than an entry under Usage quota reads: that section's
endpoint list is closed, and every entry on it is a vendor Cargento chose and verified. Here the
Expand Down Expand Up @@ -1157,10 +1157,7 @@ The bounds, all of which hold together:
- Off switch. The feature ships `--no-reach` with it: a flag that disables the pathway for a run
regardless of the stored setting, mirroring `--no-usage` and `--no-history` at every one of their
sites, including the branch that forwards flags to a respawned daemon, so a restart cannot
re-enable what the operator disabled. That flag does not exist yet, and this document does not
claim it does. Nothing posts, so there is nothing to switch off. A test holds those two statements
together: it asserts this section still says nothing posts and that the parser still has no such
flag, so whoever adds the flag is failed here until they amend this section too.
re-enable what the operator disabled.

A violation of any of those is a security bug: a post with no URL configured, a post to any
destination but the configured one, a redirect followed, a payload carrying any field beyond the
Expand Down
2 changes: 2 additions & 0 deletions cargento/skills/cargento/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,8 @@ Paths 2 and 3 are complementary and can both be installed. Keep `Notification` o
| `--no-ask` | For this run, do not let a session ask the reader a question: the register, poll and answer routes refuse and the page offers no control. The rollback switch for the ask lane. |
| `--no-focus` | For this run, do not raise a session's terminal: no focus command runs, no terminal identity is recorded, and the page is handed no capability to ask with, so it offers no raise control. `--no-events` turns it off as well. The rollback switch for the terminal raise. |
| `--no-history` | For this run, keep no local history of what the server observed: nothing is written and an existing store is not read back, so the board opens with no memory of earlier sessions. |
| `--no-reach` | For this run, disable outbound reach nudges: no webhook URL is resolved and no off-machine nudge is posted. |
| `--reach-url URL` | Webhook URL for off-machine reach nudges when sessions need input or finish unread while away from the desk. Overrides `CARGENTO_REACH_URL` and `~/.cargento/reach_url`. |
| `--history-days N` | How long the local history keeps an observation, in days (default 14). Eviction is age first, so narrowing this drops what falls outside the window and widening it again brings nothing back. Zero or negative is refused. |
| `--history-max-bytes N` | The size cap on the local history store, in bytes (default 1048576). It is the read cap too: a file larger than it is discarded unread rather than parsed. Zero or negative is refused. |
| `http://127.0.0.1:4553/?all=1` | Show all sessions ever, including idle ones |
Expand Down
55 changes: 33 additions & 22 deletions cargento/skills/cargento/cargento_runtime/aggregate.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
notifications,
observer,
quota,
reach,
reading,
records,
sessions,
Expand Down Expand Up @@ -726,23 +727,10 @@ def _usage_for(
)
return []

def collect(self, *, show_all: bool, notify: bool = True) -> Collection:
config, state, window_hours, now = (
self.config,
self.state,
self.config.window_hours,
self.clock(),
)
cleared_marks = dismissals.refresh(config, state)
# Alongside the dismissal refresh and for its reason: two dashboards can
# bind on one machine and the file is the record, so a save made in the
# other is picked up here rather than at the next restart.
annotation_entries = annotation_store.refresh(config, state)
# Sampled before the harness loop for the reason Claude's collector used
# to sample it before its transcript scan: a SessionEnd that commits
# while this collection is in flight must invalidate the popup, and a
# generation read at decision time would only be compared against itself.
generations = notifications.hook_generations(state)
def _collect_harnesses(
self, now: float, window_hours: float, show_all: bool
) -> tuple[list[Session], list[dict[str, Any]], list[dict[str, Any]], bool, bool]:
config, state = self.config, self.state
out_sessions: list[Session] = []
harnesses: list[dict[str, Any]] = []
usage: list[dict[str, Any]] = []
Expand All @@ -767,14 +755,35 @@ def collect(self, *, show_all: bool, notify: bool = True) -> Collection:
)
if spec.usage is None:
continue
# The `usage` key exists exactly when a discovered harness can
# publish quota; the page keeps its band hidden otherwise. A
# failed quota read is a diagnostic, never a harness error — the
# session rows above already collected, and a broken tile must
# not repaint the whole strip red.
usage_supported = True
usage_fetch_active = usage_fetch_active or spec.usage_is_fetch
usage.extend(self._usage_for(spec, now, window_hours))
return out_sessions, harnesses, usage, usage_supported, usage_fetch_active

def collect(self, *, show_all: bool, notify: bool = True) -> Collection:
config, state, window_hours, now = (
self.config,
self.state,
self.config.window_hours,
self.clock(),
)
cleared_marks = dismissals.refresh(config, state)
# Alongside the dismissal refresh and for its reason: two dashboards can
# bind on one machine and the file is the record, so a save made in the
# other is picked up here rather than at the next restart.
annotation_entries = annotation_store.refresh(config, state)
# Sampled before the harness loop for the reason Claude's collector used
# to sample it before its transcript scan: a SessionEnd that commits
# while this collection is in flight must invalidate the popup, and a
# generation read at decision time would only be compared against itself.
generations = notifications.hook_generations(state)
(
out_sessions,
harnesses,
usage,
usage_supported,
usage_fetch_active,
) = self._collect_harnesses(now, window_hours, show_all)

# After every producer has reported and before anything is published, so
# one collection records at most one reading per window and the page sees
Expand Down Expand Up @@ -828,6 +837,8 @@ def collect(self, *, show_all: bool, notify: bool = True) -> Collection:
notify=notify,
)
out_sessions, cleared = _subtract_dismissed(out_sessions, cleared_marks)
if notify:
reach.maybe_reach_nudge(config, state, out_sessions, now=now)
_attach_cached_goals(config, out_sessions)
sessions.assign_display_ids(config, out_sessions)
out_sessions.sort(key=row_order)
Expand Down
15 changes: 15 additions & 0 deletions cargento/skills/cargento/cargento_runtime/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,19 @@ def build_parser() -> argparse.ArgumentParser:
"waiting agent"
),
)
parser.add_argument(
"--no-reach",
action="store_true",
help=(
"do not send off-machine nudges for this run, regardless of the "
"configured webhook URL. The off switch for the one pathway that "
"reaches outside this machine"
),
)
parser.add_argument(
"--reach-url",
help="webhook endpoint for off-machine nudges when a session needs human attention",
)
parser.add_argument(
"--no-irreversible",
action="store_true",
Expand Down Expand Up @@ -407,6 +420,8 @@ def build_runtime(
annotations_enabled=not args.no_annotations,
unasked_enabled=bool(args.unasked_readings),
ask_enabled=not args.no_ask,
reach_enabled=not args.no_reach,
reach_url=args.reach_url,
history_enabled=not args.no_history,
history_retention_sec=args.history_days * runtime_config.SECONDS_PER_DAY,
history_max_bytes=args.history_max_bytes,
Expand Down
12 changes: 12 additions & 0 deletions cargento/skills/cargento/cargento_runtime/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ class RuntimeConfig:
# the routes refuse, and the payload carries no `ask` flag, so the page
# offers no control rather than one that answers 503.
ask_enabled: bool
# Off-machine reach nudges (H2, DRC-4034,
# [DEC-4](SECURITY.md#off-machine-nudges-reaching-the-operator-away-from-the-desk)).
# `--no-reach` is the off switch that disables all outbound off-machine nudges for this run.
reach_enabled: bool
reach_url: str | None
reach_cooldown_sec: float
# The trailing window every published token rate is averaged over. What a
# row carries is therefore a MEAN and not an instantaneous reading, and at
# ten minutes it lags a burst by minutes. `sessions.rate_from` divides by it,
Expand Down Expand Up @@ -587,6 +593,9 @@ def build_runtime_config(
annotations_enabled: bool = True,
unasked_enabled: bool = False,
ask_enabled: bool = True,
reach_enabled: bool = True,
reach_url: str | None = None,
reach_cooldown_sec: float = 60.0,
history_enabled: bool = True,
history_retention_sec: float = HISTORY_RETENTION_DEFAULT_DAYS * SECONDS_PER_DAY,
history_max_bytes: int = HISTORY_MAX_BYTES_DEFAULT,
Expand Down Expand Up @@ -637,6 +646,9 @@ def build_runtime_config(
annotations_enabled=annotations_enabled,
unasked_enabled=unasked_enabled,
ask_enabled=ask_enabled,
reach_enabled=reach_enabled,
reach_url=reach_url,
reach_cooldown_sec=reach_cooldown_sec,
history_enabled=history_enabled,
# Ten minutes stays. The burn ordering (DRC-4011) wants the fastest
# session "right now", and this window is the reason it cannot have it:
Expand Down
52 changes: 23 additions & 29 deletions cargento/skills/cargento/cargento_runtime/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,25 @@ def _history_bound_argv(args: argparse.Namespace) -> list[str]:
return argv


def _opt_out_argv(args: argparse.Namespace) -> list[str]:
flags = [
("--no-spacedock", args.no_spacedock),
("--no-usage", args.no_usage),
("--no-git", args.no_git),
("--no-focus", args.no_focus),
("--no-events", args.no_events),
("--no-irreversible", args.no_irreversible),
("--no-dismiss", args.no_dismiss),
("--no-ask", args.no_ask),
("--no-history", args.no_history),
]
argv = [flag for flag, enabled in flags if enabled]
if getattr(args, "no_reach", False):
# SECURITY.md's off switch for off-machine reach nudges.
argv.append("--no-reach")
return argv


def spawn_argv(config: RuntimeConfig, args: argparse.Namespace) -> list[str]:
"""The complete argv for a re-spawned child, built from parsed values.

Expand All @@ -643,35 +662,10 @@ def spawn_argv(config: RuntimeConfig, args: argparse.Namespace) -> list[str]:
"--window-hours",
str(args.window_hours),
]
if args.no_spacedock:
argv.append("--no-spacedock")
if args.no_usage:
argv.append("--no-usage")
if args.no_git:
argv.append("--no-git")
if args.no_focus:
# SECURITY.md's focus off switch. Read off the namespace directly, like
# every branch around it, so a flag added to the parser and forgotten
# here raises rather than silently re-enabling a command the operator
# disabled: a respawned daemon that re-enables it is a security bug by
# the contract's own terms, and the two exact-set assertions in
# `test_lifecycle` are blind to an omitted branch.
argv.append("--no-focus")
if args.no_events:
argv.append("--no-events")
argv.extend(["--no-irreversible"] if args.no_irreversible else [])
if args.no_dismiss:
argv.append("--no-dismiss")
if args.no_ask:
argv.append("--no-ask")
if args.no_history:
# [DEC-6](SECURITY.md#local-history-the-session-history-store)'s off switch. Read off
# the namespace directly, like every branch
# above, so a flag added to the parser and forgotten here raises rather
# than silently re-enabling a store the user disabled: the two exact-set
# assertions in `test_lifecycle` are blind to an omitted branch, and the
# hand-written namespaces are what actually force this edit.
argv.append("--no-history")
argv.extend(_opt_out_argv(args))
reach_url = getattr(args, "reach_url", None)
if reach_url:
argv.extend(["--reach-url", reach_url])
argv.extend(_history_bound_argv(args))
# Forward the bind host only when the operator chose a non-default address,
# so a Windows --daemon re-spawn keeps a --host 0.0.0.0 bind instead of
Expand Down
Loading