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
54 changes: 54 additions & 0 deletions architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -5640,6 +5640,60 @@ that leaves no record for anything else to observe.
directory, identifier and filesystem operations; nothing under `repl/` names a
runtime.

### What one REPL command settles before it draws

The command line is read, the Agent configuration is settled, and then one
immutable profile is assembled in the command's own scope: the selected Plugins,
the Agent identity components, the packaged `<Plan>` declaration and the ordinary
evaluation ceiling, with the settled permission mode beside them. The program
takes that one profile and nothing else, so what an entry may resolve, what
ceiling a generated fragment runs under and how a permission request is answered
are facts about the command a person invoked rather than about the entry they
typed. The profile carries data and installations, never authority: no stack,
provider, Plan writer, live request or scope reaches the application model, the
route or the Journal through it.

The `<Plan>` the REPL declares is the packaged Component `xmd plan` runs, and the
ceiling around a returned program is the shared ordinary profile — whose write
table holds paired canonical `<Elicit>` beside `<File>` and `<File.Delete>`, so a
generated program may ask before it writes. That entry is a pinned capability
rather than a name the fragment resolves: preflight selects it where an
`<Elicit>` occurrence appears under a `write` selection, and the body it selects
is core's own `<Elicit>`, closed over before any document code runs. No component
name is resolved for it, so a same-name repository, registered, declared,
middleware or separately loaded definition inherits no grant and never runs, and
an execution that never writes the element resolves nothing. Only the interaction
is contextual: the pinned body asks through the Elicitation Api lexically in
scope, which is what lets the REPL drawer answer a generated question and an
`<Answers>` region answer it without anybody being asked.

Permission composes outermost-first: Core's own audit observer, then this
session's authoritative policy, then whatever the document and provider
installed, then the base denial. One private ledger per inherited Prompt scope
holds what was granted, and a retained audit is read rather than answered. The
live overlay, the application and the terminal have one owner each — the session,
the root transition boundary and the screen — and every ending cancels and joins
that owner rather than appending on its way out.

Ownership in the model is read from the Journal and from nothing else. An
import's retained selection says where its scope's source came from — a path for
a component read from somewhere, the retained origin for one the host declared —
and that origin is the path the effects inside it are recorded against. A
generated fragment has no file, so the work inside it names the admission it was
decided under, and the scope that admission created is what owns it: the
identity chooses the candidate, and journal order and coroutine ancestry only
say whether that candidate could be its owner — an admission that happened afterwards,
or on work the effect is not part of, owns nothing however recently it ran. Two
sequential fragments are therefore sibling scopes, work in a fragment's spawned
descendants belongs to that fragment, and concurrent siblings are independent of
which finished first. A record naming a source this prefix never admitted, one it
admitted twice, or two sources at once is refused rather than attached to a
guessed owner.

This command installs no foreground launcher, no browser elicitation and no
readline permission handling, reads no runtime global, and adds no durable record
family of its own: what it retains is the ordinary events any other run writes.

## Changing these rules

Spec, tests, and mechanics move together, in the same PR. If a workaround
Expand Down
14 changes: 12 additions & 2 deletions packages/cli/src/agent-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,19 @@ export function hostAcpDependencies(stack: PlanWriterStack): AcpxProviderDepende
*
* Nothing here starts an agent. The provider validates availability on first
* use, and an embedded adapter reaches the disk at that same point.
*
* `acp` is the seam `planAgentContext` already takes, for the same reason and on
* the same terms: production states none and gets what this host carries, while
* a journey states a scriptable runtime so it can drive this exact provider
* stack rather than a copy of it. It reaches the subprocess boundary and nothing
* above it — the provider, the components, the policy and the record are the
* product's own.
*/
export function* installAgentProviderStack(stack: AgentStack): Operation<void> {
const acpx = createAcpxProvider(hostAcpDependencies(stack));
export function* installAgentProviderStack(
stack: AgentStack,
acp?: AcpxProviderDependencies,
): Operation<void> {
const acpx = createAcpxProvider(acp ?? hostAcpDependencies(stack));
yield* registerAgentProvider("acpx", acpx);

// The trusted host selects its own root provider by name. Document-level
Expand Down
147 changes: 115 additions & 32 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ import {
resolvePlanWriterStack,
} from "./agent-stack.ts";
import { planComponentDeclaration } from "./plan-component.ts";
import { assembleReplProfile } from "./repl-profile.ts";
import { planAgentContext } from "./plan-writer-profile.ts";
import { useVerboseComponent } from "./verbose-component.ts";
import type { AgentStack } from "./agent-stack.ts";
Expand Down Expand Up @@ -208,6 +209,41 @@ const SECRET_DETECTION_FIELD = {
* a journal, a permission mode, an exec deadline or a presentation option would
* each configure work this command never performs.
*/
/**
* The Agent configuration two commands share.
*
* Declared once and spread into both, because `xmd run` and `xmd repl` configure
* the same thing: which provider answers, which agent a `<Session>` means by
* default, and how a permission request is decided. A second table would be two
* descriptions of one option, free to drift in wording, default or precedence —
* and a person reading either `--help` would have no way to tell which was true.
*
* `xmd test` refuses all five, because its agents are the deterministic
* `<TestAgent>` stack, and `xmd plan` accepts only the two that say who writes.
*/
const agentFields = {
agentProvider: {
description: "agent provider for agent components",
...field(z.string(), field.default("acpx")),
},
defaultAgent: {
description: "default agent name (overrides DEFAULT_AGENT_NAME)",
...field(z.string().optional()),
},
approveAll: {
description: "approve every agent permission request",
...field(z.boolean(), field.default(false)),
},
approveReads: {
description: "approve read and search agent permissions, ask for the rest (default)",
...field(z.boolean(), field.default(false)),
},
denyAll: {
description: "deny every agent permission request",
...field(z.boolean(), field.default(false)),
},
};

const executionFields = {
include: {
description: "component search directory",
Expand All @@ -227,14 +263,7 @@ const executionFields = {
description: "output raw markdown without normalization or terminal formatting",
...field(z.boolean(), field.default(false)),
},
agentProvider: {
description: "agent provider for agent components",
...field(z.string(), field.default("acpx")),
},
defaultAgent: {
description: "default agent name (overrides DEFAULT_AGENT_NAME)",
...field(z.string().optional()),
},
...agentFields,
timeout: {
description: "deadline for the whole run, as a duration (500ms, 30s, 5min)",
...field(z.string().optional()),
Expand All @@ -247,18 +276,6 @@ const executionFields = {
description: "default timeout for each fetch, as a duration (500ms, 30s, 5min)",
...field(z.string().optional()),
},
approveAll: {
description: "approve every agent permission request",
...field(z.boolean(), field.default(false)),
},
approveReads: {
description: "approve read and search agent permissions, ask for the rest (default)",
...field(z.boolean(), field.default(false)),
},
denyAll: {
description: "deny every agent permission request",
...field(z.boolean(), field.default(false)),
},
secretDetection: SECRET_DETECTION_FIELD,
};

Expand Down Expand Up @@ -290,10 +307,12 @@ const REPL_DESCRIPTION = "Run and reconstruct one XMD entry in an interactive te
* the command reopens exactly that retained history and selects exactly what the
* location names; with none, it starts a fresh execution with an empty draft.
*
* No option a run configures appears here. This command renders no file, takes
* no document reference and spends no model turn beyond what the one entry a
* person types asks for, so a permission mode, an exec deadline, a props file
* and a journal each configure work this command never performs.
* The entry a person types may run Agent work, so this command configures the
* Agent the same way `xmd run` does: the five shared fields, with the same
* descriptions, defaults and environment precedence. Everything else a run
* configures is absent — this command renders no file, takes no document
* reference and writes no journal of its own, so an exec deadline, a props file
* and a trace each configure work it never performs.
*/
const replConfig = object({
location: {
Expand All @@ -302,6 +321,7 @@ const replConfig = object({
"that retained history and the exact view it names",
...field(z.string().optional(), cli.argument()),
},
...agentFields,
});

/** What `xmd --help` says the plan command is for. */
Expand Down Expand Up @@ -588,21 +608,60 @@ export type ReplHostInstaller = () => Operation<void>;
* ignored — a caller who wrote `--json` asked for something, and silence would
* let them believe they got it.
*/
/** The two REPL options that carry a value, by the spelling a caller writes. */
const REPL_VALUES: readonly string[] = ["--agent-provider", "--default-agent"];

/** The three REPL switches, which carry none. */
const REPL_SWITCHES: readonly string[] = ["--approve-all", "--approve-reads", "--deny-all"];

export function replGrammarError(
args: readonly string[],
location: string | undefined,
): string | undefined {
// The command's own name is the first token, as it is for every command that
// reads its line directly.
const rest = args.slice(1);
const option = rest.find((token) => token.startsWith("-") && token !== "-");
if (option !== undefined) {
const positional: string[] = [];
for (let at = 0; at < rest.length; at += 1) {
const token = rest[at] ?? "";
if (!token.startsWith("-") || token === "-") {
positional.push(token);
continue;
}
// `--name=value` and `--name value` are both the parser's forms, so the scan
// reads the name the same way the parser will.
const split = token.indexOf("=");
const name = split === -1 ? token : token.slice(0, split);
const written = split === -1 ? undefined : token.slice(split + 1);
if (REPL_SWITCHES.includes(name)) {
if (written !== undefined) {
return `xmd repl: ${name} is a switch and takes no value`;
}
continue;
}
if (REPL_VALUES.includes(name)) {
if (written !== undefined) {
if (written.length === 0) {
return `xmd repl: ${name} requires a value`;
}
continue;
}
// The next token is this option's value, not a location: a scan that
// counted it as one would refuse `--default-agent claude` for naming two
// things.
const value = rest[at + 1];
if (value === undefined || value.startsWith("-")) {
return `xmd repl: ${name} requires a value`;
}
at += 1;
continue;
}
return (
`unrecognized option for xmd repl: ${option} — xmd repl takes one optional location ` +
"and no options"
`unrecognized option for xmd repl: ${name} — xmd repl takes one optional location and ` +
"the agent options --agent-provider, --default-agent, --approve-all, --approve-reads " +
"and --deny-all"
);
}
const positional = rest.filter((token) => !token.startsWith("-"));
if (positional.length > 1) {
return (
"xmd repl takes at most one location. This REPL admits one entry per execution, so " +
Expand Down Expand Up @@ -2835,17 +2894,41 @@ function* dispatch(
yield* exit(1);
break;
}
// The Agent configuration, settled before a terminal exists: mutual
// exclusion, an unknown provider and `DEFAULT_AGENT_NAME` precedence are
// all decided here, so an invocation nobody could run refuses without
// having taken the screen, made a directory or materialized an adapter.
const replStack = yield* settleAgentStack(
{
agentProvider: command.config.agentProvider,
defaultAgent: command.config.defaultAgent,
approveAll: command.config.approveAll,
approveReads: command.config.approveReads,
denyAll: command.config.denyAll,
},
sessions,
);
if (replStack === undefined) {
break;
}
if (installRepl === undefined) {
console.error(
"xmd repl: this host assembles no interactive terminal, so there is nothing to open.",
);
yield* exit(1);
break;
}
// One profile for the whole command, assembled once in this scope: the
// packaged `<Plan>` Component, the selected Plugins, the Agent identity
// vocabulary and the ceiling a generated fragment runs under. Nothing is
// assembled again per entry, so two entries of one session cannot run
// under different rules.
const profile = yield* assembleReplProfile(replStack, plugins);
yield* installRepl();
const ran = yield* runReplProgram(
command.config.location === undefined ? {} : { location: command.config.location },
);
const ran = yield* runReplProgram({
profile,
...(command.config.location === undefined ? {} : { location: command.config.location }),
});
if (!ran.ok) {
console.error(`xmd repl: ${ran.error.message}`);
yield* exit(1);
Expand Down
18 changes: 12 additions & 6 deletions packages/cli/src/evaluation-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@
* ## The two tables
*
* `read` is core's self-closing `<File />`, its self-closing `<Glob />` and
* canonical `<Syntax />`. `write` is core's paired `<File>…</File>` and
* self-closing `<File.Delete />`. A fragment run by `xmd run` therefore reaches
* the caller's own filesystem through the Files provider this command
* installed, and reaches nothing else at all: no network read, no process, no
* repository, no Git, no credential and no agent.
* canonical `<Syntax />`. `write` is core's paired `<File>…</File>`, its
* self-closing `<File.Delete />` and paired canonical `<Elicit>`: a fragment
* that may change something may also ask the person first, which is what makes
* "preview this, then confirm it" a program an Agent can write. A `read`
* selection admits no question — it promises nobody will be interrupted.
*
* A fragment run by `xmd run` therefore reaches the caller's own filesystem
* through the Files provider this command installed, and the person through the
* Elicitation provider the run already has. It reaches nothing else at all: no
* network read, no process, no repository, no Git, no credential and no agent.
*
* Reading and writing stay separate spellings of one name. A selection of
* `read` admits `<File />` and not `<File>…</File>`, so a fragment that asks to
Expand All @@ -40,6 +45,7 @@
*/

import {
elicitWriteEntry,
fileDeleteEntry,
fileReadEntry,
fileWriteEntry,
Expand Down Expand Up @@ -92,7 +98,7 @@ function ordinaryFiles(): FragmentFileAccess {
export function ordinaryEvaluationProfile(): FragmentEvaluationInput {
return {
read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()],
write: [fileWriteEntry(), fileDeleteEntry()],
write: [fileWriteEntry(), fileDeleteEntry(), elicitWriteEntry()],
files: ordinaryFiles(),
};
}
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/plugin-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ const COMMANDS: ReadonlySet<string> = new Set([
"syntax",
"upgrade",
"workflow",
// `repl` runs a document, so a Plugin has to be told it is the command that
// did. Left out, a REPL invocation normalizes to `run` and its own name
// arrives as a positional — which tells every Plugin that something it was
// written for is happening when it is not, and tells none of them that the
// REPL is.
"repl",
]);

/**
Expand Down
Loading
Loading