@timqi/pier 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +97 -0
  3. package/dist/agent/config.js +133 -0
  4. package/dist/agent/credentials.js +179 -0
  5. package/dist/agent/events.js +253 -0
  6. package/dist/agent/models.js +15 -0
  7. package/dist/agent/pi.js +296 -0
  8. package/dist/boards/boards.js +200 -0
  9. package/dist/boards/pier.css +445 -0
  10. package/dist/channels/chains.js +67 -0
  11. package/dist/channels/chunk.js +28 -0
  12. package/dist/channels/commands.js +28 -0
  13. package/dist/channels/config.js +172 -0
  14. package/dist/channels/control.js +71 -0
  15. package/dist/channels/conversations.js +65 -0
  16. package/dist/channels/gatekeeper.js +63 -0
  17. package/dist/channels/panel.js +233 -0
  18. package/dist/channels/receipts.js +104 -0
  19. package/dist/channels/routes.js +110 -0
  20. package/dist/channels/runtime.js +76 -0
  21. package/dist/channels/slack-api.js +296 -0
  22. package/dist/channels/slack-directory.js +77 -0
  23. package/dist/channels/slack-outbound.js +121 -0
  24. package/dist/channels/slack-panel.js +122 -0
  25. package/dist/channels/slack-render.js +214 -0
  26. package/dist/channels/slack-tool.js +334 -0
  27. package/dist/channels/slack.js +510 -0
  28. package/dist/channels/telegram-api.js +78 -0
  29. package/dist/channels/telegram-panel.js +113 -0
  30. package/dist/channels/telegram-render.js +96 -0
  31. package/dist/channels/telegram.js +473 -0
  32. package/dist/channels/types.js +27 -0
  33. package/dist/cli.js +101 -0
  34. package/dist/core/hub.js +53 -0
  35. package/dist/core/identity.js +66 -0
  36. package/dist/core/queue.js +11 -0
  37. package/dist/core/reply.js +202 -0
  38. package/dist/core/router.js +189 -0
  39. package/dist/core/types.js +7 -0
  40. package/dist/db.js +268 -0
  41. package/dist/log.js +55 -0
  42. package/dist/main.js +183 -0
  43. package/dist/paths.js +17 -0
  44. package/dist/secrets.js +191 -0
  45. package/dist/service.js +134 -0
  46. package/dist/settings.js +57 -0
  47. package/dist/tasks/agent.js +197 -0
  48. package/dist/tasks/callbacks.js +140 -0
  49. package/dist/tasks/command.js +74 -0
  50. package/dist/tasks/definitions.js +316 -0
  51. package/dist/tasks/execution.js +141 -0
  52. package/dist/tasks/groups.js +187 -0
  53. package/dist/tasks/messages.js +248 -0
  54. package/dist/tasks/routes.js +219 -0
  55. package/dist/tasks/runs.js +104 -0
  56. package/dist/tasks/service.js +282 -0
  57. package/dist/tasks/store.js +168 -0
  58. package/dist/tasks/tool.js +281 -0
  59. package/dist/tasks/types.js +5 -0
  60. package/dist/web/auth.js +280 -0
  61. package/dist/web/files.js +167 -0
  62. package/dist/web/public/assets/index-8CinH1uR.css +2 -0
  63. package/dist/web/public/assets/index-DAgP1Gq8.js +78 -0
  64. package/dist/web/public/icon-192.png +0 -0
  65. package/dist/web/public/icon-32.png +0 -0
  66. package/dist/web/public/icon-512.png +0 -0
  67. package/dist/web/public/icon-maskable-512.png +0 -0
  68. package/dist/web/public/icon-touch-192.png +0 -0
  69. package/dist/web/public/icon.svg +19 -0
  70. package/dist/web/public/index.html +251 -0
  71. package/dist/web/public/manifest.webmanifest +16 -0
  72. package/dist/web/public/sw.js +21 -0
  73. package/dist/web/server.js +366 -0
  74. package/dist/web/session-state.js +39 -0
  75. package/docs/deploy.md +307 -0
  76. package/package.json +55 -0
  77. package/skills/pier-boards/SKILL.md +210 -0
  78. package/skills/pier-slack/SKILL.md +135 -0
  79. package/skills/pier-tasks/SKILL.md +120 -0
@@ -0,0 +1,191 @@
1
+ // Layer-1 secret encryption: the credentials Pier must read by itself
2
+ // (channel tokens, provider API keys, OAuth tokens) are stored as ciphertext
3
+ // and pass through here. Two keys, standard envelope: a KEK from
4
+ // `~/.pier/master.key` wraps a DEK, and only the DEK touches data — so
5
+ // rotating the KEK rewrites one file and zero data rows. The KEK is either a
6
+ // `vt://` record (decrypted through vt, one approval per process start) or a
7
+ // raw key in the file (the no-vt fallback — same at-rest level as the
8
+ // plaintext files it replaces, and the mode is the operator's explicit
9
+ // choice, never a silent downgrade).
10
+ //
11
+ // Both keys live in `master.key` (JSON: kek, wrapped dek, dek id), not the
12
+ // database: rotation is then a single atomic rename, with no crash window
13
+ // where the file holds the new KEK and the database a DEK wrapped by the old
14
+ // one. Layer 2 — secrets needing per-use approval — never passes through
15
+ // here: those stay `vt://` strings Pier cannot read, and the agent runs vt
16
+ // itself.
17
+ import { spawn } from "node:child_process";
18
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
19
+ import { chmodSync, readFileSync, renameSync, writeFileSync } from "node:fs";
20
+ import { logger } from "./log.js";
21
+ import { pierPath } from "./paths.js";
22
+ const log = logger("secrets");
23
+ const KEY_BYTES = 32; // AES-256
24
+ const IV_BYTES = 12; // GCM standard nonce
25
+ export class Secrets {
26
+ path;
27
+ vt;
28
+ #dek;
29
+ #kek;
30
+ #file;
31
+ /** Why decrypt is refused right now — "" once unlocked. */
32
+ #lockedReason = "unlock() has not run";
33
+ constructor(path = pierPath("master.key"), vt = vtCli) {
34
+ this.path = path;
35
+ this.vt = vt;
36
+ }
37
+ get state() {
38
+ return this.#dek ? "unlocked" : "locked";
39
+ }
40
+ get mode() {
41
+ return this.#file ? (this.#file.kek.startsWith("vt://") ? "vt" : "file") : undefined;
42
+ }
43
+ /** Why decrypt is refused right now — "" once unlocked. Shown in the
44
+ * Console's Security tab, which is where a refused unlock gets repaired. */
45
+ get lockedReason() {
46
+ return this.#lockedReason;
47
+ }
48
+ /**
49
+ * Load master.key — created on first boot, file mode, so an unattended
50
+ * start needs no ceremony; vt mode is entered later via rotate. Throws on a
51
+ * failed vt approval or a corrupt file, and remembers why: the process must
52
+ * keep serving (web is how the operator unlocks or repairs), but every
53
+ * refused decrypt names the reason instead of pretending to be empty.
54
+ */
55
+ async unlock() {
56
+ try {
57
+ let raw;
58
+ try {
59
+ raw = readFileSync(this.path, "utf8");
60
+ }
61
+ catch {
62
+ this.#file = this.#create();
63
+ log.info(`created ${this.path} (file mode)`);
64
+ raw = readFileSync(this.path, "utf8");
65
+ }
66
+ const file = JSON.parse(raw);
67
+ if (!file.kek || !file.dek || !file.dekId)
68
+ throw new Error(`${this.path} is malformed`);
69
+ this.#kek = file.kek.startsWith("vt://")
70
+ ? Buffer.from(await this.vt.read(file.kek), "base64")
71
+ : Buffer.from(file.kek, "base64");
72
+ if (this.#kek.length !== KEY_BYTES)
73
+ throw new Error(`${this.path} KEK is not ${KEY_BYTES} bytes`);
74
+ this.#dek = open(this.#kek, file.dek, `kek:${file.dekId}`);
75
+ this.#file = file;
76
+ this.#lockedReason = "";
77
+ log.info(`secrets unlocked (${this.mode} mode, dek ${file.dekId})`);
78
+ }
79
+ catch (err) {
80
+ this.#lockedReason = String(err);
81
+ throw err;
82
+ }
83
+ }
84
+ encrypt(plaintext) {
85
+ const { dek, file } = this.#unlocked();
86
+ return `v1:${file.dekId}:${seal(dek, plaintext, `v1:${file.dekId}`)}`;
87
+ }
88
+ decrypt(blob) {
89
+ const { dek, file } = this.#unlocked();
90
+ const [v, dekId, ...rest] = blob.split(":");
91
+ if (v !== "v1" || rest.length !== 3)
92
+ throw new Error("not a v1 secret envelope");
93
+ if (dekId !== file.dekId)
94
+ throw new Error(`sealed by unknown key ${dekId}, have ${file.dekId}`);
95
+ return open(dek, rest.join(":"), `v1:${dekId}`).toString("utf8");
96
+ }
97
+ /**
98
+ * New KEK, same DEK: every stored envelope stays valid. `mode` switches how
99
+ * the new KEK is protected (entering vt mode runs `vt create`, one
100
+ * approval); omitted, the current mode is kept. The rewrapped file lands by
101
+ * atomic rename — a crash leaves either the old working file or the new one.
102
+ */
103
+ async rotateKek(mode = this.mode ?? "file") {
104
+ const { file } = this.#unlocked();
105
+ const kek = randomBytes(KEY_BYTES);
106
+ const next = {
107
+ kek: mode === "vt" ? await this.vt.create(kek.toString("base64")) : kek.toString("base64"),
108
+ dek: seal(kek, this.#dek, `kek:${file.dekId}`),
109
+ dekId: file.dekId,
110
+ };
111
+ if (mode === "vt" && !next.kek.startsWith("vt://")) {
112
+ throw new Error("vt create did not return a vt:// record");
113
+ }
114
+ this.#write(next);
115
+ this.#kek = kek;
116
+ this.#file = next;
117
+ log.info(`KEK rotated (${mode} mode, dek ${file.dekId} unchanged)`);
118
+ }
119
+ /** First boot: random KEK and DEK, file mode. */
120
+ #create() {
121
+ const kek = randomBytes(KEY_BYTES);
122
+ const dekId = randomBytes(4).toString("hex");
123
+ const file = {
124
+ kek: kek.toString("base64"),
125
+ dek: seal(kek, randomBytes(KEY_BYTES), `kek:${dekId}`),
126
+ dekId,
127
+ };
128
+ this.#write(file);
129
+ return file;
130
+ }
131
+ #write(file) {
132
+ const tmp = `${this.path}.tmp`;
133
+ writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
134
+ chmodSync(tmp, 0o600); // mode above is masked by umask; this is not
135
+ renameSync(tmp, this.path);
136
+ }
137
+ #unlocked() {
138
+ if (!this.#dek || !this.#file)
139
+ throw new Error(`secrets locked: ${this.#lockedReason}`);
140
+ return { dek: this.#dek, file: this.#file };
141
+ }
142
+ }
143
+ /** AES-256-GCM, `iv:ct:tag` base64. `aad` binds ciphertext to its role, so an
144
+ * envelope pasted into another slot fails closed instead of decrypting. */
145
+ function seal(key, plaintext, aad) {
146
+ const iv = randomBytes(IV_BYTES);
147
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
148
+ cipher.setAAD(Buffer.from(aad));
149
+ const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
150
+ return [iv, ct, cipher.getAuthTag()].map((b) => b.toString("base64")).join(":");
151
+ }
152
+ function open(key, sealed, aad) {
153
+ const [iv, ct, tag] = sealed.split(":").map((part) => Buffer.from(part, "base64"));
154
+ if (!iv || !ct || !tag)
155
+ throw new Error("malformed ciphertext");
156
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
157
+ decipher.setAAD(Buffer.from(aad));
158
+ decipher.setAuthTag(tag);
159
+ return Buffer.concat([decipher.update(ct), decipher.final()]);
160
+ }
161
+ /** The real vt CLI. Absent binary or denied approval both surface as the
162
+ * spawn/exit error — unlock() records it and the operator reads it. */
163
+ export const vtCli = {
164
+ read: (record) => run("vt", ["read", record]),
165
+ create: async (plaintext) => {
166
+ const out = await run("vt", ["create"], plaintext);
167
+ const record = out.match(/vt:\/\/\S+/)?.[0];
168
+ if (!record)
169
+ throw new Error("vt create printed no vt:// record");
170
+ return record;
171
+ },
172
+ };
173
+ function run(cmd, args, stdin) {
174
+ return new Promise((resolvePromise, reject) => {
175
+ const child = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
176
+ let out = "";
177
+ let err = "";
178
+ child.stdout.on("data", (d) => (out += d.toString()));
179
+ child.stderr.on("data", (d) => (err += d.toString()));
180
+ child.on("error", reject);
181
+ child.on("close", (code) => {
182
+ if (code === 0)
183
+ resolvePromise(out.trim());
184
+ else
185
+ reject(new Error(`${cmd} ${args[0]} exited ${code}: ${err.trim() || out.trim()}`));
186
+ });
187
+ if (stdin !== undefined)
188
+ child.stdin.write(stdin);
189
+ child.stdin.end();
190
+ });
191
+ }
@@ -0,0 +1,134 @@
1
+ // The systemd user unit Pier writes for itself.
2
+ //
3
+ // A *user* unit, not a system one: Pier runs as you, reads your Pi
4
+ // configuration and drives sessions in your own directories. As root or a
5
+ // dedicated service user it would be an agent that cannot touch the files you
6
+ // wanted it to work on.
7
+ //
8
+ // Writing this file is a command rather than a page of documentation to copy
9
+ // because two of its lines are only knowable at runtime: the absolute path of
10
+ // the node that is running (systemd starts with a minimal PATH, so a node
11
+ // installed by fnm/nvm/asdf is not on it) and the absolute path of the
12
+ // installed entry point.
13
+ import { execFileSync } from "node:child_process";
14
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
15
+ import { homedir, userInfo } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+ export const UNIT_NAME = "pier.service";
18
+ /** `~/.config/systemd/user/pier.service` — where a user unit belongs. */
19
+ export const unitPath = (home = homedir()) => join(home, ".config", "systemd", "user", UNIT_NAME);
20
+ /** The drop-in Pier writes once and never touches again: what the unit may
21
+ * consume is the operator's call, not ours. */
22
+ export const limitsPath = (home = homedir()) => join(dirname(unitPath(home)), `${UNIT_NAME}.d`, "limits.conf");
23
+ export function renderUnit({ execPath, entry, host, port, pierHome }) {
24
+ return `[Unit]
25
+ Description=Pier — agent workspace
26
+ Documentation=https://github.com/timqi/pier
27
+ After=network-online.target
28
+ Wants=network-online.target
29
+
30
+ [Service]
31
+ Type=simple
32
+ WorkingDirectory=%h
33
+ # Absolute paths on purpose: systemd starts with a minimal PATH, and the node
34
+ # that installed Pier is usually not on it.
35
+ ExecStart=${execPath} ${entry}
36
+ Environment=NODE_ENV=production
37
+ # Loopback by default. Put a reverse proxy in front before widening this —
38
+ # whoever reaches this port can drive an agent that runs a shell.
39
+ Environment=HOST=${host}
40
+ Environment=PORT=${port}
41
+ ${pierHome ? `# Where the database, the boards and the password hash live.\nEnvironment=PIER_HOME=${pierHome}\n` : ""}Restart=always
42
+ RestartSec=2
43
+ # The journal is where the first-run password is printed, so keep it readable.
44
+ StandardOutput=journal
45
+ StandardError=journal
46
+ # Otherwise every line is tagged "node"; this makes \`journalctl -t pier\` work.
47
+ SyslogIdentifier=pier
48
+
49
+ [Install]
50
+ WantedBy=default.target
51
+ `;
52
+ }
53
+ /**
54
+ * Sized as a share of the machine, not as "how much should Pier need": the
55
+ * limit covers node, every subagent and every command a turn ran, and it
56
+ * exists to protect the OS and sshd outside it. Written commented so the
57
+ * operator tuning it can see what each line buys.
58
+ */
59
+ export function renderLimits() {
60
+ return `[Service]
61
+ # Soft ceiling: past this the kernel reclaims hard and lets the unit crawl
62
+ # instead of killing anything. This is the one that should bite first.
63
+ MemoryHigh=60%
64
+ # Hard ceiling: the kernel OOM-kills *inside this cgroup*, so what it leaves
65
+ # behind is for everything outside it.
66
+ MemoryMax=75%
67
+ # Swapping an agent is worse than failing it.
68
+ MemorySwapMax=0
69
+ # A runaway command an agent ran can fork as well as allocate.
70
+ TasksMax=512
71
+ # Prefer this unit's processes if the *machine* runs out anyway.
72
+ OOMScoreAdjust=200
73
+ # A child being OOM-killed must not take the service with it.
74
+ OOMPolicy=continue
75
+ `;
76
+ }
77
+ /** Best-effort: a step that fails says so and the rest still runs, because a
78
+ * half-installed service the operator knows about beats a stack trace. */
79
+ const runner = (say) => (argv) => {
80
+ try {
81
+ execFileSync(argv[0], argv.slice(1), { stdio: "pipe" });
82
+ return true;
83
+ }
84
+ catch (err) {
85
+ const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
86
+ say(`! ${argv.join(" ")} — ${detail}`);
87
+ return false;
88
+ }
89
+ };
90
+ export function install(options) {
91
+ const { force, home = homedir(), say, exec, ...unit } = options;
92
+ const run = exec ?? runner(say);
93
+ const path = unitPath(home);
94
+ if (existsSync(path) && !force) {
95
+ say(`${path} already exists — nothing written. Re-run with --force to replace it.`);
96
+ return;
97
+ }
98
+ mkdirSync(dirname(path), { recursive: true });
99
+ writeFileSync(path, renderUnit(unit), { mode: 0o644 });
100
+ say(`wrote ${path}`);
101
+ const limits = limitsPath(home);
102
+ if (existsSync(limits)) {
103
+ say(`kept ${limits}`); // tuned by hand, by definition
104
+ }
105
+ else {
106
+ mkdirSync(dirname(limits), { recursive: true });
107
+ writeFileSync(limits, renderLimits(), { mode: 0o644 });
108
+ say(`wrote ${limits}`);
109
+ }
110
+ run(["systemctl", "--user", "daemon-reload"]);
111
+ // Without lingering, the user manager stops at logout and takes every
112
+ // scheduled task with it. It can need a polkit prompt, hence best-effort.
113
+ if (!run(["loginctl", "enable-linger", userInfo().username])) {
114
+ say(` run it yourself so Pier survives logout: loginctl enable-linger ${userInfo().username}`);
115
+ }
116
+ if (run(["systemctl", "--user", "enable", "--now", UNIT_NAME])) {
117
+ say(`started. The first run prints a password once: journalctl --user -u pier -e`);
118
+ }
119
+ }
120
+ export function uninstall(home = homedir(), say = console.log, exec) {
121
+ const run = exec ?? runner(say);
122
+ run(["systemctl", "--user", "disable", "--now", UNIT_NAME]);
123
+ for (const path of [unitPath(home), limitsPath(home)]) {
124
+ if (!existsSync(path))
125
+ continue;
126
+ rmSync(path, { force: true });
127
+ say(`removed ${path}`);
128
+ }
129
+ rmSync(dirname(limitsPath(home)), { force: true, recursive: true });
130
+ run(["systemctl", "--user", "daemon-reload"]);
131
+ // Left alone on purpose: the database, the boards, and linger — none of them
132
+ // are this command's to decide about.
133
+ say(`$PIER_HOME is untouched; linger is still enabled.`);
134
+ }
@@ -0,0 +1,57 @@
1
+ // Instance settings: the facts about *this* Pier that are neither a credential
2
+ // nor per-session. Today there is one — the URL it is reached at from outside,
3
+ // which nothing in the process can discover for itself: a request's Host header
4
+ // is whatever a proxy chose to pass on, and an agent writing a board has no
5
+ // request at all.
6
+ //
7
+ // A key-value table, so the next setting is not the next table and not a third
8
+ // kind of storage. It used to be a JSON file, justified by "the agent reads it
9
+ // too" — the agent is told the URL in its system prompt (core/reply.ts), and
10
+ // nothing outside this process ever opened that file.
11
+ import { pierDb } from "./db.js";
12
+ /**
13
+ * `""` clears it, `null` rejects it. Rejecting rather than repairing: a
14
+ * mistyped host quietly turned into a URL produces board links that 404 for
15
+ * the person they were sent to, and the sender never finds out.
16
+ */
17
+ export function normalizePublicUrl(raw) {
18
+ const text = raw.trim();
19
+ if (!text)
20
+ return "";
21
+ let url;
22
+ try {
23
+ // Scheme-less input is the common way to type a host, and https is the
24
+ // only guess worth making for something on the internet.
25
+ url = new URL(text.includes("://") ? text : `https://${text}`);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ if (url.protocol !== "http:" && url.protocol !== "https:")
31
+ return null;
32
+ if (url.search || url.hash || url.username || url.password)
33
+ return null;
34
+ return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
35
+ }
36
+ export class SettingsStore {
37
+ #db;
38
+ constructor(db = pierDb()) {
39
+ this.#db = db;
40
+ }
41
+ get() {
42
+ return { publicUrl: this.#value("publicUrl") ?? "" };
43
+ }
44
+ /** Store an already-normalized value — validation belongs at the boundary
45
+ * that received it, so this never has to guess what the caller meant. */
46
+ setPublicUrl(publicUrl) {
47
+ this.#db.prepare(`
48
+ INSERT INTO settings(key, value) VALUES ('publicUrl', ?)
49
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
50
+ `).run(publicUrl);
51
+ return this.get();
52
+ }
53
+ #value(key) {
54
+ const row = this.#db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
55
+ return row?.value;
56
+ }
57
+ }
@@ -0,0 +1,197 @@
1
+ import { Router } from "../core/router.js";
2
+ import { TaskMessenger } from "./messages.js";
3
+ import { TaskStore } from "./store.js";
4
+ const MAX_ACTIVE_AGENTS = 4;
5
+ const MAX_ACTIVE_PER_ROOT = 4;
6
+ export class AgentTaskRunner {
7
+ factory;
8
+ router;
9
+ store;
10
+ messages;
11
+ changed;
12
+ active = new Set();
13
+ slotWaiters = new Set();
14
+ sessionTails = new Map();
15
+ constructor(factory, router, store, messages, changed) {
16
+ this.factory = factory;
17
+ this.router = router;
18
+ this.store = store;
19
+ this.messages = messages;
20
+ this.changed = changed;
21
+ }
22
+ async execute(run, action, signal, start) {
23
+ await this.acquireSlot(run, signal);
24
+ try {
25
+ const session = await this.resolveSession(run, action);
26
+ return await this.withSession(session.id, async () => {
27
+ await this.waitUntilIdle(session, signal);
28
+ start();
29
+ // No input is no block: `<task_input>\nnull\n</task_input>` is four
30
+ // lines telling the agent nothing, on every run that has no input.
31
+ const input = run.input === undefined || run.input === null
32
+ ? ""
33
+ : `\n\n<task_input>\n${JSON.stringify(run.input, null, 2)}\n</task_input>`;
34
+ const prompt = run.context.resumePrompt ?? `${action.prompt}${input}`;
35
+ run.context.sessionId = session.id;
36
+ run.context.model = session.model;
37
+ run.context.renderedPrompt = prompt;
38
+ this.store.saveRun(run);
39
+ let text = "";
40
+ const unsubscribe = session.subscribe((event) => {
41
+ if (event.type === "turn-end")
42
+ text = event.text;
43
+ });
44
+ const abort = () => void session.abort();
45
+ signal.addEventListener("abort", abort, { once: true });
46
+ // A pre-aborted signal never fires the listener: check before the
47
+ // prompt starts or a cancelled run would hang until its timeout.
48
+ if (signal.aborted)
49
+ throw new Error("cancelled");
50
+ try {
51
+ const turn = session.systemInput(prompt, {
52
+ kind: "task-delegation",
53
+ taskId: run.taskId,
54
+ runId: run.id,
55
+ sourceSessionId: run.sourceSessionId,
56
+ }, "prompt");
57
+ await Promise.resolve();
58
+ this.messages.deliverPendingControls(run);
59
+ await turn;
60
+ if (signal.aborted)
61
+ throw new Error("cancelled");
62
+ if (!text) {
63
+ const history = await session.history();
64
+ text = [...history].reverse().find((turn) => turn.role === "assistant")?.text ?? "";
65
+ }
66
+ return { type: "agent", text, sessionId: session.id };
67
+ }
68
+ finally {
69
+ signal.removeEventListener("abort", abort);
70
+ unsubscribe();
71
+ }
72
+ });
73
+ }
74
+ finally {
75
+ this.releaseSlot(run.id);
76
+ }
77
+ }
78
+ async resolveSession(run, action) {
79
+ if (run.targetSessionId) {
80
+ return this.router.ensure({ channelId: "task", conversationId: run.targetSessionId });
81
+ }
82
+ const listed = await this.factory.list();
83
+ const source = run.sourceSessionId
84
+ ? listed.find((session) => session.id === run.sourceSessionId)
85
+ : undefined;
86
+ const policy = action.session;
87
+ let cwd;
88
+ if (run.sessionMode === "fork") {
89
+ if (!run.sourceSessionId)
90
+ throw new Error("fork requires a source session");
91
+ cwd = policy.mode === "fork" && policy.cwd ? policy.cwd : source?.cwd ?? "";
92
+ }
93
+ else if (policy.mode === "fresh") {
94
+ cwd = policy.cwd;
95
+ }
96
+ else if (policy.mode === "reuse") {
97
+ cwd = listed.find((session) => session.id === policy.sessionId)?.cwd ?? "";
98
+ }
99
+ else {
100
+ cwd = policy.cwd ?? source?.cwd ?? "";
101
+ }
102
+ if (!cwd)
103
+ throw new Error("could not resolve child working directory");
104
+ const opts = {
105
+ cwd,
106
+ name: `${run.context.definition.name} [${run.id.slice(0, 8)}]`,
107
+ // Unspecified model inherits the caller's live model, not the global
108
+ // default; falls back to the default when the caller isn't attached.
109
+ model: action.launch?.model ??
110
+ (run.sourceSessionId ? this.router.modelOf(run.sourceSessionId) : undefined),
111
+ thinking: action.launch?.thinking,
112
+ capabilities: action.launch?.capabilities,
113
+ };
114
+ const session = run.sessionMode === "fork"
115
+ ? await this.factory.fork(run.sourceSessionId, opts)
116
+ : await this.factory.create(opts);
117
+ run.targetSessionId = session.id;
118
+ run.context.sessionId = session.id;
119
+ run.context.cwd = cwd;
120
+ this.store.saveRun(run);
121
+ this.router.attach({ channelId: "task", conversationId: session.id }, session);
122
+ this.changed(run);
123
+ return session;
124
+ }
125
+ async acquireSlot(run, signal) {
126
+ while (this.active.size >= MAX_ACTIVE_AGENTS || this.activeForRoot(run.rootRunId) >= MAX_ACTIVE_PER_ROOT) {
127
+ if (signal.aborted)
128
+ throw new Error("cancelled");
129
+ await new Promise((resolve, reject) => {
130
+ const wake = () => {
131
+ signal.removeEventListener("abort", abort);
132
+ this.slotWaiters.delete(wake);
133
+ resolve();
134
+ };
135
+ const abort = () => {
136
+ this.slotWaiters.delete(wake);
137
+ reject(new Error("cancelled"));
138
+ };
139
+ this.slotWaiters.add(wake);
140
+ signal.addEventListener("abort", abort, { once: true });
141
+ });
142
+ }
143
+ this.active.add(run.id);
144
+ }
145
+ activeForRoot(rootRunId) {
146
+ let count = 0;
147
+ for (const id of this.active) {
148
+ if (this.store.getRun(id)?.rootRunId === rootRunId)
149
+ count += 1;
150
+ }
151
+ return count;
152
+ }
153
+ releaseSlot(id) {
154
+ this.active.delete(id);
155
+ for (const wake of this.slotWaiters)
156
+ wake();
157
+ }
158
+ async withSession(sessionId, fn) {
159
+ const previous = this.sessionTails.get(sessionId) ?? Promise.resolve();
160
+ let release = () => { };
161
+ const gate = new Promise((resolve) => { release = resolve; });
162
+ const tail = previous.then(() => gate);
163
+ this.sessionTails.set(sessionId, tail);
164
+ await previous;
165
+ try {
166
+ return await fn();
167
+ }
168
+ finally {
169
+ release();
170
+ if (this.sessionTails.get(sessionId) === tail)
171
+ this.sessionTails.delete(sessionId);
172
+ }
173
+ }
174
+ async waitUntilIdle(session, signal) {
175
+ // The session's own event stream is the only busy/idle signal — no polling.
176
+ while (session.state === "streaming") {
177
+ if (signal.aborted)
178
+ throw new Error("cancelled");
179
+ await new Promise((resolve, reject) => {
180
+ const settle = (error) => {
181
+ unsubscribe();
182
+ signal.removeEventListener("abort", onAbort);
183
+ if (error)
184
+ reject(error);
185
+ else
186
+ resolve();
187
+ };
188
+ const onAbort = () => settle(new Error("cancelled"));
189
+ const unsubscribe = session.subscribe((event) => {
190
+ if (event.type === "state" && event.state === "idle")
191
+ settle();
192
+ });
193
+ signal.addEventListener("abort", onAbort, { once: true });
194
+ });
195
+ }
196
+ }
197
+ }