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: 2df61fec47c0ebd8
raw_hash: 0ee7581f4d71df17
vouched: 2026-10-20T17:05:11Z
body_hash: 33d554e96630bf6d
raw_hash: f8aa0b8e89a37828
vouched: 2026-10-20T17:10:51Z
lines: 253-482
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5D4PGYZHXCA8WY1C8ZF4NF7
anchor: '@file'
created: 2026-10-20T17:10:44Z
norm: '1'
sig: e3b0c44298fc1c14
body_hash: fead7ca7cb28760f
raw_hash: 5b36bffc37f91339
lines: 1-187
---

Synthetic people stay in the owner's own app frames (people, presence); never publish them to the directory, lobbies or peers
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: function presenceChanged
created: 2026-10-15T03:29:13Z
norm: '1'
sig: 65636cf5f3dc4666
body_hash: b14172cdb315f14d
raw_hash: 708e82787b7742b9
body_hash: b5bb75b50aa22043
raw_hash: f87da30c2926f8ef
vouched: 2026-10-20T17:10:51Z
lines: 217-222
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ id: 01M4YT9Z8YJ2ZWZCVB752WDCFF
anchor: function serve
created: 2026-10-15T03:39:45Z
norm: '1'
sig: 9102a6912d6b112c
body_hash: 7c58dffb06b79595
raw_hash: 6bf75b65468de74e
vouched: 2026-10-19T20:58:11Z
sig: 171797818ef4d836
body_hash: bbdb9eb136165a77
raw_hash: db94b1c01ca466d2
vouched: 2026-10-20T17:10:51Z
lines: 668-751
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ id: 01M4ZH1NMBK54TCB970R3AJ1X1
anchor: function serve
created: 2026-10-15T10:17:10Z
norm: '1'
sig: 9102a6912d6b112c
body_hash: 7c58dffb06b79595
raw_hash: 6bf75b65468de74e
vouched: 2026-10-19T20:58:11Z
sig: 171797818ef4d836
body_hash: bbdb9eb136165a77
raw_hash: db94b1c01ca466d2
vouched: 2026-10-20T17:10:51Z
lines: 830-948
supersedes: 01M4Z2R1HEVDKEA8VEJ7XRJQ8F
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: function websocket
created: 2026-10-15T20:16:37Z
norm: '1'
sig: 1b462e5d3bc33728
body_hash: aee7bf28626367fb
raw_hash: 6675f75b614405d9
body_hash: 39bbd535e37f47a0
raw_hash: c1a585dfb617245a
vouched: 2026-10-20T17:10:51Z
lines: 673-737
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: '@file'
created: 2026-10-20T17:04:56Z
norm: '1'
sig: 1acdc0f6a0895ac5
body_hash: 2cc87782e4d39750
raw_hash: 2c0a05f043c689ac
body_hash: f383b10cec21cd9b
raw_hash: 9875bf699ce7d092
vouched: 2026-10-20T17:10:51Z
lines: 1-1153
---

Expand Down
9 changes: 6 additions & 3 deletions apps/host/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,10 @@ 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] serve the app and its WebSocket gateway;
--loopback stays off the tailnet and the directory
ace serve [--port 4140] [--loopback] [--demo <script>]
serve the app and its WebSocket gateway;
--loopback stays off the tailnet and the directory;
--demo runs scripted synthetic teammates
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
Expand Down Expand Up @@ -122,6 +124,7 @@ const { values: flags, positionals } = parseArgs({
follow: { type: "boolean", short: "f" },
json: { type: "boolean" },
loopback: { type: "boolean" },
demo: { type: "string" },
help: { type: "boolean", short: "h" },
},
});
Expand Down Expand Up @@ -488,7 +491,7 @@ async function main() {
return;
}
case "serve":
return serve(Number(flags.port || config.port), !!flags.loopback);
return serve(Number(flags.port || config.port), !!flags.loopback, flags.demo);
case "key": {
const name = rest[0];
if (!name || !/^[A-Z][A-Z0-9_]*$/.test(name)) {
Expand Down
187 changes: 187 additions & 0 deletions apps/host/src/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* Demo mode: scripted synthetic teammates for recordings. They are present and act through the
* same paths real teammates do. Their messages, asks and reactions are ordinary channel requests
* that the channel admits by its own rules. Only their identities are made up, and only on this
* host: presence and people go to the owner's own app, never to the directory, lobbies or peers.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";

import type { Presence } from "@ace/directory/protocol";

import type { Event } from "@ace/channel/protocol";

import * as catalog from "./catalog";
import { Connection, request } from "./client";
import { failure, log } from "./log";
import type { People } from "./protocol";

type Person = {
/** The participant name their messages carry, like a Tailscale login. */
login: string;
/** Whose GitHub avatar and name the app shows for them. */
github: string;
};

type Target = { channel: string; chat?: number };

/** `at` is seconds from the script's start; each step does one thing. */
type Step =
& { at: number; who: string }
& (
| ({ open: true } & Target)
| ({ close: true } & Target)
| { away: boolean }
| { leave: true }
| ({ say: string } & Target)
| ({ ask: string } & Target)
/** Reacts to the chat's newest message or reply. */
| ({ react: string } & Target)
);

export type Script = {
people: Record<string, Person>;
steps: Step[];
/** Seconds after the last step before the script starts over; once when absent. */
loop?: number;
};

type Here = { channels: Set<string>; away: boolean };

let script: Script | undefined;
const here = new Map<string, Here>();
let project: (channel: string) => Promise<string>;
let changed: () => void;
/** Each listed channel's project, kept from each step so presence can be read synchronously. */
const projects = new Map<string, string>();
let timers: ReturnType<typeof setTimeout>[] = [];

export function people(): People {
const found: People = {};
for (const person of Object.values(script?.people || {})) found[person.login] = person.github;
return found;
}

/** Synthetic people's presence by project, in the lobby's shape. */
export function presence(): Record<string, Presence[]> {
const found: Record<string, Presence[]> = {};
for (const [who, state] of here) {
const person = script!.people[who]!;
const byProject = new Map<string, string[]>();
for (const channel of state.channels) {
const id = projects.get(channel);
if (!id) continue;
byProject.set(id, [...byProject.get(id) || [], channel]);
}
for (const [id, channels] of byProject) {
(found[id] ||= []).push({ login: person.github, host: "demo", channels, away: state.away });
}
}
return found;
}

function channelId(name: string): string {
const record = catalog.list().find((value) => value.name === name || value.id === name);
if (!record) throw new Error(`No channel ${name} on this host`);
return record.id;
}

async function present(who: string, target: Target) {
const id = channelId(target.channel);
projects.set(id, await project(id));
let state = here.get(who);
if (!state) here.set(who, state = { channels: new Set(), away: false });
state.channels.add(id);
state.away = false;
changed();
return id;
}

/** The chat's newest message or reply, read from the replayed transcript. */
async function newest(id: string, chat?: number): Promise<number> {
const connection = await Connection.open(id);
try {
let entry: number | undefined;
const live = Promise.withResolvers<void>();
await connection.request({ op: "watch", ...(chat === undefined ? {} : { chat }) }, {
event(event: Event) {
if (event.kind === "message" || event.kind === "reply") entry = event.entry;
if (event.kind === "live") live.resolve();
},
closed: (error) => live.reject(new Error(error)),
});
await live.promise;
if (entry === undefined) throw new Error(`Nothing to react to in ${id}`);
return entry;
} finally {
connection.close();
}
}

async function run(step: Step) {
const person = script!.people[step.who];
if (!person) throw new Error(`No person ${step.who} in the script`);
const author = person.login;
if ("leave" in step) {
here.delete(step.who);
return changed();
}
if ("away" in step) {
const state = here.get(step.who);
if (state) state.away = step.away;
return changed();
}
if ("close" in step) {
here.get(step.who)?.channels.delete(channelId(step.channel));
return changed();
}
const id = await present(step.who, step);
const chat = step.chat === undefined ? {} : { chat: step.chat };
if ("say" in step) return void await request(id, { op: "say", author, text: step.say, ...chat });
// An ask returns once admitted; the run continues in the channel as anyone's would.
if ("ask" in step) return void await request(id, { op: "ask", author, text: step.ask, ...chat });
if ("react" in step) {
const entry = await newest(id, step.chat);
await request(id, { op: "react", author, entry, emoji: step.react, ...chat });
}
}

function schedule() {
const steps = [...script!.steps].sort((a, b) => a.at - b.at);
let last = 0;
for (const step of steps) {
last = Math.max(last, step.at);
timers.push(setTimeout(() => {
log("info", "demo.step", { who: step.who, at: step.at });
run(step).catch((error) =>
log("warn", "demo.step.failed", { at: step.at, ...failure(error) })
);
}, step.at * 1000));
}
if (script!.loop === undefined) return;
timers.push(setTimeout(() => {
timers = [];
here.clear();
changed();
schedule();
}, (last + script!.loop) * 1000));
}

/** Starts the script in `file`; the returned function stops it and takes everyone away. */
export function start(
file: string,
options: { project(channel: string): Promise<string>; changed(): void },
): () => void {
script = JSON.parse(readFileSync(resolve(file), "utf8")) as Script;
({ 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}`);
changed();
schedule();
return () => {
for (const timer of timers) clearTimeout(timer);
timers = [];
here.clear();
script = undefined;
};
}
26 changes: 23 additions & 3 deletions apps/host/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as catalog from "./catalog";
import { Connection } from "./client";
import * as compute from "./compute";
import { config } from "./config";
import * as demo from "./demo";
import { diagnostics } from "./diagnostics";
import * as directory from "./directory";
import { retryLoginShell, shell } from "./environment";
Expand Down Expand Up @@ -296,6 +297,7 @@ function people(): People {
const known: People = {};
for (const host of directory.read().hosts) if (host.github) known[host.login] = host.github;
Object.assign(known, peers.people());
Object.assign(known, demo.people());
const own = github.login(loginChanged);
if (own) known[catalog.user] = own;
return known;
Expand Down Expand Up @@ -355,9 +357,18 @@ function lobbyChanged(): void {

let presenceSent = "";

/** Lobby presence, with demo mode's synthetic people beside the real ones. */
function present(): Record<string, Presence[]> {
const found = { ...lobby.presence() };
for (const [project, people] of Object.entries(demo.presence())) {
found[project] = [...found[project] || [], ...people];
}
return found;
}

/** Lobby presence reaches only the owner's app; peers never see it. */
function presenceChanged(): void {
const frame = JSON.stringify({ presence: lobby.presence() } satisfies HostFrame);
const frame = JSON.stringify({ presence: present() } satisfies HostFrame);
if (frame === presenceSent) return;
presenceSent = frame;
for (const socket of sockets) if (!socket.data.peer) socket.send(frame);
Expand Down Expand Up @@ -828,7 +839,7 @@ function websocket(): Bun.WebSocketHandler<Client> {
void queue(async () => {
socket.send(JSON.stringify({ projects: await projects.list() } satisfies HostFrame));
socket.send(JSON.stringify({ people: people() } satisfies HostFrame));
socket.send(JSON.stringify({ presence: lobby.presence() } satisfies HostFrame));
socket.send(JSON.stringify({ presence: present() } satisfies HostFrame));
});
},
async message(socket, raw) {
Expand Down Expand Up @@ -1022,7 +1033,7 @@ async function listen(port: number): Promise<Server<Client>> {
* entry, or lobby presence. A second host on this machine would otherwise publish under the
* machine's tailnet name.
*/
export async function serve(port: number, loopback = false): Promise<never> {
export async function serve(port: number, loopback = false, script?: string): Promise<never> {
config.port = port;
config.loopback = loopback;
// Channel workers and agent shells reach this listener for the owner's windows.
Expand Down Expand Up @@ -1120,6 +1131,15 @@ export async function serve(port: number, loopback = false): Promise<never> {
} else if (!loopback) {
console.log("Tailscale is not running; channels stay on this machine");
}
if (script) {
cleanup.push(demo.start(script, {
project: projectOfChannel,
changed: () => {
peopleChanged();
presenceChanged();
},
}));
}
mkdirSync(join(catalog.home, "channels"), { recursive: true });
// Workers create and remove their sockets; tell clients when a channel starts or retires.
const watcher = watch(join(catalog.home, "channels"), { recursive: true }, (_, file) => {
Expand Down
Loading