Skip to content
Open
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
68 changes: 64 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ you are unsure about.

Two things are deliberately allowed and are not leaks:

- `claude-fleet` — the real exported id of the legacy local adapter that ships
here (`packages/core/src/fleet-adapter.ts`). Banning the string would hide
the code rather than clean it up.
- `claude-fleet` — the runtime id used by the example runtime module in
`examples/local-tmux-runtime/`. It is an example, not shipped code, and
banning the string would hide it rather than let it demonstrate the seam.
- The `lambda-curry` GitHub org in repository URLs — that is this project's
actual public home.

Expand Down Expand Up @@ -76,6 +76,54 @@ Conventions the surface enforces, all covered by that test:
`prompt` preset returns no `status` at all.
- `run_task` is the only capability that mutates anything.

## `run_task`'s payload is not a second `context`

`task` and `context` reach the agent as ONE conversational message. A brief of
the form "you are the manager; write the context to a file; then launch the
worker" therefore reaches the manager AND the worker — the manager faithfully
passes the whole brief onward, the worker reads the same manager instructions,
concludes it is the manager, and launches another worker. Observed on two
independent dispatches; every status surface above the worker looked healthy
throughout.

`payload` (`packages/core/src/payload-store.ts`) is the structural fix: the
bytes never enter the instruction stream at all. The server materialises the
blob to a file and the agent is told only the path. Never parse, interpret,
template, truncate, or echo a payload, and never return its contents from a
read tool — `get_task` reports `payloadPath` and nothing more. Retention is
**TTL-based, never terminal-based**: the worker routinely outlives the job that
launched it, so deleting on completion pulls the file out from under a live
reader.

**A failed WRITE fails the dispatch; a failed SWEEP is invisible. Do not
collapse the two.** A sweep is not load-bearing — nobody asked for it and
nothing reads its result — so it must never block a task. A write is
load-bearing by construction: the caller passed a payload, the task text names
the file, and dispatching without it sends a task referencing data the agent
cannot find, which reads exactly like a task that never had a payload. That is
the same failure-rendered-as-a-state this repo keeps fixing. `write` therefore
returns `string` and throws — there is deliberately no "returned nothing"
branch to tempt a caller — and `submitTask` refuses through the same rejection
path a "session busy" collision uses. A missing payload store counts as a
failed write. No fallback, no flag.

## A failed store read must not destroy the store

`JsonFileJobStore` and `JsonFileAttachmentStore` distinguish "the file is not
there" (empty is a fact) from "the file could not be read" (empty is a lie).
Both used to answer `[]` for either, and since `persistActiveJobs` is a
whole-map overwrite, the first save after a failed load wrote the truncated set
over the only file that could have shown what was lost. The evidence destroyed
itself.

An unreadable file is renamed aside (`<file>.corrupt-<timestamp>`) before
anything can overwrite it, and the degradation is reported through the store's
`onDegraded` sink — `GatewayPool` collects it and `get_connection_info` serves
it as `degradedStores`, which is the tool a supervisor already calls when
something looks inconsistent. If the preservation itself fails, the store
**refuses to save**: not persisting is recoverable, shredding the only copy is
not. Keep both stores identical here.

## Deploying a tool-surface change

**ChatGPT freezes the approved tool snapshot.** Editing a declaration and
Expand Down Expand Up @@ -118,14 +166,26 @@ tasks/cancel` can later be a thin adapter over it — which mainly means not
baking today's `check_task` wire shape into the job model itself. The
capability layer is where that adapter goes.

## The runtime seam is optional
## The runtime seam is optional, and core ships no runtime

ClawConnect never starts, chooses, or enumerates an agent session. There is no
spawn and no list callback, and that is structural, not a policy — do not add
one. A default install registers **no** runtime: every MCP tool behaves
identically without one, and an attachment for an unregistered runtime reads
back as a normalized `unknown_runtime` result rather than an error.

`packages/core` holds the neutral registry, the attachment model, and the
callback seam — and **no concrete adapter**. It shipped one until 2026-08-18
(a tmux/`~/.claude-fleet` adapter that both entrypoints constructed by
default), which meant core knew about exactly one runtime while its own design
notes said it knew about none. That adapter now lives in
`examples/local-tmux-runtime/` and reaches the connector through
`CLAWCONNECT_AGENT_SESSION_RUNTIME_MODULES` like any host module. Do not
reintroduce a runtime-specific import, constant, or `ResultSource` value into
core: how strongly a runtime can vouch for what it returns is a claim made
inside that runtime's module, where the evidence is, and the record already
names which runtime answered.

A host either embeds the library and passes `agentSessionRuntimes`, or runs a
shipped binary and names ES modules via
`CLAWCONNECT_AGENT_SESSION_RUNTIME_MODULES`. If you change either path, update
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pnpm run ready
The core, unversioned task-contract surface — same names and behavior across every client (Claude Code, Cursor, Codex, ChatGPT, the CLI); see `docs/decisions/2026-07-27-task-contract.md` and `docs/architecture/2026-07-27-multi-client-compatibility.md` for the accepted contract and the implementation boundaries behind it:

- **`run_task`** — Submit a task to your OpenClaw agent. Returns `jobId`/`taskId`, `sessionKey`, status, and a structured `nextAction` (`{tool: "check_task", args: {jobId, sessionKey}}`) telling the caller exactly what to call next. `nextAction.args` uses `check_task`'s own parameter names, so it can be forwarded verbatim — that's why the identifier there is `jobId`, not the `taskId` alias carried at the top level.
- **`run_task` — the `payload` argument** — Opaque content that is **not addressed to the agent**. ClawConnect writes it to a file (mode `0600`, under `~/.clawconnect/payloads/`, overridable via `CLAWCONNECT_PAYLOAD_DIR`) and puts only the **path** in the message the agent receives, followed by a short note that the contents are data to hand onward rather than instructions to follow. It is never parsed, templated, truncated, or echoed, and no read tool returns its contents — `get_task` reports the `payloadPath` so a supervisor can see a payload existed and where it went. Files are swept on a **24h TTL**, never on job completion: the worker a payload was written for routinely outlives the job that launched it, and deleting on completion would pull the file out from under a live reader. If the payload cannot be stored, the task is **refused** rather than dispatched without it — a task whose text names a file the agent cannot find is indistinguishable from one that never had a payload, and the error tells the caller nothing is running so a retry is not a duplicate. (A failed *sweep*, by contrast, is silent and never blocks a dispatch.)
- **`check_task`** — The only tool that waits. Blocks server-side for up to `waitMs` (default **45000ms**, override per call, clamped to `[1000, 120000]` — out-of-range values clamp rather than error) and returns early on a terminal status (`completed` / `completed_no_summary` / `error`). A timeout return is **not** an error and **not** terminal: `continuePolling` is `true`, `nextAction` says to call `check_task` again with the same `jobId`, and `retryAfterMs` suggests a delay before that next call (`0` normally — a wait-mode call already blocked for its full window, so calling again immediately is fine; `10000` during late-recovery, since the transcript is only re-read on that cadence server-side) — never submit a new `run_task` because a poll timed out (the session-busy guard would refuse it as a duplicate anyway). `mode: "poll"` (also bounded by `waitMs`) returns as soon as any new log activity appears, for live-progress use cases distinct from "give me the final result". `completed_no_summary` and `error` are terminal and should be reported as such; a single follow-up poll ~30s later can occasionally upgrade a long tool-heavy run whose final text landed after the connector marked it terminal, but that is the exception, not the loop.
- **`get_task`** — An **immediate, non-waiting** snapshot for diagnostics, manual reads, or UI refresh — including `status: "running"` if that's the current truth. Never blocks, unlike `check_task`. `detail` controls which fields come back (`core`/`summary`/`updates`/`artifacts`/`diagnostics`/`full`/`fullWithDiagnostics`), plus `detail: "prompt"` to retrieve the original submitted `{task, context, senderName}` — not included at any other detail level, so it never appears in a normal response by accident. This is also the read path for a task's **complete** summary and artifacts.
- **`cancel_task`** — Sends a run-scoped `chat.abort` and waits for bounded terminal reconciliation. A confirmed abort returns `status: "cancelled"`; an unreachable or unconfirmed abort returns a terminal, actionable error instead of leaving the task indefinitely `running`. Concurrent duplicate calls join the same cancellation operation.
Expand Down
41 changes: 12 additions & 29 deletions apps/chatgpt/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApp, checkTaskText } from "./app.ts";
import { AgentSessionRuntimeRegistry } from "@clawconnect/core";
import type { AgentRegistry, FleetAdapter, JobSnapshot } from "@clawconnect/core";
import type { AgentRegistry, JobSnapshot } from "@clawconnect/core";
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";

/**
Expand Down Expand Up @@ -735,30 +735,7 @@ describe("check_task model-facing text carries the resume cursor", () => {
});
});

describe("production entrypoint wiring — FleetAdapter", () => {
/**
* Independent-review blocker 1: recovery tier 3 (see docs/architecture/
* 2026-08-02-managed-fleet-attachment-plan.md) is only reachable if a real
* FleetAdapter is actually injected in production, not just implemented and
* left unwired. Proves createApp() does this by default, without spinning
* up a real recovery scenario — SessionManager.hasFleetAdapter() is the
* dedicated, minimal surface for exactly this assertion.
*/
it("createApp wires a real FleetAdapter into every agent's SessionManager by default", () => {
const jobStoreDir = mkdtempSync(join(tmpdir(), "clawconnect-jobstore-"));
tmpDirs.push(jobStoreDir);
const { pool } = createApp(fakeRegistry(), { jobStoreDir });
expect(pool.forAgent("test-agent").sessions.hasFleetAdapter()).toBe(true);
});

it("createApp lets a caller override the FleetAdapter (e.g. a test injecting a fake)", () => {
const jobStoreDir = mkdtempSync(join(tmpdir(), "clawconnect-jobstore-"));
tmpDirs.push(jobStoreDir);
const fake: FleetAdapter = { isLive: async () => false, readTerminalHandoff: async () => null };
const { pool } = createApp(fakeRegistry(), { jobStoreDir, fleetAdapter: fake });
expect(pool.forAgent("test-agent").sessions.hasFleetAdapter()).toBe(true);
});

describe("production entrypoint wiring — agent-session runtimes", () => {
/**
* Same failure mode, one layer up: a host's managed-agent-session runtimes
* are only reachable if the registry actually reaches every agent's
Expand All @@ -781,12 +758,18 @@ describe("production entrypoint wiring — FleetAdapter", () => {
);
});

it("createApp leaves claude-fleet the only reachable runtime when no registry is supplied", () => {
/**
* The default install registers NOTHING. Core ships no runtime of its own —
* an entrypoint that quietly constructed one would make that claim false,
* which is exactly what the built-in tmux adapter did until it moved out to
* examples/local-tmux-runtime.
*/
it("createApp registers no runtime at all when no registry is supplied", () => {
const jobStoreDir = mkdtempSync(join(tmpdir(), "clawconnect-jobstore-"));
tmpDirs.push(jobStoreDir);
const { pool } = createApp(fakeRegistry(), { jobStoreDir });
expect(pool.forAgent("test-agent").sessions.hasAgentSessionRuntime("example-runtime")).toBe(
false,
);
const sessions = pool.forAgent("test-agent").sessions;
expect(sessions.hasAgentSessionRuntime("example-runtime")).toBe(false);
expect(sessions.hasAgentSessionRuntime("claude-fleet")).toBe(false);
});
});
18 changes: 8 additions & 10 deletions apps/chatgpt/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@ import { Hono } from "hono";
import { McpServer, createMcpHandler, isJsonContentType } from "@modelcontextprotocol/server";
import type { AuthInfo, McpRequestContext } from "@modelcontextprotocol/server";
import { toWebRequest } from "@modelcontextprotocol/node";
import { GatewayPool, LocalTmuxFleetAdapter, buildCapabilities } from "@clawconnect/core";
import { DEFAULT_PAYLOAD_DIR, FilePayloadStore, GatewayPool, buildCapabilities } from "@clawconnect/core";
import { registerCapability } from "@clawconnect/mcp";
import type {
AgentRegistry,
AgentSessionRuntimeRegistry,
ContinuationState,
FleetAdapter,
Identity,
JobSnapshot,
Scope,
Expand Down Expand Up @@ -43,15 +42,15 @@ export interface CreateAppOptions {
/** Directory for per-agent job-persistence files. Defaults on — override so tests write into a scratch dir. */
jobStoreDir?: string;
/**
* Fleet-transcript recovery adapter. Defaults to a real LocalTmuxFleetAdapter
* so recovery tier 3 is actually reachable in production. Override in tests
* to inject a fake and assert on wiring.
* Directory for run_task's opaque payload files (see core's
* payload-store.ts). Defaults to `~/.clawconnect/payloads`; override so
* tests write into a scratch dir.
*/
fleetAdapter?: FleetAdapter;
payloadDir?: string;
/**
* Managed-agent-session runtimes this deployment can drive (see
* agent-session.ts). Omitted, claude-fleet stays the only reachable runtime
* and an attachment naming any other reads back as a precise
* agent-session.ts). Omittedthe default install — no attachment has
* anything to ask, and one naming any runtime reads back as a precise
* unknown_runtime result rather than failing the task.
*/
agentSessionRuntimes?: AgentSessionRuntimeRegistry;
Expand Down Expand Up @@ -132,13 +131,12 @@ export function createApp(registry: AgentRegistry, opts: CreateAppOptions = {}):
}

const hono = new Hono();
const fleetAdapter = opts.fleetAdapter ?? new LocalTmuxFleetAdapter();
const pool = new GatewayPool(
registry,
opts.jobStoreDir ?? DEFAULT_JOB_STORE_DIR,
fleetAdapter,
undefined,
opts.agentSessionRuntimes,
new FilePayloadStore(opts.payloadDir ?? DEFAULT_PAYLOAD_DIR),
);
// Reload every configured agent's persisted jobs now, not lazily on first
// request — otherwise an agent nobody has queried yet since the restart
Expand Down
48 changes: 29 additions & 19 deletions docs/architecture/runtime-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,22 +307,32 @@ ChatGPT HTTP app — ClawConnect is exactly what it was before this seam:
A delegation can never make a ClawConnect task fail. That is the point of
returning structured unavailability instead of throwing.

### Legacy local adapter

One built-in adapter predates this seam and remains for backward
compatibility: a local-only recovery path for existing `claude-fleet`
attachments, which reads tmux liveness and a terminal transcript. It is
**not** a runtime selector and not a general integration path:

- It is consulted only for an attachment whose `runtime` is exactly
`claude-fleet`.
- A host that registers `claude-fleet` itself takes precedence over it.
- It offers `inspect` only — a tmux probe cannot deliver a turn or end a
session, so `continue`/`detach` report as unsupported.
- It reports liveness and nothing else. It never claims a state, because a
bare liveness bit cannot distinguish "working" from "waiting on a human".

New runtime wiring belongs in a host-supplied registry, not here.
### No built-in adapter

`packages/core` ships no runtime. There is no default, no fallback, and no
runtime-specific constant anywhere in it — only the registry, the attachment
model, and the callback seam.

Until 2026-08-18 that was not true. Core shipped `LocalTmuxFleetAdapter` (tmux
liveness plus a `~/.claude-fleet/<handle>/meta.json` transcript read), both
entrypoints constructed it by default, and a `"fleet-transcript"` value in the
core `ResultSource` type named its provenance. So core knew about exactly one
runtime while this document said it knew about none. That adapter now lives in
`examples/local-tmux-runtime/` and reaches a deployment through
`CLAWCONNECT_AGENT_SESSION_RUNTIME_MODULES` like any host module.

One consequence worth stating, because it looks like a loss and is not. A
delegated result's `resultSource` is now always `"agent-session"`; there is no
value naming a particular runtime's evidence. How strongly a runtime can vouch
for what it returns is a claim made inside that runtime's module, where the
evidence is — the example still refuses to read a transcript until the tmux
pane has ENDED, and still skips an entry it cannot date. ClawConnect keeps its
own checks on top (the turn must be a completed one, the answer must be
datable, and it must post-date the job it would answer) and otherwise takes the
module's word, because it has no way to verify the claim and restating an
unverifiable one is worse than not making it. A reader who wants to know what
answered reads `agentSession.runtime` on the same snapshot, which names the
actual runtime rather than a category.

## Keeping this boundary honest

Expand All @@ -333,9 +343,9 @@ can contradict, and runs with the ordinary suite (`vp test`). It asserts that:
- no document references an internal absolute path or thread/artifact id;
- neither entry point registers a runtime of its own.

Two deliberate exemptions. The legacy adapter's id `claude-fleet` is allowed
everywhere, because it is a real exported identifier in this repository and
banning the string would hide the code rather than clean it up. The dated
Two deliberate exemptions. The id `claude-fleet` is allowed everywhere,
because it is the runtime id used by `examples/local-tmux-runtime/` and
banning the string would hide the example rather than clean it up. The dated
documents under `docs/architecture/` and `docs/decisions/` are historical
build records carrying their own non-normative banners; they are checked for
internal references but not for host names, since rewriting a record to look
Expand Down
Loading