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
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function main
created: 2026-10-15T04:47:23Z
norm: '1'
sig: 7d5d3f63a3bddde4
body_hash: 1a9df6a60adae3f7
raw_hash: 7454a2968598e917
vouched: 2026-10-19T21:10:15Z
body_hash: 2df61fec47c0ebd8
raw_hash: 0ee7581f4d71df17
vouched: 2026-10-20T17:05:11Z
lines: 253-482
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function handle
created: 2026-10-15T04:08:49Z
norm: '1'
sig: e597b5fbc8e664ce
body_hash: 28e153c79ae61537
raw_hash: 640ff85ff07439ad
vouched: 2026-10-19T21:15:06Z
body_hash: 1b1e7b788ab2e1af
raw_hash: 581d53ea3cc3de56
vouched: 2026-10-20T17:05:11Z
lines: 266-519
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function handle
created: 2026-10-15T06:00:20Z
norm: '1'
sig: e597b5fbc8e664ce
body_hash: 28e153c79ae61537
raw_hash: 640ff85ff07439ad
vouched: 2026-10-19T21:15:07Z
body_hash: 1b1e7b788ab2e1af
raw_hash: 581d53ea3cc3de56
vouched: 2026-10-20T17:05:11Z
lines: 281-561
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function handle
created: 2026-10-19T21:09:57Z
norm: '1'
sig: e597b5fbc8e664ce
body_hash: 28e153c79ae61537
raw_hash: 640ff85ff07439ad
vouched: 2026-10-19T21:15:07Z
body_hash: 1b1e7b788ab2e1af
raw_hash: 581d53ea3cc3de56
vouched: 2026-10-20T17:05:11Z
lines: 384-700
supersedes: 01M5APK9YCQH467YNMKAC8BT5B
---
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5D4BXMERGZ1EZ3EYAFM3YC4
anchor: '@file'
created: 2026-10-20T17:04:56Z
norm: '1'
sig: 1acdc0f6a0895ac5
body_hash: 2cc87782e4d39750
raw_hash: 2c0a05f043c689ac
lines: 1-1153
---

Worker requests (WORKER) never pass through the gateway; any new WorkerRequest op must be added there
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-15T02:58:00Z
norm: '1'
sig: 1a43097a4e1ad24c
body_hash: 764891fdee20e693
raw_hash: bd2ba9a358d5c3a8
vouched: 2026-10-19T20:53:00Z
body_hash: b6bff4ee7aa001b5
raw_hash: da64faa9eaa88a3e
vouched: 2026-10-20T17:05:11Z
lines: 1-194
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-14T17:33:22Z
norm: '1'
sig: c905677fa4be33c1
body_hash: b94131a282fede50
raw_hash: 0632800a47dcc747
vouched: 2026-10-19T20:57:54Z
body_hash: ab25308bd79989e5
raw_hash: 8691059ceba82558
vouched: 2026-10-20T17:05:11Z
lines: 114-792
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-15T00:30:42Z
norm: '1'
sig: c905677fa4be33c1
body_hash: b94131a282fede50
raw_hash: 0632800a47dcc747
vouched: 2026-10-19T20:57:54Z
body_hash: ab25308bd79989e5
raw_hash: 8691059ceba82558
vouched: 2026-10-20T17:05:11Z
lines: 114-792
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-15T02:34:37Z
norm: '1'
sig: c905677fa4be33c1
body_hash: b94131a282fede50
raw_hash: 0632800a47dcc747
vouched: 2026-10-19T20:57:54Z
body_hash: ab25308bd79989e5
raw_hash: 8691059ceba82558
vouched: 2026-10-20T17:05:11Z
lines: 114-794
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-15T10:17:55Z
norm: '1'
sig: c905677fa4be33c1
body_hash: b94131a282fede50
raw_hash: 0632800a47dcc747
vouched: 2026-10-19T20:57:54Z
body_hash: ab25308bd79989e5
raw_hash: 8691059ceba82558
vouched: 2026-10-20T17:05:11Z
lines: 121-817
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-16T22:11:35Z
norm: '1'
sig: c905677fa4be33c1
body_hash: b94131a282fede50
raw_hash: 0632800a47dcc747
vouched: 2026-10-19T20:57:54Z
body_hash: ab25308bd79989e5
raw_hash: 8691059ceba82558
vouched: 2026-10-20T17:05:11Z
lines: 127-873
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5D4BXMW2NZW40Y17T5QG77G
anchor: function importSeed
created: 2026-10-20T17:04:56Z
norm: '1'
sig: 2b5122279f6dcc31
body_hash: 6d99539836bcb9df
raw_hash: fa97fa789d71e663
lines: 183-208
---

Imports only into an empty root chat, appending through pi so ids, heads and docs stay pi's
21 changes: 21 additions & 0 deletions apps/host/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ChannelInfo, ChatId, Event, Request } from "@ace/channel/protocol"

import { appUrl, token } from "./auth";
import { backup, restore } from "./backup";
import { exportSeed, importSeed } from "./seed";
import * as catalog from "./catalog";
import { Connection, request } from "./client";
import { config } from "./config";
Expand Down Expand Up @@ -58,6 +59,10 @@ const HELP = `ace — channels on this host
ace tab rename <window> <channel> <tab> <name> rename one tab; an empty name restores its default
ace backup <channel> <directory> snapshot a local or hosted channel's data into a new directory
ace restore <directory> [--project <dir>] restore a backup as a new local channel
ace export <channel> <file> save a channel's chats as an editable seed file
ace import <file> [--project <dir>] [--name <name>] [--now]
add a seed as a new local channel; --now moves its
newest message to now
ace say <channel> [--chat <id>] <text…> post without invoking the agent
ace ask <channel> [--chat <id>] [--model <provider/id>] [--detach] <text…>
ace watch <channel> [--chat <id>]
Expand Down Expand Up @@ -107,6 +112,7 @@ const { values: flags, positionals } = parseArgs({
all: { type: "boolean" },
team: { type: "boolean" },
detach: { type: "boolean" },
now: { type: "boolean" },
channel: { type: "string" },
trace: { type: "string" },
level: { type: "string" },
Expand Down Expand Up @@ -243,6 +249,7 @@ function printAccess(access: ProjectAccess) {

const owned: Record<string, string> = {
backup: "back it up",
export: "export it",
rename: "rename it",
fork: "fork it",
kill: "kill it",
Expand Down Expand Up @@ -408,6 +415,17 @@ async function main() {
const record = await restore(ref!, flags.project);
return console.log(`${record.name}\t${record.id}`);
}
case "import": {
if (positionals.length !== 2) {
throw new Error("ace import <seed-file> [--project <dir>] [--name <name>] [--now]");
}
const record = await importSeed(ref!, {
dir: flags.project || process.cwd(),
name: flags.name,
now: flags.now,
});
return console.log(`${record.name}\t${record.id}`);
}
case "models": {
for (const m of await models()) console.log(`${m.provider}/${m.modelId}`);
return;
Expand Down Expand Up @@ -554,6 +572,9 @@ async function main() {
console.log(await backup(record, rest[0]!));
console.log("Channel data saved. Back up project files and lane worktrees separately.");
return;
case "export":
if (rest.length !== 1) throw new Error("ace export <channel> <file>");
return console.log(await exportSeed(record, rest[0]!));
case "rename":
if (rest.length !== 1) throw new Error("ace rename <channel> <name>");
await request(record.id, { op: "rename", author: catalog.user, name: rest[0]! });
Expand Down
5 changes: 5 additions & 0 deletions apps/host/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
type People,
type Presence,
projectId,
type WorkerRequest,
} from "./protocol";
import { type Machine, self, whois } from "./tailnet";
import {
Expand Down Expand Up @@ -464,6 +465,9 @@ const localOps = new Set<HostRequest["op"]>([

type ChannelRequest = Extract<HostRequest, { op: "channel" }>["request"];

/** Worker requests come from this host's own processes, never through the gateway. */
const WORKER = new Set<string>(["worker", "freeze", "export"] satisfies WorkerRequest["op"][]);

/** The verified person replaces any claimed author. Older clients send stop and kill without one. */
function authored(request: ChannelRequest, user: string): ChannelRequest {
if (request.op === "stop" || request.op === "kill" || "author" in request) {
Expand Down Expand Up @@ -739,6 +743,7 @@ async function handle(
client.terminals.delete(request.terminal);
return terminals.close(request.terminal);
case "channel": {
if (WORKER.has(request.request.op)) throw new Error(`Unknown request ${request.request.op}`);
// Only a watch streams; other requests must not leave watchers behind in relays.
const watch: Watch | undefined = request.request.op === "watch"
? {
Expand Down
3 changes: 2 additions & 1 deletion apps/host/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ export type MoveTarget = { target: string; label: string; place: "local" | "host

export type WorkerInfo = { id: string; pid: number };
/** `freeze` drains admitted requests and exits the worker for a move, or fails if work remains. */
export type WorkerRequest = { op: "worker" } | { op: "freeze" };
/** Only this host's own processes send these; the gateway refuses them from clients. */
export type WorkerRequest = { op: "worker" } | { op: "freeze" } | { op: "export" };

export type Diagnostics = {
tools: { name: string; path?: string; version?: string; error?: string }[];
Expand Down
82 changes: 82 additions & 0 deletions apps/host/src/seed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { readFileSync, rmSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";

import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";

import { Channel, type Seed, shiftTo, validateSeed } from "@ace/channel";

import * as catalog from "./catalog";
import { Connection } from "./client";
import { models } from "./keys";
import { log } from "./log";
import { project } from "./manage";

/** A local channel's chats as a seed file, read through its worker so it can keep running. */
export async function exportSeed(record: catalog.Listing, file: string): Promise<string> {
if (record.hosted) throw new Error("Export a hosted channel after moving it here with ace move");
const connection = await Connection.open(record.id);
try {
const seed = await connection.request<Seed>({ op: "export" });
const path = resolve(file);
writeFileSync(path, `${JSON.stringify(seed, null, "\t")}\n`, { mode: 0o600, flag: "wx" });
return path;
} finally {
connection.close();
}
}

/**
* A seed as a new local channel in `dir`, or the current directory. `now` moves the seed's times
* so its newest message is now. The channel is listed only once its store holds the whole seed.
*/
export async function importSeed(
file: string,
options: { dir: string; name?: string; now?: boolean },
): Promise<catalog.Listing> {
const seed = JSON.parse(readFileSync(resolve(file), "utf8")) as Seed;
validateSeed(seed);
const record = {
...catalog.draft(await project(options.dir), seed.chats[0]!.model, options.name || seed.name),
...(seed.summary ? { summary: seed.summary } : {}),
};
const { base, storage } = catalog.paths(record.id);
const envs: NodeExecutionEnv[] = [];
try {
const channel = await Channel.open({
id: record.id,
name: record.name,
prefix: record.prefix,
named: true,
owner: record.owner,
project: record.project,
model: record.model,
storage: await openNodeSqliteStorage(storage),
models: models(),
env(cwd) {
const env = new NodeExecutionEnv({ cwd });
envs.push(env);
return env;
},
directory: {
self: { id: record.id, name: record.prefix },
list: async () => [],
deliver: async () => {
throw new Error("An importing channel delivers nothing");
},
},
log,
});
try {
await channel.import(seed, options.now ? shiftTo(seed, Date.now()) : 0);
} finally {
await channel.close();
for (const env of envs) await env.cleanup(BACKGROUND_CONTEXT);
}
return catalog.save({ ...record, named: true });
} catch (error) {
rmSync(base, { recursive: true, force: true });
throw error;
}
}
1 change: 1 addition & 0 deletions apps/host/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ function serve(socket: Socket) {
send({ id: frame, ok: true, value: null });
return exit(false, "move");
}
if (body.op === "export") return send({ id: frame, ok: true, value: await channel.export() });
if (catalog.read(id).archived && ARCHIVED.has(body.op)) {
throw new Error("The channel is archived");
}
Expand Down
15 changes: 15 additions & 0 deletions docs/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,21 @@ The restored chats keep the original working-directory and lane paths. If the ba
during a run, the copy resumes that run when it first opens, and pi reports interrupted tools that
cannot safely be rerun. Stop the original channel's work first if both would use the same lanes.

## Seeds

A seed is a channel's chats as editable JSON, for demos and fixtures rather than recovery.
`ace export <channel> <file>` writes a local channel's ownerless chats: people's messages by author
and ISO time, as they read now, and agents' replies and tool results as the pi messages their runs
committed, with reactions, captions, recaps, and each chat's model. Subagent chats, deleted
messages, run bookkeeping, and lanes stay behind.

`ace import <file> [--project <dir>] [--name <name>] [--now]` adds a seed as a new local channel in
the current directory's project or `--project`. It appends every item through pi, so the channel is
ordinary: its agents read the imported history as context, and later runs continue it. Authors are
any valid participant names, and `--now` moves every time so the newest message lands at import.
The first chat is the root chat. Agent items must be `pi.assistant` or `pi.tool-result` entries,
and tool calls and results must still pair up. A failed import removes the new channel's directory.

## Recover in place on the same machine

To return a local channel to a snapshot under its original ID:
Expand Down
11 changes: 11 additions & 0 deletions packages/channel/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import type {
} from "./protocol";
import { queue, withdrawn } from "./queue";
import * as room from "./room";
import { exportSeed, importSeed, type Seed } from "./seed";
import { type Tabs, tabs } from "./tabs";

type Settings = { shared: boolean; desktop: boolean };
Expand Down Expand Up @@ -553,6 +554,16 @@ export class Channel {
}

/** Durably abort every chat's work, including background subagents, so reopening resumes none of it. */
/** The channel's chats as a seed another channel can import. */
export(): Promise<Seed> {
return exportSeed(this.#harness);
}

/** Fills a new channel from a seed, `shift` milliseconds later than its own times. */
import(value: Seed, shift?: number): Promise<void> {
return importSeed(this.#harness, value, shift);
}

async kill(): Promise<void> {
const { chats } = await this.info();
this.#log("warn", "channel.kill", {
Expand Down
1 change: 1 addition & 0 deletions packages/channel/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export { Channel, type Options } from "./channel";
export { failure, type Fields, type Level, type Log, scoped } from "./log";
export { validateName } from "./metadata";
export * from "./protocol";
export { type Seed, type SeedChat, type SeedItem, shiftTo, validateSeed } from "./seed";
Loading