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: 87accd22387f08aa
raw_hash: f6660ea485c82556
vouched: 2026-10-22T21:15:29Z
body_hash: ac23ed1035252ab1
raw_hash: b0bccb033de16d7f
vouched: 2026-10-22T21:21:50Z
lines: 253-482
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function main
created: 2026-10-22T14:31:16Z
norm: '1'
sig: 7d5d3f63a3bddde4
body_hash: 87accd22387f08aa
raw_hash: f6660ea485c82556
vouched: 2026-10-22T21:15:29Z
body_hash: ac23ed1035252ab1
raw_hash: b0bccb033de16d7f
vouched: 2026-10-22T21:21:50Z
lines: 332-614
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-20T17:10:44Z
norm: '1'
sig: e3b0c44298fc1c14
body_hash: 34d1a59cc18e2a4e
raw_hash: 154e8bdfc835590a
vouched: 2026-10-22T15:28:03Z
body_hash: 3f078d24af67c8ac
raw_hash: 80fe2a184e820585
vouched: 2026-10-22T21:10:47Z
lines: 1-187
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function serve
created: 2026-10-15T03:39:45Z
norm: '1'
sig: 171797818ef4d836
body_hash: 6333f6a695d3ff98
raw_hash: ad0574ab5dfd2999
vouched: 2026-10-22T15:20:51Z
body_hash: 0f15dc30991bf467
raw_hash: aa609814e48a6d89
vouched: 2026-10-22T21:10:47Z
lines: 668-751
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function serve
created: 2026-10-15T10:17:10Z
norm: '1'
sig: 171797818ef4d836
body_hash: 6333f6a695d3ff98
raw_hash: ad0574ab5dfd2999
vouched: 2026-10-22T15:20:51Z
body_hash: 0f15dc30991bf467
raw_hash: aa609814e48a6d89
vouched: 2026-10-22T21:10:47Z
lines: 830-948
supersedes: 01M4Z2R1HEVDKEA8VEJ7XRJQ8F
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-20T17:04:56Z
norm: '1'
sig: 1acdc0f6a0895ac5
body_hash: aa87c2347e08ef4b
raw_hash: 0097527a5ffa6445
vouched: 2026-10-22T21:13:07Z
body_hash: 018bb7ba4e642584
raw_hash: 39402f04edba65d3
vouched: 2026-10-22T21:21:50Z
lines: 1-1153
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-22T14:00:47Z
norm: '1'
sig: 1acdc0f6a0895ac5
body_hash: aa87c2347e08ef4b
raw_hash: 0097527a5ffa6445
vouched: 2026-10-22T21:13:07Z
body_hash: 018bb7ba4e642584
raw_hash: 39402f04edba65d3
vouched: 2026-10-22T21:21:50Z
lines: 1-1309
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5JQ79TQ8E8R693FQD7PF3RP
anchor: function serve
created: 2026-10-22T21:10:40Z
norm: '1'
sig: 171797818ef4d836
body_hash: 0f15dc30991bf467
raw_hash: aa609814e48a6d89
lines: 1191-1341
---

Only a command-line host prints the sign-in link; Ace Helper's console is its log file, which must never hold the token
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-13T17:41:10Z
norm: '1'
sig: 4b03c8eb05d682d4
body_hash: d49430c648b04352
raw_hash: f4153907c9e313c2
vouched: 2026-10-21T19:33:15Z
body_hash: d3b539175eba5e12
raw_hash: 6e008c8c2632d2b4
vouched: 2026-10-22T21:10:47Z
lines: 1-39
---

Expand Down
88 changes: 55 additions & 33 deletions apps/host/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import {
} from "./manage";
import { move } from "./move";

const HELP = `ace — channels on this host
const USAGE = `ace — channels on this host

ace new [--project <dir>] [--name <name>] [--model <provider/id>]
ace ls [--all] [--team] --team adds reachable teammates' channels
Expand Down Expand Up @@ -88,14 +88,15 @@ const HELP = `ace — channels on this host
ace directory [<url> | off] the team's directory, which lists every host's channels;
a running host checks it with ACE_SECRET first
ace web [<url> | off] the deployed web app your devices may use with this host
ace serve [--port 4140] [--loopback] [--demo <script>]
ace serve [--port ${config.port}] [--loopback] [--demo <script.json>]
serve the app and its WebSocket gateway;
--loopback stays off the tailnet and the directory;
--demo runs scripted synthetic teammates
--demo runs scripted synthetic teammates (docs/demo.md)
ace logs [--channel <ref>] [--trace <id>] [--level warn] [--since 10m] [--grep <text>]
[-n 200] [--follow] [--json] every process's logs, merged in time order
ace open [--port 4140] open this host's authenticated app
ace open [--port ${config.port}] open this host's authenticated app
ace archive <channel> | unarchive <channel> | delete <channel>
ace --version

info, say, ask, answer, watch, chat and stop also take a teammate's channel, by name or ID, through this
host's running gateway (open Ace or run ace serve). Their host decides who may invoke its agents.
Expand All @@ -104,34 +105,48 @@ Models are provider/id, such as openai/gpt-6-astra or anthropic/claude-opus-5-5.
A credential such as OPENAI_API_KEY comes from ACE_OPENAI_API_KEY, then OPENAI_API_KEY, then the
keychain. Store keys there for the desktop app, which starts without your shell's environment.`;

const { values: flags, positionals } = parseArgs({
allowPositionals: true,
options: {
project: { type: "string" },
name: { type: "string" },
model: { type: "string" },
hosted: { type: "string" },
local: { type: "boolean" },
compute: { type: "boolean" },
chat: { type: "string" },
port: { type: "string" },
all: { type: "boolean" },
team: { type: "boolean" },
detach: { type: "boolean" },
now: { type: "boolean" },
channel: { type: "string" },
trace: { type: "string" },
level: { type: "string" },
since: { type: "string" },
grep: { type: "string" },
lines: { type: "string", short: "n" },
follow: { type: "boolean", short: "f" },
json: { type: "boolean" },
loopback: { type: "boolean" },
demo: { type: "string" },
help: { type: "boolean", short: "h" },
},
});
// The app installs the command under another name for Canary.
const HELP = USAGE.replace(/\bace\b/g, config.command);

const OPTIONS = {
project: { type: "string" },
name: { type: "string" },
model: { type: "string" },
hosted: { type: "string" },
local: { type: "boolean" },
compute: { type: "boolean" },
chat: { type: "string" },
port: { type: "string" },
all: { type: "boolean" },
team: { type: "boolean" },
detach: { type: "boolean" },
now: { type: "boolean" },
channel: { type: "string" },
trace: { type: "string" },
level: { type: "string" },
since: { type: "string" },
grep: { type: "string" },
lines: { type: "string", short: "n" },
follow: { type: "boolean", short: "f" },
json: { type: "boolean" },
loopback: { type: "boolean" },
demo: { type: "string" },
help: { type: "boolean", short: "h" },
version: { type: "boolean" },
} as const;

function parse() {
try {
return parseArgs({ allowPositionals: true, options: OPTIONS });
} catch (error) {
// Node's message goes on to explain `--` for positionals, which no command here takes.
const [problem] = (error as Error).message.split(". ");
console.error(`${problem}. Run ${config.command} --help for usage.`);
process.exit(1);
}
}

const { values: flags, positionals } = parse();
const [command, ref, ...rest] = positionals;

const model = parseModel;
Expand Down Expand Up @@ -374,7 +389,14 @@ function advice(conflict: HostConflict): string {
}

async function main() {
if (flags.help || !command) return console.log(HELP);
if (flags.version) {
return console.log(
config.version
? `${config.command} ${config.version}`
: "Ace from source has no release version",
);
}
if (flags.help || !command || command === "help") return console.log(HELP);
// Shells start background jobs with SIGINT ignored and Bun keeps that, so `kill -INT` would not
// end a backgrounded watch. A listener takes SIGINT back; serve shuts down with its own.
if (command !== "serve") process.on("SIGINT", () => process.exit(130));
Expand Down
2 changes: 2 additions & 0 deletions apps/host/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export const config = {
services: fileURLToPath(new URL("../../../services", import.meta.url)),
appUrl: process.env.ACE_APP_URL,
helper: false,
/** The name the command line was run as, such as `ace-canary` through the app's link. */
command: "ace",
/** Ace Helper's app version, reported by /health so a conflict can name the build on the port. */
version: undefined as string | undefined,
/** Set by ace serve --loopback; the host stays off the tailnet and the team directory. */
Expand Down
60 changes: 58 additions & 2 deletions apps/host/src/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,12 +177,68 @@ function schedule() {
}, (last + script!.loop) * 1000));
}

/** Starts the script in `file`; the returned function stops it and takes everyone away. */
const ACTIONS = ["open", "close", "away", "leave", "type", "say", "ask", "react"];
const UNTARGETED = new Set(["away", "leave"]);

function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/** The script in `file`, or an error naming what's wrong in one line, before the host serves. */
export function read(file: string): Script {
const path = resolve(file);
let text: string;
try {
text = readFileSync(path, "utf8");
} catch {
throw new Error(`No demo script at ${path}`);
}
let value: unknown;
try {
value = JSON.parse(text);
} catch (error) {
throw new Error(`${file} is not JSON: ${(error as Error).message}`, { cause: error });
}
const fail = (problem: string) => new Error(`${file}: ${problem}. See docs/demo.md.`);
if (!isObject(value) || !isObject(value.people)) {
throw fail(`needs "people", naming each person's "login" and "github"`);
}
for (const [who, person] of Object.entries(value.people)) {
if (
!isObject(person) || typeof person.login !== "string" || typeof person.github !== "string"
) {
throw fail(`person ${who} needs a "login" and a "github" login`);
}
}
if (!Array.isArray(value.steps)) throw fail(`needs "steps", a list`);
for (const [index, step] of value.steps.entries()) {
const where = `step ${index + 1}`;
if (!isObject(step)) throw fail(`${where} is not an object`);
if (typeof step.at !== "number" || step.at < 0) {
throw fail(`${where} needs "at", seconds from the start`);
}
if (typeof step.who !== "string" || !(step.who in value.people)) {
throw fail(
`${where} needs "who", one of ${Object.keys(value.people).join(", ") || "the people"}`,
);
}
const actions = ACTIONS.filter((action) => action in step);
if (actions.length !== 1) throw fail(`${where} needs exactly one of ${ACTIONS.join(", ")}`);
if (!UNTARGETED.has(actions[0]!) && typeof step.channel !== "string") {
throw fail(`${where} needs "channel"`);
}
}
if (value.loop !== undefined && typeof value.loop !== "number") throw fail(`"loop" is seconds`);
return value as Script;
}

/** Starts a script read from `file`; the returned function stops it and takes everyone away. */
export function start(
value: Script,
file: string,
options: { project(channel: string): Promise<string>; changed(): void },
): () => void {
script = JSON.parse(readFileSync(resolve(file), "utf8")) as Script;
script = value;
({ project, changed } = options);
log("info", "demo.start", { file, people: Object.keys(script.people).length });
console.log(`Demo mode: ${Object.keys(script.people).join(", ")} from ${file}`);
Expand Down
11 changes: 9 additions & 2 deletions apps/host/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,7 @@ export async function serve(port: number, loopback = false, script?: string): Pr
// Hosted channels run tools in this process.
seal();
openLog("host");
const scripted = script === undefined ? undefined : demo.read(script);
await claim(port);
await resume();
const server = await listen(port);
Expand Down Expand Up @@ -1242,6 +1243,12 @@ export async function serve(port: number, loopback = false, script?: string): Pr
process.on("SIGTERM", () => void stop());
process.on("SIGINT", () => void stop());
console.log(`Ace on http://${server.hostname}:${server.port}`);
// Ace Helper's console is its log file, which must not hold the token.
if (!config.helper) {
const signIn = auth.appUrl(server.port);
signIn.hash = new URLSearchParams({ token: auth.token() }).toString();
console.log(`Sign in at ${signIn.href}`);
}
if (loopback) {
log("info", "host.tailnet", { loopback: true });
console.log(
Expand Down Expand Up @@ -1295,8 +1302,8 @@ export async function serve(port: number, loopback = false, script?: string): Pr
} else if (!loopback) {
console.log("Tailscale is not running; channels stay on this machine");
}
if (script) {
cleanup.push(demo.start(script, {
if (scripted) {
cleanup.push(demo.start(scripted, script!, {
project: projectOfChannel,
changed: () => {
peopleChanged();
Expand Down
1 change: 1 addition & 0 deletions apps/host/src/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ config.services = join(dirname(process.execPath), "..", "Resources", "services")
config.worker = [process.execPath, "--worker"];

if (cli) {
config.command = basename(process.argv0);
await import("./cli");
} else if (worker) {
// A compiled executable dispatches its worker entry itself, rather than running a TS file.
Expand Down