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 .claude-plugin/marketplace.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"plugins": [],
"metadata": {
"description": "Agent collaboration plugin marketplace",
"version": "4.5.1",
"version": "4.5.2",
"repository": "https://github.com/sumitake/agent-collab"
}
}
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
{
"name": "agent-collab",
"description": "Unified dynamic-host collaboration package. Centralized skills and async coordination work without legacy packages; every model-execution route requires the verified signed plugin artifact.",
"version": "4.5.1",
"version": "4.5.2",
"author": {
"name": "John Osumi"
},
Expand All @@ -32,7 +32,7 @@
],
"metadata": {
"description": "Agent collaboration plugin marketplace",
"version": "4.5.1",
"version": "4.5.2",
"repository": "https://github.com/sumitake/agent-collab"
}
}
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ trust:
primary's identity, model, and session dynamically — no per-host forks to
drift out of sync.

This public repository distributes that one package, **agent-collab** (v4.5.1), and is
This public repository distributes that one package, **agent-collab** (v4.5.2), and is
the source of truth for the coordinator policy, skills, migration tooling, the
fail-closed runtime client, contribution governance, and release-safety checks.
The signed and notarized darwin-arm64 native runtime is committed in this
Expand Down Expand Up @@ -129,7 +129,25 @@ Contributors need no access to the private build/sign system. See

| Package | Version | Role |
|---|---:|---|
| `agent-collab` | 4.5.1 | Unified skills, dynamic host policy, migration preflight, and verified native-runtime client |
| `agent-collab` | 4.5.2 | Unified skills, dynamic host policy, migration preflight, and verified native-runtime client |

## What's new - v4.5.2

- **Broker status separates process configuration from momentary activity.**
Successful status reports `persistent_process=false` only after the exact
live launchd transcript proves that neither top-level
`KeepAlive`/`RunAtLoad` properties nor structured event triggers are
configured. Any event-trigger block is intentionally persistence-like:
ambiguity blocks readiness instead of being mislabeled socket-only.
`persistence_state` preserves the full `nonpersistent` / `persistent` /
`unproven` result; an unproven live format yields
`persistent_process=null` and fails readiness closed. A separate, optional
`process_idle` boolean-or-null reports bounded point-in-time quiescence only
when that idle probe actually ran; null means unmeasured, never idle. An
in-flight request or post-request grace therefore cannot make an otherwise
proven callable lane unavailable. Both new status observations are additive
and optional for rolling-upgrade consumers. Mutating lifecycle operations
never use either observation and still require their full idle proof.

## What's new - v4.5.1

Expand Down
30 changes: 30 additions & 0 deletions changelog.d/2026-07-28-dispatcher-status-semantics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
### agent-collab 4.5.2 — 2026-07-28

### Fixed

- Bind `broker-status`'s `persistent_process` field to an exact live launchd
properties proof instead of deriving it from a one-second point-in-time idle
observation or trusting only the mutable on-disk plist.
- Add `persistence_state=nonpersistent|persistent|unproven`; report the matching
`persistent_process=false|true|null`, fail readiness closed on unproven
diagnostic output, and never conflate unknown configuration with observed
persistence.
- Report that observation separately as `process_idle=true|false|null`; null
means the idle probe did not run, while a measured active process does not
make authoritative status reject a callable dispatcher during bounded
post-request grace.
- Keep both new status observations optional for rolling-upgrade consumers and
keep `process_idle` independent from the selected-lane liveness verdict when
present.
- Pin sanitized golden transcripts captured from real nonpersistent,
KeepAlive, and conditional-KeepAlive launchd jobs; closed brace structure,
top-level property tokens, and structured event-trigger evidence fail closed
on malformed or ambiguous format drift. Any event-trigger block is
intentionally persistence-like rather than eligible for socket-only status.
- Leave every mutating lifecycle command on the existing full idle-proof
boundary; a failed full-idle proof restores the pre-mutation selector.

### Cross-check

- Companion to workspace PR #2438. The revised cross-repo design is pending an
exact-head distinct-family review before either PR may merge.
2 changes: 1 addition & 1 deletion plugins/agent-collab/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-collab",
"version": "4.5.1",
"version": "4.5.2",
"description": "Unified dynamic-host collaboration package with centralized skills, migration preflight, and a verified plugin-relative native runtime boundary. The signed runtime artifact is intentionally absent until the private build/sign integration completes.",
"author": {
"name": "John Osumi"
Expand Down
2 changes: 1 addition & 1 deletion plugins/agent-collab/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-collab",
"version": "4.5.1",
"version": "4.5.2",
"description": "Unified dynamic-host collaboration package with centralized skills, migration preflight, and a verified plugin-relative native runtime boundary. The signed runtime artifact is intentionally absent until the private build/sign integration completes.",
"author": {
"name": "John Osumi"
Expand Down
27 changes: 19 additions & 8 deletions plugins/agent-collab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ verifiable compliance evidence, and operator final-say, delivered as one
package for every supported host. This document is the package's technical
reference; the repository README carries the purpose and governance narrative.

Current: **4.5.1**
Current: **4.5.2**

It resolves `primary_id`, `primary_family`, `active_model`, `host_runtime`, and
`session_identifier` from the current host or explicit configuration. ZCode
Expand Down Expand Up @@ -65,7 +65,7 @@ one macOS `LC_BUILD_VERSION` with minimum macOS 14.0 instead of trusting those
manifest labels. The broker transport and provider protocol are both version 2.
The package
carries both `.claude-plugin/plugin.json` and `.codex-plugin/plugin.json`; both
identify this same 4.5.1 package.
identify this same 4.5.2 package.

Codex, Gemini, OpenCode, Grok, and Composer are broker-only contracts. Their sealed requests cross a
mode-`0600`, digest-bound per-user launchd Unix socket; launchd starts the exact
Expand Down Expand Up @@ -172,7 +172,7 @@ retained-lane rollback continuity; any received v2 discriminator selects only
v2. Dual-version callers use `GEMINI_GOVERNANCE_PROOF_KEYSETS`; the legacy
`GEMINI_GOVERNANCE_PROOF_KEYS` alias remains v1-only.

This 4.5.1 source tree carries the rebuilt darwin-arm64 activation artifact
This 4.5.2 source tree retains the rebuilt darwin-arm64 activation artifact
from final workspace `1.0.823` commit
`d08b6382710d6d5910d64cf011bcac873a2e1c03`. Its manifest pins the complete
standalone bundle at SHA-256
Expand Down Expand Up @@ -347,11 +347,22 @@ atomically activates a closed plist, proves the job/socket and one-request
process exit, and retains one verified prior record. Failed updates restore the
complete prior state; same-version reactivation preserves its rollback target,
and an unverified version is never recorded as rollback-safe. `broker-status`
is read-only and emits no prompt, credential, provider output, or private path;
an exact selected-lane ping proves callability, while a separate one-second
quiescence observation reports `persistent_process` without making an active
request look unavailable. Lifecycle mutations still require their full idle
proof. Bounded `launchctl` collection failures return a typed lifecycle error.
is read-only and emits no prompt, credential, provider output, or private path.
Before the exact selected-lane ping may prove callability, the live launchd
job's closed transcript must contain one valid top-level `properties =` line
and no `KeepAlive`, `RunAtLoad`, or structured event-trigger evidence. Any
event-trigger block is intentionally persistence-like: ambiguous lifecycle
configuration blocks readiness rather than being mislabeled socket-only.
`persistence_state` reports `nonpersistent`, `persistent`, or `unproven`; the
matching `persistent_process` value is `false`, `true`, or `null`, and an
unproven diagnostic format fails readiness closed. A separate optional
one-second quiescence observation reports `process_idle=true|false` only after
that probe runs; otherwise it is null, meaning unmeasured rather than idle.
Bounded request activity or post-request grace therefore does not masquerade
as configured persistence. Both new status observations are additive and
optional for rolling-upgrade consumers. Lifecycle mutations never use either
observation and still require their full idle proof. Bounded `launchctl`
collection failures remain typed and auditable.

Use the closed rollback/removal actions only when needed:

Expand Down
180 changes: 158 additions & 22 deletions plugins/agent-collab/runtime_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from dataclasses import dataclass, replace
from enum import Enum
from pathlib import Path, PurePosixPath
from typing import Any, Callable, Iterator, Mapping
from typing import Any, Callable, Iterator, Mapping, Sequence

try:
import fcntl as _fcntl
Expand Down Expand Up @@ -321,6 +321,15 @@ def _read_execute_blank_ranges() -> tuple[tuple[int, int], ...] | None:
_JOB_LABEL_RE = re.compile(
r"com\.agent-collab\.provider-(?:broker|dispatcher\.[0-9a-f]{32})"
)
_LAUNCHCTL_PROPERTIES_VALUE_RE = re.compile(r"^[A-Za-z0-9_ |\-]*$")
_LAUNCHCTL_KEY_VALUE_LINE_RE = re.compile(
r"^(?P<indent>[ \t]+)"
r"(?P<key>[A-Za-z][A-Za-z0-9 _-]*?)"
r"[ \t]*=[ \t]*(?P<value>.*)$"
)
_PERSISTENCE_NONPERSISTENT = "nonpersistent"
_PERSISTENCE_PERSISTENT = "persistent"
_PERSISTENCE_UNPROVEN = "unproven"
_TEAM_ID_RE = re.compile(r"^[A-Z0-9]{10}$")
_DEVELOPER_ID_RE = re.compile(
r"^Developer ID Application: [^\r\n]{1,160} \(([A-Z0-9]{10})\)$"
Expand Down Expand Up @@ -4435,6 +4444,109 @@ def _job_loaded(label: str, *, deadline: float | None = None) -> bool:
).returncode == 0


def _job_persistence_state(label: str) -> str:
"""Observe one live job's closed launchd persistence projection."""

if not isinstance(label, str) or _JOB_LABEL_RE.fullmatch(label) is None:
raise ValueError("provider job label is invalid")
uid = os.getuid()
try:
result = _launchctl(["print", f"gui/{uid}/{label}"])
except (OSError, RuntimeError, subprocess.SubprocessError):
return _PERSISTENCE_UNPROVEN
if result.returncode != 0 or not isinstance(result.stdout, str):
return _PERSISTENCE_UNPROVEN
lines = result.stdout.splitlines()
expected_header = f"gui/{uid}/{label} = {{"
if (
not lines
or lines[0] != expected_header
or lines[-1] != "}"
or not _launchctl_transcript_structure_closed(lines)
):
return _PERSISTENCE_UNPROVEN

properties: list[str] = []
event_triggers = 0
nested_keepalive = 0
malformed_persistence_shape = False
for line in lines[1:-1]:
match = _LAUNCHCTL_KEY_VALUE_LINE_RE.fullmatch(line)
if match is None:
continue
indent = match.group("indent")
key = " ".join(match.group("key").casefold().split())
value = match.group("value").strip()
if key == "properties":
if (
indent != "\t"
or _LAUNCHCTL_PROPERTIES_VALUE_RE.fullmatch(value) is None
):
malformed_persistence_shape = True
else:
properties.append(value)
elif key == "event triggers":
if indent != "\t" or value != "{":
malformed_persistence_shape = True
else:
event_triggers += 1
elif key == "keepalive":
if indent != "\t\t\t" or value not in {"0", "1"}:
malformed_persistence_shape = True
else:
nested_keepalive += 1
elif key == "runatload":
# Current launchctl projects RunAtLoad through the top-level
# properties token. A standalone key is an unknown transcript
# shape, never evidence that the job is nonpersistent.
malformed_persistence_shape = True

if (
malformed_persistence_shape
or len(properties) != 1
or event_triggers > 1
or (nested_keepalive and event_triggers != 1)
):
return _PERSISTENCE_UNPROVEN
tokens = {
token
for token in re.split(r"[|\s]+", properties[0].casefold())
if token
}
if (
tokens.intersection({"keepalive", "runatload"})
or event_triggers == 1
or nested_keepalive
):
return _PERSISTENCE_PERSISTENT
return _PERSISTENCE_NONPERSISTENT


def _launchctl_transcript_structure_closed(lines: Sequence[str]) -> bool:
"""Require a complete tab-indented launchctl brace transcript."""

depth = 1
last_index = len(lines) - 1
for index, line in enumerate(lines[1:], start=1):
stripped = line.lstrip("\t")
indent = len(line) - len(stripped)
if stripped == "}":
if indent != depth - 1:
return False
depth -= 1
if depth == 0 and index != last_index:
return False
continue
if stripped.endswith(" = {") or stripped.endswith(" => {"):
if indent != depth:
return False
depth += 1
continue
if stripped and (indent != depth or "{" in stripped or "}" in stripped):
return False
return depth == 0


def _broker_job_loaded() -> bool:
return _job_loaded(BROKER_LABEL)

Expand All @@ -4449,8 +4561,9 @@ def _broker_job_loaded() -> bool:
# skips the ping and so silently masked the miscalibration.
BROKER_COLD_START_TIMEOUT_SECONDS = 30.0
# Status needs only a short point-in-time quiescence observation after a
# successful liveness ping. Lifecycle mutations keep their full idle-proof
# bound and still require ``persistent_process`` to be false.
# successful liveness ping. ``process_idle`` reports that observation;
# ``persistence_state`` separately records the live launchd-properties proof.
# Lifecycle mutations keep their full idle-proof bound.
BROKER_STATUS_IDLE_OBSERVATION_SECONDS = 1.0
# Upper bound on how long teardown may poll to reap a SIGKILLed leader when no
# caller deadline is supplied (preserves the pre-existing wait bound). When a
Expand Down Expand Up @@ -6284,26 +6397,37 @@ def broker_status() -> RuntimeResult:
lane.socket_path, expected_type=stat.S_IFSOCK, mode=0o600
) is not None
ready = False
persistent_process = False
process_idle: bool | None = None
persistence_state = _PERSISTENCE_UNPROVEN
persistent_process: bool | None = None
if loaded and socket_valid:
if lane.transport == "dispatcher":
ping = invoke_dispatcher_ping(lane=lane)
live = ping.status is RuntimeStatus.OK
persistence_state = _job_persistence_state(lane.label)
if persistence_state == _PERSISTENCE_NONPERSISTENT:
persistent_process = False
if lane.transport == "dispatcher":
ping = invoke_dispatcher_ping(lane=lane)
live = ping.status is RuntimeStatus.OK
else:
live = _broker_ping(lane.socket_path)
idle = _wait_for_job_idle(
lane.label,
deadline=(
time.monotonic()
+ BROKER_STATUS_IDLE_OBSERVATION_SECONDS
),
)
# Liveness proves the selected lane is callable. Idleness
# is a separate lifecycle/quiescence signal: a legitimate
# in-flight request can keep the socket-activated job
# running after this ping without making the lane
# unavailable. Keep the observation even when liveness
# fails; overall status still follows liveness.
ready = live
process_idle = idle
elif persistence_state == _PERSISTENCE_PERSISTENT:
persistent_process = True
else:
live = _broker_ping(lane.socket_path)
idle = _wait_for_job_idle(
lane.label,
deadline=(
time.monotonic()
+ BROKER_STATUS_IDLE_OBSERVATION_SECONDS
),
)
# Liveness proves the selected lane is callable. Idleness is a
# separate lifecycle/quiescence signal: a legitimate in-flight
# provider request can keep the socket-activated job running
# after this ping without making the lane unavailable.
ready = live
persistent_process = not idle
persistence_state = _PERSISTENCE_UNPROVEN

rollback_available = False
retained = selector.get("retained")
Expand All @@ -6327,11 +6451,23 @@ def broker_status() -> RuntimeResult:
"rollback_available": rollback_available,
"dispatcher_ready": ready and lane.transport == "dispatcher",
"persistent_process": persistent_process,
"persistence_state": persistence_state,
"process_idle": process_idle,
},
error=(
""
if status is RuntimeStatus.OK
else "provider selected lane is installed but not executable"
else (
"provider selected lane is configured for process persistence"
if persistence_state == _PERSISTENCE_PERSISTENT
else (
"provider selected lane live persistence configuration is unproven"
if loaded
and socket_valid
and persistence_state == _PERSISTENCE_UNPROVEN
else "provider selected lane is installed but not executable"
)
)
),
)
state = _read_current_broker_state(root)
Expand Down
Loading
Loading