teapot-coding-agent 0.11.1 → 0.12.0

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.
package/dist/agent/llm.js CHANGED
@@ -36,7 +36,7 @@ function sanitize(messages) {
36
36
  return m;
37
37
  });
38
38
  }
39
- export async function chat(cfg, messages, tools, signal) {
39
+ export async function chat(cfg, messages, tools, signal, onDelta) {
40
40
  try {
41
41
  const res = await client(cfg).chat.completions.create({
42
42
  model: cfg.model,
@@ -72,6 +72,8 @@ export async function chat(cfg, messages, tools, signal) {
72
72
  if (!message.content && !message.tool_calls) {
73
73
  throw new Error("LLM API error: empty completion");
74
74
  }
75
+ // non-streaming fallback still feeds the UI one final snapshot
76
+ onDelta?.({ text: message.content ?? "", reasoning: reasoning ?? "" });
75
77
  return {
76
78
  message,
77
79
  reasoning,
@@ -165,7 +167,7 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
165
167
  catch (err) {
166
168
  // provider may not support streaming at all — one clean fallback
167
169
  if (!gotChunk && !signal?.aborted)
168
- return chat(cfg, messages, tools, signal);
170
+ return chat(cfg, messages, tools, signal, onDelta);
169
171
  // user interrupt: hand back whatever streamed so far so the harness can
170
172
  // keep the partial output visible instead of losing it
171
173
  if (signal?.aborted && (text || reasoning)) {
@@ -3,7 +3,7 @@
3
3
  * Tool specs are plain JSON-schema function definitions — provider-agnostic.
4
4
  */
5
5
  import { spawn } from "node:child_process";
6
- import { existsSync, promises as fs } from "node:fs";
6
+ import { existsSync, promises as fs, realpathSync } from "node:fs";
7
7
  import path from "node:path";
8
8
  import { discoverSkills, isValidSkillName, readSkillFile, saveSkill, SKILL_FILE, } from "./skills.js";
9
9
  const str = (v, fallback = "") => (typeof v === "string" ? v : fallback);
@@ -23,6 +23,19 @@ function safeJoin(cwd, p) {
23
23
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
24
24
  throw new Error(`path escapes workspace: ${p}`);
25
25
  }
26
+ // symlinks inside the workspace can point anywhere — resolve the real
27
+ // target and confine THAT too (path.resolve alone doesn't follow links)
28
+ let real = abs;
29
+ try {
30
+ real = realpathSync(abs);
31
+ }
32
+ catch {
33
+ /* target may not exist yet (write_file) — lexical check above still holds */
34
+ }
35
+ const relReal = path.relative(cwd, real);
36
+ if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
37
+ throw new Error(`path escapes workspace via symlink: ${p}`);
38
+ }
26
39
  return abs;
27
40
  }
28
41
  /** Run a command in its own process group; kill the whole group on timeout. */
@@ -699,6 +712,110 @@ export const TOOLS = [
699
712
  return { ok: res.ok || text.length > 0, result: clipText(text, num(args.limit, 20_000)) };
700
713
  },
701
714
  },
715
+ {
716
+ name: "spawn_agent",
717
+ description: "Spawn a sub-agent to work a task in parallel (same workspace, own session). " +
718
+ 'context "none" = fresh start with just the task; "fork" = inherit this conversation ' +
719
+ "byte-exactly (provider prefix cache stays warm) before the task is appended. " +
720
+ "Returns the sub-agent id immediately; its finish summary is delivered back to you.",
721
+ parameters: {
722
+ type: "object",
723
+ properties: {
724
+ task: { type: "string", description: "self-contained instructions for the sub-agent" },
725
+ context: { type: "string", enum: ["none", "fork"], description: "default none" },
726
+ name: { type: "string", description: "optional short name fragment for the id" },
727
+ },
728
+ required: ["task"],
729
+ },
730
+ async run(args, ctx) {
731
+ const sa = ctx.subAgents;
732
+ if (!sa)
733
+ return { ok: false, result: "sub-agents are not available here" };
734
+ const task = str(args.task).trim();
735
+ if (!task)
736
+ return { ok: false, result: "task required" };
737
+ const context = args.context === "fork" ? "fork" : "none";
738
+ try {
739
+ const r = await sa.spawn({
740
+ task,
741
+ context,
742
+ name: str(args.name).trim() || undefined,
743
+ });
744
+ return { ok: true, result: `spawned sub-agent ${r.id} — it works in parallel; use list_children / message_agent to steer it, stop_children to halt` };
745
+ }
746
+ catch (e) {
747
+ return { ok: false, result: `spawn failed: ${e.message}` };
748
+ }
749
+ },
750
+ },
751
+ {
752
+ name: "list_children",
753
+ description: "List your live sub-agents: id, status, current goal.",
754
+ parameters: { type: "object", properties: {} },
755
+ async run(_args, ctx) {
756
+ const sa = ctx.subAgents;
757
+ if (!sa)
758
+ return { ok: false, result: "sub-agents are not available here" };
759
+ const kids = sa.list();
760
+ if (!kids.length)
761
+ return { ok: true, result: "(no sub-agents)" };
762
+ return {
763
+ ok: true,
764
+ result: kids
765
+ .map((k) => `${k.id} · ${k.status} · ${clip(k.goal, 60)}`)
766
+ .join("\n"),
767
+ };
768
+ },
769
+ },
770
+ {
771
+ name: "stop_children",
772
+ description: "Stop one or more of your sub-agents. Without ids: stops ALL of them (and their descendants).",
773
+ parameters: {
774
+ type: "object",
775
+ properties: {
776
+ ids: { type: "array", items: { type: "string" }, description: "sub-agent ids; omit for all" },
777
+ },
778
+ },
779
+ async run(args, ctx) {
780
+ const sa = ctx.subAgents;
781
+ if (!sa)
782
+ return { ok: false, result: "sub-agents are not available here" };
783
+ const ids = Array.isArray(args.ids) ? args.ids.map(String) : undefined;
784
+ const r = await sa.stop(ids);
785
+ return {
786
+ ok: true,
787
+ result: r.stopped.length ? `stopped: ${r.stopped.join(", ")}` : "(nothing running to stop)",
788
+ };
789
+ },
790
+ },
791
+ {
792
+ name: "message_agent",
793
+ description: "Send a message to a specific sub-agent (steer it mid-flight or answer its ask_user question).",
794
+ parameters: {
795
+ type: "object",
796
+ properties: {
797
+ id: { type: "string", description: "sub-agent id" },
798
+ text: { type: "string" },
799
+ },
800
+ required: ["id", "text"],
801
+ },
802
+ async run(args, ctx) {
803
+ const sa = ctx.subAgents;
804
+ if (!sa)
805
+ return { ok: false, result: "sub-agents are not available here" };
806
+ const id = str(args.id);
807
+ const text = str(args.text);
808
+ if (!text.trim())
809
+ return { ok: false, result: "text required" };
810
+ try {
811
+ await sa.message(id, text);
812
+ return { ok: true, result: `message delivered to ${id}` };
813
+ }
814
+ catch (e) {
815
+ return { ok: false, result: e.message };
816
+ }
817
+ },
818
+ },
702
819
  {
703
820
  name: "load_skill",
704
821
  description: "Load a skill's full instructions by name. Use when the system prompt's skill list " +
@@ -812,12 +929,17 @@ export function toolSpecs() {
812
929
  function: { name: t.name, description: t.description, parameters: t.parameters },
813
930
  }));
814
931
  }
932
+ /** tools that mutate the workspace — blocked for read-only personas */
933
+ const MUTATING_TOOLS = new Set(["write_file", "edit_file", "apply_patch", "bash"]);
815
934
  export async function executeTool(name, rawArgs, ctx) {
816
935
  const def = TOOLS.find((t) => t.name === name);
817
936
  if (!def)
818
937
  return { ok: false, result: `unknown tool: ${name}` };
819
938
  if (ctx.signal?.aborted)
820
939
  return { ok: false, result: "aborted (harness shutdown)" };
940
+ // read-only personas (researcher/reviewer) are enforced, not just asked
941
+ if (ctx.readOnly && MUTATING_TOOLS.has(name))
942
+ return { ok: false, result: `${name} is blocked: this agent runs with read-only tools` };
821
943
  let args;
822
944
  try {
823
945
  args = rawArgs ? JSON.parse(rawArgs) : {};
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Zod schema for teapot's config — the single source of truth for what a
3
+ * valid config looks like. Used to gate web-UI edits (PUT /api/config) and
4
+ * any programmatic writes, with human-readable error messages.
5
+ *
6
+ * NOTE: validation gates, it does not become the storage format — the master
7
+ * persists the raw user JSON so unknown fields survive round-trips.
8
+ */
9
+ import { z } from "zod";
10
+ export const ProviderSchema = z.object({
11
+ baseUrl: z.union([z.string().url("baseUrl must be a valid URL"), z.literal("")]).optional(),
12
+ apiKey: z.string().optional(),
13
+ model: z.string().optional(),
14
+ });
15
+ export const AgentEntrySchema = z.object({
16
+ id: z
17
+ .string()
18
+ .regex(/^[A-Za-z0-9._-]{1,64}$/, "id: letters/digits/dots/dashes only, max 64"),
19
+ workspace: z.string().min(1, "workspace required"),
20
+ provider: z.string().optional(),
21
+ model: z.string().optional(),
22
+ baseUrl: z.string().optional(),
23
+ apiKey: z.string().optional(),
24
+ contextWindowTokens: z.number().int().positive().optional(),
25
+ parent: z.string().optional(),
26
+ });
27
+ export const TaskSchema = z.object({
28
+ id: z.string().min(1, "task id required").max(64),
29
+ agent: z.string().min(1, "task agent required"),
30
+ schedule: z.string().min(3, "schedule required (cron or 'every Nm')"),
31
+ prompt: z.string().min(1, "prompt required"),
32
+ forked: z.boolean().optional(),
33
+ });
34
+ /** the patch shape accepted by PUT /api/config */
35
+ export const ConfigPatchSchema = z.object({
36
+ providers: z.record(z.string().min(1, "provider name required"), ProviderSchema).optional(),
37
+ defaultProvider: z.string().optional(),
38
+ progressIntervalMs: z.number().int().min(10_000, "progress interval must be ≥ 10s").optional(),
39
+ progressMinChars: z.number().int().min(100).optional(),
40
+ contextTokenBudget: z.number().int().min(1_000).optional(),
41
+ contextWindowTokens: z.number().int().min(1_000).optional(),
42
+ maxSpawnDepth: z.number().int().min(0).max(8).optional(),
43
+ tasks: z.array(TaskSchema).optional(),
44
+ });
45
+ /** Format a ZodError into a compact operator-readable message. */
46
+ export function formatZodError(error) {
47
+ return error.issues
48
+ .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
49
+ .join("; ")
50
+ .slice(0, 500);
51
+ }
package/dist/index.js CHANGED
@@ -1,21 +1,45 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * teapot master entry point.
4
- * Usage: teapot [config.json]
4
+ * Usage: teapot [--port N] [--config file.json] [config.json]
5
5
  * Config may also come from TEAPOT_* env vars (see src/master.ts).
6
+ * First run (no config file): boot anyway and finish setup in the web UI.
6
7
  */
8
+ import { existsSync } from "node:fs";
7
9
  import { loadConfig, resolveConfigPath, Master } from "./master.js";
8
10
  import { buildApp, serveApp } from "./server/api.js";
9
11
  async function main() {
10
- const configPath = resolveConfigPath(process.argv[2]);
12
+ // CLI: teapot [--port N] [-p N] [--config file] [-c file] [config.json]
13
+ let cfgArg;
14
+ let portOverride;
15
+ const args = process.argv.slice(2);
16
+ for (let i = 0; i < args.length; i++) {
17
+ const a = args[i];
18
+ if (a === "--port" || a === "-p")
19
+ portOverride = Number(args[++i]);
20
+ else if (a === "--config" || a === "-c")
21
+ cfgArg = args[++i];
22
+ else if (!cfgArg && !a.startsWith("-"))
23
+ cfgArg = a;
24
+ }
25
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
26
+ console.log("teapot [--port N] [--config file.json] [config.json]");
27
+ console.log(" env: TEAPOT_PORT, TEAPOT_CONFIG_DIR, TEAPOT_DATA_DIR, TEAPOT_API_TOKEN");
28
+ return;
29
+ }
30
+ const configPath = resolveConfigPath(cfgArg);
31
+ const configExisted = existsSync(configPath);
11
32
  const config = loadConfig(configPath);
12
- console.log(`[teapot] config: ${configPath}`);
33
+ if (portOverride !== undefined && !Number.isNaN(portOverride))
34
+ config.port = portOverride;
35
+ console.log(`[teapot] config: ${configPath}${configExisted ? "" : " (not found — first run)"}`);
13
36
  const hasProviders = Object.keys(config.providers ?? {}).length > 0;
14
37
  if (!config.llm.apiKey && !hasProviders)
15
- console.warn("[teapot] warning: no API key configured");
38
+ console.warn("[teapot] warning: no API key configured — finish setup in the web UI");
16
39
  if (!config.llm.model && !hasProviders)
17
40
  console.warn("[teapot] warning: no model configured");
18
41
  const master = new Master(config, configPath);
42
+ master.configFileExists = configExisted;
19
43
  await master.start();
20
44
  const app = buildApp(master);
21
45
  serveApp(app, config.port);
package/dist/master.js CHANGED
@@ -7,9 +7,46 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSy
7
7
  import { randomUUID } from "node:crypto";
8
8
  import path from "node:path";
9
9
  import os from "node:os";
10
+ import { fileURLToPath } from "node:url";
10
11
  import { Agent } from "./agent/agent.js";
11
12
  import { parseSchedule, matches, nextFireAt } from "./scheduler/cron.js";
12
13
  import { bus } from "./bus.js";
14
+ /** how deep sub-agent spawning may nest (parent=0, its subs=1, …) */
15
+ const MAX_SPAWN_DEPTH = 3;
16
+ /**
17
+ * Skills shipped with the package — resolved relative to this module so it
18
+ * works from a global `npm install -g` install, an npx cache, or a repo
19
+ * checkout alike. Lowest-priority skill root (workspace > global > bundled).
20
+ */
21
+ const BUNDLED_SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../skills");
22
+ /** default sub-agent personas — mentionable from the composer (@name) and
23
+ * usable by agents via spawn_agent({persona}) */
24
+ export const SUB_PERSONAS = {
25
+ reviewer: {
26
+ label: "🔍 reviewer",
27
+ directive: "ROLE: code reviewer. Inspect the change set, judge correctness, style, tests; report concrete findings as a prioritized list.",
28
+ readOnly: true,
29
+ },
30
+ tester: {
31
+ label: "🧪 tester",
32
+ directive: "ROLE: test engineer. Write and run tests for the task at hand, hunt edge cases, report pass/fail with exact commands.",
33
+ },
34
+ researcher: {
35
+ label: "🔎 researcher",
36
+ directive: "ROLE: read-only explorer. Search the codebase/docs, map how things work, and report a compact briefing with file:line references.",
37
+ readOnly: true,
38
+ },
39
+ implementer: {
40
+ label: "🔨 implementer",
41
+ directive: "ROLE: hands-on implementer. Make the change end-to-end (code + tests), keep edits small and verified, then report what changed and why.",
42
+ },
43
+ "gyaru-reviewer": {
44
+ label: "💅 gyaru reviewer",
45
+ directive: "ROLE: pre-commit diff reviewer in a blunt gyaru voice. Read the whole diff and hunt: debug leftovers, unclear UI copy, silent data-loss risks, convention drift. Every finding must be concrete — suggested fix or an explicit shrug.",
46
+ readOnly: true,
47
+ },
48
+ };
49
+ ;
13
50
  const CONFIG_DIR = process.env.TEAPOT_CONFIG_DIR ??
14
51
  path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "teapot-coding-agent");
15
52
  const DATA_DIR = process.env.TEAPOT_DATA_DIR ??
@@ -141,6 +178,8 @@ export class Master {
141
178
  startedAt = Date.now();
142
179
  config;
143
180
  configPath;
181
+ /** false on first boot with no config file → web UI shows the setup wizard */
182
+ configFileExists = true;
144
183
  constructor(config, configPath) {
145
184
  this.config = config;
146
185
  this.configPath = configPath;
@@ -176,13 +215,17 @@ export class Master {
176
215
  a.opts.progressMinChars =
177
216
  patch.progressMinChars;
178
217
  }
218
+ if (patch.contextTokenBudget !== undefined)
219
+ this.config.contextTokenBudget = patch.contextTokenBudget;
220
+ if (patch.maxSpawnDepth !== undefined)
221
+ this.config.maxSpawnDepth = patch.maxSpawnDepth;
179
222
  if (patch.tasks) {
180
223
  this.config.tasks = patch.tasks;
181
224
  // rebuild schedule table live
182
225
  this.tasks = patch.tasks.map((t) => ({
183
226
  task: t,
184
227
  schedule: parseSchedule(t.schedule),
185
- lastRunMin: -1,
228
+ lastRunMin: t.lastRunMin ?? -1,
186
229
  }));
187
230
  }
188
231
  this.saveConfig();
@@ -203,12 +246,59 @@ export class Master {
203
246
  this.tasks.push({
204
247
  task: t,
205
248
  schedule: parseSchedule(t.schedule),
206
- lastRunMin: -1,
249
+ lastRunMin: t.lastRunMin ?? -1,
207
250
  });
208
251
  }
209
252
  // single low-frequency tick for everything periodic (idle cost ≈ 0)
210
253
  setInterval(() => void this.tick(), 15_000).unref();
211
254
  }
255
+ /**
256
+ * First-run wizard bootstrap: write a minimal config file, apply it to the
257
+ * running master, and optionally create + start the first agent. Only
258
+ * available while no config file exists — after this, edits go through
259
+ * PUT /api/config behind whatever auth is configured.
260
+ */
261
+ async applySetup(body) {
262
+ if (this.configFileExists)
263
+ throw new Error("setup already completed — edit the config file instead");
264
+ const llm = {
265
+ baseUrl: body.baseUrl,
266
+ ...(body.apiKey ? { apiKey: body.apiKey } : {}),
267
+ model: body.model,
268
+ };
269
+ const raw = {
270
+ llm,
271
+ ...(body.password ? { password: body.password } : {}),
272
+ agents: [],
273
+ };
274
+ const dir = path.dirname(this.configPath);
275
+ mkdirSync(dir, { recursive: true });
276
+ writeFileSync(this.configPath, JSON.stringify(raw, null, 2) + "\n");
277
+ // hot-apply to the running master (no restart needed)
278
+ this.config.llm = { ...this.config.llm, ...llm };
279
+ if (body.password)
280
+ this.config.password = body.password;
281
+ this.configFileExists = true;
282
+ this.raw.llm = llm;
283
+ if (body.password)
284
+ this.raw.password = body.password;
285
+ let agentId;
286
+ if (body.workspace?.trim()) {
287
+ const ws = path.resolve(body.workspace.replace(/^~/, process.env.HOME ?? "~"));
288
+ await mkdirSync(ws, { recursive: true });
289
+ const name = (body.agentName?.trim() || path.basename(ws))
290
+ .replace(/[^\w.-]/g, "-")
291
+ .slice(0, 40) || "agent";
292
+ let id = name;
293
+ let n = 2;
294
+ while (this.agents.has(id))
295
+ id = `${name.slice(0, 38)}-${n++}`;
296
+ const agent = await this.addAgent({ id, workspace: ws }, { persist: true, fresh: true });
297
+ agentId = id;
298
+ agent.start("created by setup wizard");
299
+ }
300
+ return { ok: true, agentId };
301
+ }
212
302
  /**
213
303
  * Create an agent; optionally persist it to the config file.
214
304
  * Each incarnation gets its own session directory under
@@ -233,6 +323,9 @@ export class Master {
233
323
  };
234
324
  if (!llm.model)
235
325
  throw new Error(`agent ${ac.id}: no model configured (set model on the agent or on its provider)`);
326
+ // spawn-tree depth: computed from the config chain (ac may not be
327
+ // registered yet when this runs — spawnChildFor persists AFTER creation)
328
+ const myDepth = ac.parent ? this.depthOf(ac.parent) + 1 : 0;
236
329
  const sessionDir = this.resolveSessionDir(ac.id, opts.fresh === true);
237
330
  await mkdirSync(sessionDir, { recursive: true });
238
331
  const agent = new Agent({
@@ -248,7 +341,12 @@ export class Master {
248
341
  ? { contextWindowTokens: (ac.contextWindowTokens ?? this.config.contextWindowTokens) }
249
342
  : {}),
250
343
  globalSkillsDir: path.join(CONFIG_DIR, "skills"),
344
+ bundledSkillsDir: BUNDLED_SKILLS_DIR,
251
345
  provider: provName,
346
+ spawnDepth: myDepth,
347
+ ...(ac.readOnly ? { readOnlyTools: true } : {}),
348
+ ...(ac.parent ? { parent: ac.parent } : {}),
349
+ ...(ac.chatFn ? { chatFn: ac.chatFn } : {}),
252
350
  });
253
351
  // console line + broadcast: the web UI only refreshes on bus traffic, so
254
352
  // every appended event must reach it (otherwise messages sit invisible
@@ -256,7 +354,24 @@ export class Master {
256
354
  agent.log.onEvent = (e) => {
257
355
  printAgentEvent(e);
258
356
  bus.emit("update", { kind: "event", agentId: e.agent, event: e });
357
+ this.onChildEvent(ac, e);
259
358
  };
359
+ // sub-agent management hooks — only when this agent can legally spawn
360
+ if (myDepth < this.maxSpawnDepth()) {
361
+ const self = agent;
362
+ const hooks = {
363
+ depth: myDepth,
364
+ spawn: (o) => this.spawnChildFor(self, o),
365
+ list: () => this.childrenOf(ac.id).map((c) => ({
366
+ id: c.id,
367
+ status: c.agent.status,
368
+ goal: c.agent.goal.text,
369
+ })),
370
+ stop: (ids) => this.stopChildrenFor(ac.id, ids),
371
+ message: (id, text) => this.messageChild(ac.id, id, text),
372
+ };
373
+ agent.toolCtx.subAgents = hooks;
374
+ }
260
375
  await agent.init();
261
376
  this.agents.set(ac.id, agent);
262
377
  if (opts.persist) {
@@ -265,6 +380,156 @@ export class Master {
265
380
  }
266
381
  return agent;
267
382
  }
383
+ /** spawn-tree depth of an agent (0 = top level); unknown → 0 */
384
+ depthOf(id, seen = new Set()) {
385
+ const cap = this.maxSpawnDepth();
386
+ let depth = 0;
387
+ let cur = this.config.agents.find((a) => a.id === id);
388
+ while (cur?.parent && !seen.has(cur.id) && depth < cap + 2) {
389
+ seen.add(cur.id);
390
+ cur = this.config.agents.find((a) => a.id === cur.parent);
391
+ depth++;
392
+ }
393
+ return depth;
394
+ }
395
+ /** configured nesting limit (config.maxSpawnDepth, default 3) */
396
+ maxSpawnDepth() {
397
+ const v = this.config.maxSpawnDepth;
398
+ return typeof v === "number" && v >= 0 ? v : MAX_SPAWN_DEPTH;
399
+ }
400
+ /** direct children of an agent, with their Agent instances */
401
+ childrenOf(id) {
402
+ const kids = this.config.agents.filter((a) => a.parent === id);
403
+ const out = [];
404
+ for (const k of kids) {
405
+ const inst = this.agents.get(k.id);
406
+ if (inst)
407
+ out.push({ id: k.id, agent: inst });
408
+ }
409
+ return out;
410
+ }
411
+ /**
412
+ * Create a sub-agent on behalf of `parent`. context "fork" writes a
413
+ * sub_fork header into the child log pointing at the parent's current
414
+ * tip — the parent's history is NEVER copied into the child's file; the
415
+ * child resolves it at restore time.
416
+ */
417
+ async spawnChildFor(parent, o) {
418
+ const parentId = parent.opts_id();
419
+ const persona = o.persona && Object.prototype.hasOwnProperty.call(SUB_PERSONAS, o.persona)
420
+ ? o.persona
421
+ : undefined;
422
+ const base = `${parentId}-sub${persona ? `-${persona}` : ""}${o.name ? `-${o.name.replace(/[^\w.-]/g, "-").slice(0, 24)}` : ""}`.slice(0, 60);
423
+ let id = base;
424
+ let n = 2;
425
+ while (this.agents.has(id) || this.config.agents.some((a) => a.id === id))
426
+ id = `${base.slice(0, 56)}-${n++}`;
427
+ // persona directives shape how the sub approaches the task
428
+ const directive = persona ? `${SUB_PERSONAS[persona].label}\n${SUB_PERSONAS[persona].directive}\n\n` : "";
429
+ const pcfg = this.config.agents.find((a) => a.id === parentId);
430
+ let forkTip;
431
+ let forkBranch;
432
+ if (o.context === "fork") {
433
+ // capture the fork tip BEFORE creating the child (parent keeps running)
434
+ forkTip = parent.log.lastEventId(parent.currentBranch);
435
+ forkBranch = parent.currentBranch;
436
+ }
437
+ const child = await this.addAgent({
438
+ id,
439
+ workspace: parent.workspace,
440
+ provider: pcfg?.provider,
441
+ model: pcfg?.model,
442
+ contextWindowTokens: pcfg?.contextWindowTokens,
443
+ parent: parentId,
444
+ ...(persona && SUB_PERSONAS[persona]?.readOnly ? { readOnly: true } : {}),
445
+ }, { persist: true, fresh: true });
446
+ if (o.context === "fork" && forkTip) {
447
+ await child.log.append("sub_fork", id, "br0", {
448
+ parentAgent: parentId,
449
+ parentSession: path.basename(parent.snapshot().sessionDir),
450
+ parentBranch: forkBranch,
451
+ upToEvent: forkTip,
452
+ });
453
+ // the inherited prefix becomes visible history for the child's loop
454
+ child.importMessages(parent.exportMessages());
455
+ }
456
+ await child.setGoal(`${directive}${o.task}`.slice(0, 2000));
457
+ await child.enqueuePrompt((o.context === "fork"
458
+ ? `[harness] You are sub-agent ${id}, spawned by @${parentId} with the conversation above. `
459
+ : `[harness] You are sub-agent ${id}, spawned by @${parentId}. `) +
460
+ `Work solely on this task:\n\n${directive}${o.task}`, "harness");
461
+ child.start(`spawned by ${parentId}`);
462
+ console.log(`[teapot] sub-agent ${id} spawned by ${parentId} (context: ${o.context ?? "none"}${persona ? `, persona: ${persona}` : ""})`);
463
+ return { id };
464
+ }
465
+ /** Stop direct children (and their descendants by default) of an agent. */
466
+ async stopChildrenFor(parentId, ids) {
467
+ const stopped = [];
468
+ const walk = (pid) => {
469
+ for (const { id, agent } of this.childrenOf(pid)) {
470
+ if (ids && !ids.includes(id)) {
471
+ // still descend: stopping a parent implies stopping its subtree
472
+ walk(id);
473
+ continue;
474
+ }
475
+ agent.stop("stopped by parent agent");
476
+ stopped.push(id);
477
+ if (ids) {
478
+ // explicit id → also take its subtree
479
+ const sub = this.childrenOf(id);
480
+ for (const { id: gid, agent: g } of sub) {
481
+ g.stop("stopped with parent");
482
+ stopped.push(gid);
483
+ }
484
+ }
485
+ else {
486
+ walk(id); // full-subtree default
487
+ }
488
+ }
489
+ };
490
+ walk(parentId);
491
+ return { stopped };
492
+ }
493
+ /** Deliver a parent's message into a child's mailbox (and wake it). */
494
+ async messageChild(parentId, childId, text) {
495
+ const cfg = this.config.agents.find((a) => a.id === childId);
496
+ if (!cfg || cfg.parent !== parentId)
497
+ throw new Error(`not your sub-agent: ${childId}`);
498
+ const child = this.agents.get(childId);
499
+ if (!child)
500
+ throw new Error(`sub-agent not running: ${childId}`);
501
+ child.enqueuePrompt(`[harness] Message from @${parentId}:\n\n${text}`, "harness");
502
+ if (child.status !== "running")
503
+ child.start(`message from ${parentId}`);
504
+ }
505
+ /**
506
+ * Mirror interesting child events into the parent's timeline (tagged with
507
+ * the acting sub id) and forward terminal outcomes to the parent's mailbox.
508
+ */
509
+ onChildEvent(ac, e) {
510
+ const parentId = ac.parent;
511
+ if (!parentId)
512
+ return;
513
+ const parent = this.agents.get(parentId);
514
+ if (!parent)
515
+ return;
516
+ // forward outcomes so the parent hears the result without polling
517
+ if (e.type === "message" && e.data.final) {
518
+ const summary = String(e.data.content ?? "").trim();
519
+ parent.enqueuePrompt(`[harness] Sub-agent ${ac.id} finished. Final report:\n${summary.slice(0, 2000) || "(no summary)"}`, "harness");
520
+ }
521
+ else if (e.type === "error") {
522
+ parent.enqueuePrompt(`[harness] Sub-agent ${ac.id} hit an error: ${String(e.data.message ?? "").slice(0, 500)}`, "harness");
523
+ }
524
+ // mirror feed-worthy activity; nested subs keep their original actor id
525
+ const MIRROR = new Set(["prompt", "message", "tool_call", "tool_result", "progress", "error", "question", "state"]);
526
+ if (!MIRROR.has(e.type))
527
+ return;
528
+ const inner = e.type === "sub" ? e.data : null;
529
+ const actor = inner?.sub ?? ac.id;
530
+ const payload = inner ? { sub: actor, type: inner.type, data: inner.data } : { sub: ac.id, type: e.type, data: e.data };
531
+ void parent.log.append("sub", parent.currentSession, parent.currentBranch, payload);
532
+ }
268
533
  /** sessions root + helpers */
269
534
  sessionsRoot() {
270
535
  return path.join(this.config.dataDir, "sessions");
@@ -390,6 +655,9 @@ export class Master {
390
655
  continue; // dedupe within the same minute
391
656
  t.lastRunMin = minuteKey;
392
657
  t.lastRunAt = Date.now();
658
+ // persist so a master restart doesn't re-fire the same minute
659
+ t.task.lastRunMin = minuteKey;
660
+ this.saveConfig();
393
661
  const agent = this.agents.get(t.task.agent);
394
662
  if (!agent)
395
663
  continue;