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: 53 additions & 1 deletion architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -6419,6 +6419,38 @@ projection detaches every value it retains and freezes it, so nothing below
holds a `DurableEvent` and nothing above can change one. Route resolution is
pure and answers with the exact objects that model holds.

### Local Sidekick attachments

The CLI-private `sidekick-acp` composition attaches observers and an ACP stdio
bridge to the live root's existing Sidekick dispatch. With `--sidekick-acp` and
no selected conversation, startup sends one real initialization message through
that dispatch and waits for joined completion before publishing the endpoint.
Existing selected history needs no initialization turn or provider setup; an
initialization failure is reported without publishing an endpoint. Ordinary
startup remains passive. Initialization retains captured provider configuration,
entry admission and pending/uncertain-effect rules. It introduces no evaluator,
journal writer or conversation controller. The root owns producers and issued
attachment mappings; each connection owns its bounded public observation and
request tasks. EOF releases only those observations. Explicit cancellation
captures an exact exchange before suspension and joins it; enclosing command
teardown joins both producer and transport resources.

A contextual transport is installed explicitly by each runtime entrypoint. Its
POSIX adapter owns asynchronous socket/file acquisition, exclusive complete
descriptor publication and identity-checked cleanup. Shared attachment modules
use that context and detached acknowledged reads. A descriptor binds its live
owner/execution/conversation/cwd, and every connection handshakes that exact
binding. Conversation selection changes invalidate it. Private protocol schemas
bound both framing and the read surface; an observer that exceeds its mailbox
cannot block the root producer.

The bridge distinguishes three facts: accepted saved message, joined exchange
completion and the product verdict inside evaluated Output. Provisional activity
is marked as such, and no native transcript crosses this boundary. Outer ACP
identity/materialization describes only the attachment and its message acceptance;
it does not assert acceptance by the underlying Sidekick provider. Unknown
feedback keeps its existing refusal and is never resent through attachment.

### Session workspace placement

A Sidekick response and an entry submitted through its controls start in the
Expand Down Expand Up @@ -8623,6 +8655,26 @@ every root job and subscription are children of that screen scope, so Exit,
Control-C, end of input and failure all unwind and join the Sidekick's work
before the command reports its outcome and the terminal is released.

**One root-owned dispatch spans the whole Sidekick exchange.** UI Send and
Retry use the same private service and admission policy as an attributed external
caller. Constructing an operation does nothing; executing it captures lexical
origin and conversation selection, then reserves before producer work. Message,
inspection, save and replacement share the existing policy: save and replacement
may accompany a message, while explicit configuration jobs exclude one another.
The producer belongs to the live command. Detaching an observer does not cancel
it or release its reservation. Exact-handle Stop joins producer cleanup before
release, and an old handle cannot stop a successor. Entry work remains owned by
ReplSession.

Every message record carries an opaque ID and a UI/attachment origin beside the
conversation-scoped numeric `turn`, and a record missing either is unreadable
rather than assumed. The reference is published only after the message append
is acknowledged.
ACP-origin acknowledgement clears neither composer nor draft takeover. The live
root indexes established IDs for passive observation; that index supplies no
restart, resend or provider-acceptance authority. An earlier answer followed by
a failed turn remains partial evidence in the failed exchange result.

**The owner reports its own changes to the loop, and that report carries no
authority.** A reply arriving, an automatic follow-up starting, a cycle failing
and a joined Stop are things no keystroke announces, so the owner owns a signal
Expand Down Expand Up @@ -8791,7 +8843,7 @@ and categorization rather than clarify them, so both stay exactly as written.
| background automation | work designed to proceed without ongoing user or agent attention, with its persistence and recovery guarantees supplied by the workflow host |
| REPL | the foreground interactive environment for experimenting with and testing components; replay restores recorded execution state, while the user or agent handles differences between that state and the live environment |
| REPL history | the immutable ordered execution records the REPL reads from the durable journal and projects into `ReplModel`; the History surface presents this read model and is not itself a journal [Reference](#the-private-repl-composition-kernel). |
| message ID | the number Sidekick retains to identify a saved person message within its conversation; fixed once saved, possibly noncontiguous, and independent of the turn numbers recorded by Core. The conversation and message ID together form its lookup key |
| message ID | the opaque identity Sidekick retains for a saved person message, written before its turn and never rewritten. The conversation and message ID together form its lookup key, and the conversation-scoped numeric `turn` beside it identifies no turn |
| turn | one request to an agent, together with the response or failure from that request |
| middleware | applied by the lexical structure, used by runtime execution [Reference](#two-layers). |
| workflow run | a workflow being carried out with its progress and outcome recorded durably; document executions perform its work, while ongoing effects remain scoped to the document execution in which they run [Reference](#workflow-runs). |
Expand Down
2 changes: 2 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/cli/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"@bomb.sh/tty": "npm:@bomb.sh/tty@0.9.0",
"@effectionx/stream-helpers": "npm:@effectionx/stream-helpers@0.8.3",
"@standard-schema/spec": "npm:@standard-schema/spec@^1.0.0",
"zod": "npm:zod@^4.3.6"
"zod": "npm:zod@^4.3.6",
"@agentclientprotocol/sdk": "npm:@agentclientprotocol/sdk@1.3.0"
}
}
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"default": "./src/node.ts"
},
"dependencies": {
"@agentclientprotocol/sdk": "1.3.0",
"@bomb.sh/tty": "0.9.0",
"@effectionx/stream-helpers": "0.8.3",
"@executablemd/acp": "workspace:*",
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/bun.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { usePosixSidekickTransport } from "./sidekick-acp/posix.ts";
/**
* Bun source entrypoint.
*
Expand Down Expand Up @@ -37,6 +38,7 @@ const UPGRADE: UpgradeAssembly = {
};

await main(function* (args) {
yield* usePosixSidekickTransport(process.platform);
yield* useBunComponentModuleLoader();
// The base providers for this host. `at: "min"` puts them beneath ordinary
// middleware, so middleware installed later can wrap either one.
Expand Down
51 changes: 48 additions & 3 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { resolve } from "node:path";
import { runSidekickCommand, SIDEKICK_HELP } from "./sidekick-acp/command.ts";

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance (measured, conditional): every xmd command now loads the ACP SDK at startup.

This static import chain is cli.ts → sidekick-acp/command.ts → sidekick-acp/bridge.ts → @agentclientprotocol/sdk, so xmd run, xmd --help, the credential helper path and so on pay for the SDK module graph even though only xmd sidekick acp uses it. Read-only timing on this head (Node v26.5.1, arm64 macOS, prepared worktree, three runs):

import('@agentclientprotocol/sdk')  69.4 ms / 59.2 ms / 66.1 ms
import('zod') alone                 22.6 ms   (already a CLI dependency)
deno run packages/cli/src/deno.ts --help  0.88 s / 0.54 s / 0.52 s (whole command, head only)

So the marginal cost is on the order of 40 ms per process under Node source; I did not measure base, the compiled binary or Bun, so treat this as a bound to confirm rather than a regression number.

Recommendation: measure xmd --help base vs head under Node and the compiled binary (for example hyperfine --warmup 3 'node packages/cli/src/node.ts --help' on each, or the compiled xmd --help). If the delta is confirmed, load the bridge lazily behind the sidekick acp branch of runSidekickCommand with a literal dynamic import("./bridge.ts") adapted through until() — the CLI already does this for the workflow host (packages/cli/src/workflow.ts line 1045), so deno compile and knip handle the pattern. protocol.ts/client.ts can stay static; they only pull zod. This changes no behaviour and keeps the manifest-boundary regression in repl-boundaries.test.ts valid.

import { SidekickTransport } from "./sidekick-acp/protocol.ts";
import { isAbsolute, resolve } from "node:path";
import { parseWorkspaceMounts } from "./workspace-mounts.ts";
/**
* CLI — run an executable markdown document.
Expand Down Expand Up @@ -322,6 +324,10 @@ const REPL_DESCRIPTION = "Run and reconstruct one XMD entry in an interactive te
* their entries is not the same choice as who their entries talk to.
*/
const replConfig = object({
sidekickAcp: {
description: "absolute private attachment file; initializes Sidekick when needed (POSIX only)",
...field(z.string().optional()),
},
mount: {
description: "external SOURCE:TARGET directory; repeat for each explicit TARGET",
...field(z.array(z.string()), field.default([]), field.array()),
Expand Down Expand Up @@ -566,6 +572,7 @@ const xmd = program({
agent: { ...agentConfig, description: AGENT_DESCRIPTION },
upgrade: { ...upgradeConfig, description: UPGRADE_DESCRIPTION },
repl: { ...replConfig, description: REPL_DESCRIPTION },
sidekick: { description: "Attach to or passively read one live Sidekick.", ...object({}) },
"test-agent": testAgentConfig,
workflow: workflowConfig,
},
Expand Down Expand Up @@ -665,7 +672,7 @@ export interface ReplHostAssembly {
* silence would let them believe they got it.
*/
/**
* The five REPL options that carry a value, by the spelling a caller writes.
* The six REPL options that carry a value, by the spelling a caller writes.
*
* Exported because the documentation check reads this command's own examples,
* and a token after one of these is that option's value rather than a location.
Expand All @@ -678,6 +685,7 @@ export const REPL_VALUES: readonly string[] = [
"--agent-provider",
"--default-agent",
"--sidekick-agent",
"--sidekick-acp",
];

/** The three REPL switches, which carry none. */
Expand All @@ -691,6 +699,7 @@ export function replGrammarError(
// reads its line directly.
const rest = args.slice(1);
const positional: string[] = [];
let attachmentPath = false;
for (let at = 0; at < rest.length; at += 1) {
const token = rest[at] ?? "";
if (!token.startsWith("-") || token === "-") {
Expand All @@ -708,6 +717,19 @@ export function replGrammarError(
}
continue;
}
if (name === "--sidekick-acp") {
if (attachmentPath) {
return "xmd repl: --sidekick-acp accepts exactly one path";
}
attachmentPath = true;
const path = written ?? rest[at + 1];
if (path === undefined || path.length === 0 || path.startsWith("-")) {
return "xmd repl: --sidekick-acp requires a value";
}
if (!isAbsolute(path)) {
return "xmd repl: --sidekick-acp requires an absolute path";
}
}
if (REPL_VALUES.includes(name)) {
if (written !== undefined) {
if (written.length === 0) {
Expand Down Expand Up @@ -2516,6 +2538,7 @@ const COMMAND_NAMES = [
"upgrade",
"agent",
"repl",
"sidekick",
"test-agent",
"workflow",
];
Expand Down Expand Up @@ -2643,7 +2666,7 @@ function renderHelp(phase: PropsPhase): string {
: command === "agent"
? AGENT_OPTIONS_HELP
: command === "repl"
? REPL_HELP
? REPL_HELP + "\n\n" + SIDEKICK_HELP
: "";
const withSource = epilogue === "" ? base : `${base}\n\n${epilogue}`;

Expand Down Expand Up @@ -2952,6 +2975,14 @@ function* dispatch(
yield* exit(1);
break;
}
if (
command.config.sidekickAcp !== undefined &&
!(yield* SidekickTransport.get())?.supported()
) {
console.error("xmd repl: Sidekick ACP requires a same-user POSIX Unix socket.");
yield* exit(1);
break;
}
const mountCwd = yield* cwd();
const mounts = parseWorkspaceMounts(command.config.mount, mountCwd, { resolve });
if (!mounts.ok) {
Expand Down Expand Up @@ -3040,6 +3071,9 @@ function* dispatch(
yield* replHost.install();
const ran = yield* runReplProgram({
profile,
...(command.config.sidekickAcp === undefined
? {}
: { sidekickAcp: command.config.sidekickAcp }),
...(command.config.location === undefined ? {} : { location: command.config.location }),
});
if (!ran.ok) {
Expand Down Expand Up @@ -3433,6 +3467,17 @@ export function* runXmd(
// restore.
replHost?: ReplHostAssembly,
): Operation<void> {
if (args[0] === "sidekick") {
try {
yield* runSidekickCommand(args.slice(1));
} catch (error) {
console.error(
"xmd sidekick: " + (error instanceof Error ? error.message : "attachment refused"),
);
yield* exit(1);
}
return;
}
// Before every scanner and before anything reads a path: what a command line
// selects is read from the argv the caller wrote, and the tokens that
// selected it are removed from everything downstream.
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/compiled.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { usePosixSidekickTransport } from "./sidekick-acp/posix.ts";
/**
* Compiled-binary entrypoint — what `deno task build` compiles.
*
Expand Down Expand Up @@ -55,6 +56,7 @@ if (isCredentialHelperMode(process.argv.slice(2))) {
await main(() => runCredentialHelper(process.argv.slice(2)));
} else {
await main(function* (args) {
yield* usePosixSidekickTransport(process.platform);
yield* useDenoComponentModuleLoader();
// The base providers for this host. `at: "min"` puts them beneath ordinary
// middleware, so middleware installed later can wrap either one.
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/deno.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { usePosixSidekickTransport } from "./sidekick-acp/posix.ts";
/**
* Deno source entrypoint.
*
Expand Down Expand Up @@ -71,6 +72,7 @@ if (isCredentialHelperMode(process.argv.slice(2))) {
await main(() => runCredentialHelper(process.argv.slice(2)));
} else {
await main(function* (args) {
yield* usePosixSidekickTransport(process.platform);
yield* useDenoComponentModuleLoader();
// The base providers for this host. `at: "min"` puts them beneath ordinary
// middleware, so middleware installed later can wrap either one.
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/node.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { usePosixSidekickTransport } from "./sidekick-acp/posix.ts";
/**
* Node source and npm-package entrypoint.
*
Expand Down Expand Up @@ -44,6 +45,7 @@ const UPGRADE: UpgradeAssembly = {
};

await main(function* (args) {
yield* usePosixSidekickTransport(process.platform);
yield* useNodeComponentModuleLoader();
// The base providers for this host. `at: "min"` puts them beneath ordinary
// middleware, so middleware installed later can wrap either one.
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/repl/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* adoption; a refusal preserves the standing route, selection and draft.
*/

import type { MessageOrigin } from "./sidekick/history.ts";
import { Ok, type Result } from "effection";
import type { Json } from "@executablemd/durable-streams";
import type { AgentOption, AgentOptions, SessionConfiguration } from "@executablemd/core";
Expand Down Expand Up @@ -206,6 +207,7 @@ export interface ReplLive {
* reader left it on when the next reply arrives.
*/
export interface ReplSidekickChatRow {
readonly origin?: MessageOrigin;
/**
* The saved request this failed attempt could be asked again, or none.
*
Expand Down Expand Up @@ -3250,7 +3252,13 @@ function sidekickComposedRows(
}
for (const item of shownChat) {
if (item.kind === "person") {
items.push(text(`sidekick:${item.key}`, ` you: ${item.text}`, styleOf("source")));
items.push(
text(
`sidekick:${item.key}`,
` ${item.origin?.kind === "acp" ? "ACP (" + item.origin.attachment + ")" : "you"}: ${item.text}`,
styleOf("source"),
),
);
continue;
}
if (item.kind === "preview") {
Expand Down
Loading
Loading