@agentplate/cli 1.2.0 → 1.3.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/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ All notable changes to Agentplate are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/), and the project aims to adhere to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.3.0] — 2026-06-02
8
+
9
+ ### Added
10
+
11
+ - **`agentplate watch`** — the mail pump that makes warm-start automatic: it
12
+ advances every **idle** agent with unread mail to its next (resumed) turn,
13
+ driving eligible agents concurrently up to `agents.maxConcurrent`. Modes:
14
+ `--once`, `--until-idle`, or loop until Ctrl-C.
15
+ - **Hard per-turn timeout** (`agents.turnTimeoutMinutes`, 0 = off) — kills a turn
16
+ that runs past the cap even while still streaming (idle reaping only catches
17
+ inactivity).
18
+ - **Per-capability runtime** — `runtime.capabilities[capability]` now selects the
19
+ runtime adapter per role (previously defined but unused).
20
+ - **Speed shortcuts** — `agents.skipScout` / `skipReview` (surfaced as lead overlay
21
+ constraints) and `agents.skipGates` / `skipSkills` (honored on the turn path).
22
+ - **Wizard** — a gated "advanced limits" step (concurrency, turn-timeout, skips).
23
+
24
+ ### Changed
25
+
26
+ - The turn path is shared via `driveTurn` / `driveAgentTurn`, used by `sling`
27
+ (turn 1), `agentplate turn` (single follow-up), and `agentplate watch`.
28
+
7
29
  ## [1.2.0] — 2026-06-02
8
30
 
9
31
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentplate/cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -12,12 +12,15 @@
12
12
  * (resumed when `resumeSessionId` is given) — there is no long-lived agent.
13
13
  */
14
14
 
15
+ import { existsSync } from "node:fs";
15
16
  import type { EventStore } from "../events/store.ts";
16
17
  import { runQualityGates } from "../insights/quality-gates.ts";
17
18
  import type { MailClient } from "../mail/client.ts";
18
19
  import { createMailStore } from "../mail/store.ts";
19
20
  import { maybeAutoMerge } from "../merge/auto.ts";
20
- import { mailDbPath } from "../paths.ts";
21
+ import { mailDbPath, manifestFilePath } from "../paths.ts";
22
+ import { getRuntime, runtimeNameForCapability } from "../runtimes/registry.ts";
23
+ import { resolveModel } from "../runtimes/resolve.ts";
21
24
  import type { AgentRuntime } from "../runtimes/types.ts";
22
25
  import type { SessionStore } from "../sessions/store.ts";
23
26
  import { runSkillFeedbackAndDistill } from "../skills/lifecycle.ts";
@@ -30,6 +33,7 @@ import type {
30
33
  SessionState,
31
34
  } from "../types.ts";
32
35
  import { updateIdentity } from "./identity.ts";
36
+ import { buildDefaultManifest, getDefinition, loadManifest } from "./manifest.ts";
33
37
  import { runTurn } from "./turn-runner.ts";
34
38
 
35
39
  /** Terminal mail types whose presence marks a capability's work complete. */
@@ -106,6 +110,8 @@ export async function driveTurn(ctx: DriveTurnCtx): Promise<DriveTurnResult> {
106
110
  prompt: ctx.prompt,
107
111
  env: model.env,
108
112
  resumeSessionId: ctx.resumeSessionId,
113
+ timeoutMs:
114
+ config.agents.turnTimeoutMinutes > 0 ? config.agents.turnTimeoutMinutes * 60_000 : undefined,
109
115
  onEvent: (event) => {
110
116
  if (event.error || event.type === "error") sawError = true;
111
117
  // Prefer the error message (so a failed agent's reason is visible in the
@@ -123,6 +129,17 @@ export async function driveTurn(ctx: DriveTurnCtx): Promise<DriveTurnResult> {
123
129
  });
124
130
  if (turn.runtimeSessionId) store.setRuntimeSessionId(sessionId, turn.runtimeSessionId);
125
131
 
132
+ // Record a clear reason when the wall-clock cap killed the turn.
133
+ if (turn.timedOut) {
134
+ events.record({
135
+ agentName: name,
136
+ runId,
137
+ type: "error",
138
+ tool: null,
139
+ detail: `Turn killed: exceeded agents.turnTimeoutMinutes (${config.agents.turnTimeoutMinutes}m).`,
140
+ });
141
+ }
142
+
126
143
  // A non-zero exit with no error event means the runtime failed via stderr;
127
144
  // record it so the failure reason is visible instead of a blank "failed".
128
145
  if (turn.exitCode !== 0 && !sawError) {
@@ -147,15 +164,19 @@ export async function driveTurn(ctx: DriveTurnCtx): Promise<DriveTurnResult> {
147
164
  });
148
165
 
149
166
  // Quality gates run once when EITHER the self-improving loop or auto-merge
150
- // needs them; the outcome feeds both. Best-effort — never fails the turn.
167
+ // needs them (and gates aren't skipped); the outcome feeds both. Best-effort.
168
+ const runSkills = config.skills.enabled && !config.agents.skipSkills;
151
169
  const autoMergeWants =
152
170
  config.merge.autoMerge !== "off" && capability !== "scout" && capability !== "merger";
171
+ const wantGates = !config.agents.skipGates && (runSkills || autoMergeWants);
153
172
  let gateStatus: OutcomeStatus | null = null;
154
- if (finalState === "completed" && (config.skills.enabled || autoMergeWants)) {
173
+ if (finalState === "completed" && (wantGates || runSkills)) {
155
174
  try {
156
- const gateOutcome = await runQualityGates(config.project.qualityGates ?? [], worktreePath);
157
- gateStatus = gateOutcome?.status ?? null;
158
- if (config.skills.enabled) {
175
+ if (wantGates) {
176
+ const gateOutcome = await runQualityGates(config.project.qualityGates ?? [], worktreePath);
177
+ gateStatus = gateOutcome?.status ?? null;
178
+ }
179
+ if (runSkills) {
159
180
  await runSkillFeedbackAndDistill({
160
181
  root,
161
182
  agentName: name,
@@ -198,3 +219,52 @@ export async function driveTurn(ctx: DriveTurnCtx): Promise<DriveTurnResult> {
198
219
 
199
220
  return { finalState, exitCode: turn.exitCode, gateStatus };
200
221
  }
222
+
223
+ export interface DriveAgentTurnCtx {
224
+ root: string;
225
+ config: AgentplateConfig;
226
+ session: AgentSession;
227
+ store: SessionStore;
228
+ events: EventStore;
229
+ mail: MailClient;
230
+ }
231
+
232
+ /**
233
+ * Run the next (resumed) turn for an existing session: resolve its runtime + model
234
+ * + manifest def, inject its unread mail as the prompt, and {@link driveTurn} with
235
+ * the stored `runtimeSessionId` (warm start). Shared by `agentplate turn` (single)
236
+ * and `agentplate watch` (the mail pump). Assumes the caller has already decided
237
+ * the session is drivable.
238
+ */
239
+ export async function driveAgentTurn(ctx: DriveAgentTurnCtx): Promise<DriveTurnResult> {
240
+ const { root, config, session } = ctx;
241
+ const manifestPath = manifestFilePath(root);
242
+ const manifest = existsSync(manifestPath) ? loadManifest(manifestPath) : buildDefaultManifest();
243
+ const def = getDefinition(manifest, session.capability);
244
+ const runtime = getRuntime(
245
+ runtimeNameForCapability(config.runtime, session.capability),
246
+ config.runtime.default,
247
+ );
248
+ const model = resolveModel(config, root, def.model, session.capability);
249
+
250
+ // The turn's user text is the agent's unread mail (a child's reply / operator
251
+ // direction); fall back to a continue nudge. checkInject marks it read.
252
+ const injected = ctx.mail.checkInject(session.agentName);
253
+ const prompt =
254
+ injected.trim().length > 0
255
+ ? injected
256
+ : "Continue your task. If it is complete, send your terminal mail.";
257
+
258
+ return driveTurn({
259
+ root,
260
+ config,
261
+ runtime,
262
+ store: ctx.store,
263
+ events: ctx.events,
264
+ mail: ctx.mail,
265
+ session,
266
+ model,
267
+ prompt,
268
+ resumeSessionId: session.runtimeSessionId ?? undefined,
269
+ });
270
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Tests for runTurn — focused on the hard wall-clock cap. Real subprocesses via
3
+ * the mock runtime (a `bash -lc` snippet), so we exercise true kill behavior.
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { MockRuntime } from "../runtimes/mock.ts";
11
+ import { runTurn } from "./turn-runner.ts";
12
+
13
+ let cwd: string;
14
+ const runtime = new MockRuntime();
15
+
16
+ beforeEach(() => {
17
+ cwd = mkdtempSync(join(tmpdir(), "agentplate-turnrunner-"));
18
+ });
19
+ afterEach(() => {
20
+ rmSync(cwd, { recursive: true, force: true });
21
+ process.env.AGENTPLATE_MOCK_CMD = undefined;
22
+ });
23
+
24
+ describe("runTurn — turn timeout", () => {
25
+ test("kills a turn that exceeds timeoutMs and flags timedOut", async () => {
26
+ process.env.AGENTPLATE_MOCK_CMD = "sleep 10"; // would hang well past the cap
27
+ const started = performance.now();
28
+ const result = await runTurn({
29
+ runtime,
30
+ worktreePath: cwd,
31
+ model: "m",
32
+ prompt: "",
33
+ timeoutMs: 200,
34
+ });
35
+ const elapsed = performance.now() - started;
36
+
37
+ expect(result.timedOut).toBe(true);
38
+ expect(result.exitCode).not.toBe(0); // killed → non-zero
39
+ expect(elapsed).toBeLessThan(3000); // resolved at the cap, not after 10s
40
+ });
41
+
42
+ test("does not flag timedOut when the turn finishes within the cap", async () => {
43
+ process.env.AGENTPLATE_MOCK_CMD = "true"; // instant exit 0
44
+ const result = await runTurn({
45
+ runtime,
46
+ worktreePath: cwd,
47
+ model: "m",
48
+ prompt: "",
49
+ timeoutMs: 5000,
50
+ });
51
+ expect(result.timedOut).toBe(false);
52
+ expect(result.exitCode).toBe(0);
53
+ });
54
+
55
+ test("no cap when timeoutMs is omitted/zero", async () => {
56
+ process.env.AGENTPLATE_MOCK_CMD = "true";
57
+ const result = await runTurn({
58
+ runtime,
59
+ worktreePath: cwd,
60
+ model: "m",
61
+ prompt: "",
62
+ timeoutMs: 0,
63
+ });
64
+ expect(result.timedOut).toBe(false);
65
+ expect(result.exitCode).toBe(0);
66
+ });
67
+ });
@@ -23,6 +23,8 @@ export interface RunTurnOptions {
23
23
  env?: Record<string, string>;
24
24
  /** Prior runtime session id, to resume across turns. */
25
25
  resumeSessionId?: string;
26
+ /** Hard wall-clock cap in ms; the child is killed past it. 0/undefined = none. */
27
+ timeoutMs?: number;
26
28
  /** Called for each parsed event (e.g. to record tool calls). */
27
29
  onEvent?: (event: AgentEvent) => void;
28
30
  }
@@ -33,6 +35,8 @@ export interface TurnResult {
33
35
  runtimeSessionId: string | null;
34
36
  /** Captured stderr (already bounded by the child). */
35
37
  stderr: string;
38
+ /** True if the turn was killed by the wall-clock cap. */
39
+ timedOut: boolean;
36
40
  }
37
41
 
38
42
  /** Run a single headless turn and resolve when the child process exits. */
@@ -59,6 +63,18 @@ export async function runTurn(opts: RunTurnOptions): Promise<TurnResult> {
59
63
  stdin: "ignore",
60
64
  });
61
65
 
66
+ // Hard wall-clock cap: kill a turn that runs past the limit even if it keeps
67
+ // streaming (idle reaping only catches inactivity). Closing the child's pipes
68
+ // ends the drain/parse loops below, so the turn resolves with a non-zero exit.
69
+ let timedOut = false;
70
+ const timer =
71
+ opts.timeoutMs && opts.timeoutMs > 0
72
+ ? setTimeout(() => {
73
+ timedOut = true;
74
+ proc.kill(); // SIGTERM
75
+ }, opts.timeoutMs)
76
+ : null;
77
+
62
78
  // Read stderr concurrently so a full pipe buffer can't deadlock the child.
63
79
  const stderrPromise = new Response(proc.stderr).text();
64
80
 
@@ -75,5 +91,6 @@ export async function runTurn(opts: RunTurnOptions): Promise<TurnResult> {
75
91
 
76
92
  const stderr = await stderrPromise;
77
93
  const exitCode = await proc.exited;
78
- return { exitCode, runtimeSessionId, stderr };
94
+ if (timer) clearTimeout(timer);
95
+ return { exitCode, runtimeSessionId, stderr, timedOut };
79
96
  }
@@ -31,7 +31,7 @@ import {
31
31
  packageAgentDefPath,
32
32
  sessionsDbPath,
33
33
  } from "../paths.ts";
34
- import { getRuntime } from "../runtimes/registry.ts";
34
+ import { getRuntime, runtimeNameForCapability } from "../runtimes/registry.ts";
35
35
  import { resolveModel } from "../runtimes/resolve.ts";
36
36
  import { createSessionStore } from "../sessions/store.ts";
37
37
  import { retrieveSkillsForSpawn } from "../skills/lifecycle.ts";
@@ -159,7 +159,18 @@ export function createSlingCommand(): Command {
159
159
  });
160
160
 
161
161
  // 3. Overlay (base definition + assignment + skills) → instruction file.
162
- const runtime = getRuntime(opts.runtime ?? config.runtime.default, config.runtime.default);
162
+ const runtime = getRuntime(
163
+ runtimeNameForCapability(config.runtime, capability, opts.runtime),
164
+ config.runtime.default,
165
+ );
166
+ // Surface project skip-defaults as constraints the spawning agent obeys.
167
+ const skipDirectives: string[] = [];
168
+ if (def.canSpawn && config.agents.skipScout) {
169
+ skipDirectives.push("Skip the scout step — dispatch builders directly (--skip-scout).");
170
+ }
171
+ if (def.canSpawn && config.agents.skipReview) {
172
+ skipDirectives.push("Skip the reviewer step before integrating (--skip-review).");
173
+ }
163
174
  const overlayConfig: OverlayConfig = {
164
175
  agentName: name,
165
176
  capability,
@@ -173,7 +184,7 @@ export function createSlingCommand(): Command {
173
184
  baseDefinition: readBaseDefinition(root, def.file),
174
185
  canSpawn: def.canSpawn,
175
186
  qualityGates: config.project.qualityGates ?? [],
176
- constraints: def.constraints,
187
+ constraints: [...def.constraints, ...skipDirectives],
177
188
  siblings: opts.siblings ? opts.siblings.split(",").map((s) => s.trim()) : undefined,
178
189
  skillsOverlay: skillsOverlay || undefined,
179
190
  };
@@ -12,19 +12,15 @@
12
12
  * call is one fresh, resumed runtime subprocess.
13
13
  */
14
14
 
15
- import { existsSync } from "node:fs";
16
15
  import { Command } from "commander";
17
- import { driveTurn } from "../agents/drive.ts";
18
- import { buildDefaultManifest, getDefinition, loadManifest } from "../agents/manifest.ts";
16
+ import { driveAgentTurn } from "../agents/drive.ts";
19
17
  import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
20
18
  import { NotFoundError, ValidationError } from "../errors.ts";
21
19
  import { createEventStore } from "../events/store.ts";
22
20
  import { jsonOutput } from "../json.ts";
23
21
  import { brand, muted, printInfo, printSuccess } from "../logging/color.ts";
24
22
  import { createMailClient } from "../mail/client.ts";
25
- import { eventsDbPath, manifestFilePath, sessionsDbPath } from "../paths.ts";
26
- import { getRuntime } from "../runtimes/registry.ts";
27
- import { resolveModel } from "../runtimes/resolve.ts";
23
+ import { eventsDbPath, sessionsDbPath } from "../paths.ts";
28
24
  import { createSessionStore } from "../sessions/store.ts";
29
25
  import type { SessionState } from "../types.ts";
30
26
 
@@ -60,34 +56,13 @@ export function createTurnCommand(): Command {
60
56
  );
61
57
  }
62
58
 
63
- const manifestPath = manifestFilePath(root);
64
- const manifest = existsSync(manifestPath)
65
- ? loadManifest(manifestPath)
66
- : buildDefaultManifest();
67
- const def = getDefinition(manifest, session.capability);
68
- const runtime = getRuntime(config.runtime.default, config.runtime.default);
69
- const model = resolveModel(config, root, def.model, session.capability);
70
-
71
- // The next turn's user text is the agent's unread mail (e.g. a child's
72
- // reply or operator direction); fall back to a continue nudge.
73
- const injected = mail.checkInject(agent);
74
- const prompt =
75
- injected.trim().length > 0
76
- ? injected
77
- : "Continue your task. If it is complete, send your terminal mail.";
78
-
79
- const { finalState, exitCode } = await driveTurn({
59
+ const { finalState, exitCode } = await driveAgentTurn({
80
60
  root,
81
61
  config,
82
- runtime,
62
+ session,
83
63
  store,
84
64
  events,
85
65
  mail,
86
- session,
87
- model,
88
- prompt,
89
- // Warm start: resume the runtime session captured on a prior turn.
90
- resumeSessionId: session.runtimeSessionId ?? undefined,
91
66
  });
92
67
 
93
68
  if (useJson) {
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `agentplate watch` tests. Real initialized temp project + real (mock) turns,
3
+ * driven deterministically via `--once`. Asserts which idle agents get advanced.
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
7
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import {
11
+ AGENTPLATE_DIR,
12
+ CONFIG_FILE,
13
+ DEFAULT_CONFIG,
14
+ serializeConfig,
15
+ setProjectRootOverride,
16
+ } from "../config.ts";
17
+ import { createMailClient } from "../mail/client.ts";
18
+ import { sessionsDbPath } from "../paths.ts";
19
+ import { createSessionStore } from "../sessions/store.ts";
20
+ import type { AgentSession } from "../types.ts";
21
+ import { createWatchCommand } from "./watch.ts";
22
+
23
+ let root: string;
24
+ let worktree: string;
25
+
26
+ function seedSession(over: Partial<AgentSession>): void {
27
+ const store = createSessionStore(sessionsDbPath(root));
28
+ const now = new Date().toISOString();
29
+ try {
30
+ store.upsertSession({
31
+ id: `session-${crypto.randomUUID()}`,
32
+ agentName: "builder-1",
33
+ capability: "builder",
34
+ taskId: "task-1",
35
+ runId: "run-1",
36
+ worktreePath: worktree,
37
+ branchName: "agentplate/builder-1",
38
+ state: "idle",
39
+ parentAgent: "lead-1",
40
+ depth: 1,
41
+ pid: null,
42
+ runtimeSessionId: "sess-prior",
43
+ startedAt: now,
44
+ lastActivity: now,
45
+ ...over,
46
+ });
47
+ } finally {
48
+ store.close();
49
+ }
50
+ }
51
+
52
+ function sendMailTo(agent: string): void {
53
+ const mail = createMailClient(root);
54
+ try {
55
+ mail.send({ from: "lead-1", to: agent, subject: "ping", body: "continue", type: "status" });
56
+ } finally {
57
+ mail.close();
58
+ }
59
+ }
60
+
61
+ /** Run `watch --once --json` and return the parsed summary. */
62
+ async function watchOnce(): Promise<{ driven: number; turns: Array<{ agent: string }> }> {
63
+ const original = process.stdout.write.bind(process.stdout);
64
+ let out = "";
65
+ process.stdout.write = (chunk: string | Uint8Array): boolean => {
66
+ out += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
67
+ return true;
68
+ };
69
+ try {
70
+ await createWatchCommand().parseAsync(["--once", "--json"], { from: "user" });
71
+ } finally {
72
+ process.stdout.write = original;
73
+ }
74
+ // jsonOutput wraps the payload in the standard { ok, data } envelope.
75
+ return JSON.parse(out.trim()).data;
76
+ }
77
+
78
+ beforeEach(() => {
79
+ root = mkdtempSync(join(tmpdir(), "agentplate-watch-"));
80
+ worktree = mkdtempSync(join(tmpdir(), "agentplate-watch-wt-"));
81
+ mkdirSync(join(root, AGENTPLATE_DIR), { recursive: true });
82
+ const config = structuredClone(DEFAULT_CONFIG);
83
+ config.runtime.default = "mock";
84
+ writeFileSync(join(root, AGENTPLATE_DIR, CONFIG_FILE), serializeConfig(config), "utf8");
85
+ setProjectRootOverride(root);
86
+ process.env.AGENTPLATE_MOCK_CMD = "true";
87
+ });
88
+
89
+ afterEach(() => {
90
+ setProjectRootOverride(null);
91
+ rmSync(root, { recursive: true, force: true });
92
+ rmSync(worktree, { recursive: true, force: true });
93
+ process.env.AGENTPLATE_MOCK_CMD = undefined;
94
+ });
95
+
96
+ describe("agentplate watch --once", () => {
97
+ test("drives an idle agent that has unread mail", async () => {
98
+ seedSession({ agentName: "builder-1", state: "idle" });
99
+ sendMailTo("builder-1");
100
+ const out = await watchOnce();
101
+ expect(out.driven).toBe(1);
102
+ expect(out.turns[0]?.agent).toBe("builder-1");
103
+ });
104
+
105
+ test("skips an idle agent with no unread mail", async () => {
106
+ seedSession({ agentName: "builder-1", state: "idle" });
107
+ const out = await watchOnce();
108
+ expect(out.driven).toBe(0);
109
+ });
110
+
111
+ test("never drives a terminal agent, even with mail", async () => {
112
+ seedSession({ agentName: "builder-1", state: "completed" });
113
+ sendMailTo("builder-1");
114
+ const out = await watchOnce();
115
+ expect(out.driven).toBe(0);
116
+ });
117
+
118
+ test("drives only the idle-with-mail agents in a mixed fleet", async () => {
119
+ seedSession({ agentName: "has-mail", state: "idle" });
120
+ seedSession({ agentName: "no-mail", state: "idle" });
121
+ sendMailTo("has-mail");
122
+ const out = await watchOnce();
123
+ expect(out.driven).toBe(1);
124
+ expect(out.turns.map((t) => t.agent)).toEqual(["has-mail"]);
125
+ });
126
+
127
+ test("drives multiple idle-with-mail agents in one pass (concurrent)", async () => {
128
+ for (const a of ["a1", "a2", "a3"]) {
129
+ seedSession({ agentName: a, state: "idle" });
130
+ sendMailTo(a);
131
+ }
132
+ const out = await watchOnce();
133
+ expect(out.driven).toBe(3);
134
+ expect(out.turns.map((t) => t.agent).sort()).toEqual(["a1", "a2", "a3"]);
135
+ });
136
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * `agentplate watch` — the mail pump that makes warm-start automatic.
3
+ *
4
+ * Each tick it finds **idle** agents (paused after a turn, awaiting mail) that now
5
+ * have **unread mail**, and runs each one's next turn via {@link driveAgentTurn},
6
+ * which **resumes** the runtime session (warm start). Detection is non-destructive
7
+ * (`mail.check(..., { unreadOnly })`); the turn itself injects + marks the mail
8
+ * read. Each pass drives eligible agents concurrently, capped at the fleet's
9
+ * `maxConcurrent`; a failing turn is logged and the loop continues.
10
+ *
11
+ * Modes: `--once` (single pass), `--until-idle` (loop until no active agents
12
+ * remain — the run drained), or the default (loop until Ctrl-C).
13
+ */
14
+
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { Command } from "commander";
17
+ import { driveAgentTurn } from "../agents/drive.ts";
18
+ import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
19
+ import { ValidationError } from "../errors.ts";
20
+ import { createEventStore } from "../events/store.ts";
21
+ import { jsonOutput } from "../json.ts";
22
+ import { brand, muted, printInfo, printSuccess, printWarning } from "../logging/color.ts";
23
+ import { createMailClient } from "../mail/client.ts";
24
+ import { currentRunPath, eventsDbPath, sessionsDbPath } from "../paths.ts";
25
+ import { createSessionStore } from "../sessions/store.ts";
26
+ import type { AgentSession } from "../types.ts";
27
+
28
+ interface WatchOptions {
29
+ run?: string;
30
+ interval?: string;
31
+ once?: boolean;
32
+ untilIdle?: boolean;
33
+ json?: boolean;
34
+ }
35
+
36
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
37
+
38
+ /** Current run id from `.agentplate/current-run.txt`, or undefined (= all runs). */
39
+ function readCurrentRun(root: string): string | undefined {
40
+ const path = currentRunPath(root);
41
+ if (!existsSync(path)) return undefined;
42
+ return readFileSync(path, "utf8").trim() || undefined;
43
+ }
44
+
45
+ export function createWatchCommand(): Command {
46
+ return new Command("watch")
47
+ .description("Auto-advance idle agents: run their next (resumed) turn when they have mail")
48
+ .option("--run <id>", "scope to a run (default: the current run)")
49
+ .option("--interval <ms>", "poll interval in milliseconds", "2000")
50
+ .option("--once", "run a single pass, then exit")
51
+ .option("--until-idle", "exit once no active agents remain (the run has drained)")
52
+ .option("--json", "output a JSON summary")
53
+ .action(async (opts: WatchOptions, command: Command) => {
54
+ const useJson = command.optsWithGlobals().json === true;
55
+ const root = findProjectRoot();
56
+ if (!isInitialized(root)) {
57
+ throw new ValidationError("Not initialized. Run `agentplate setup` first.");
58
+ }
59
+ const config = loadConfig(root);
60
+ const interval = Number(opts.interval ?? "2000");
61
+ if (!Number.isFinite(interval) || interval < 0) {
62
+ throw new ValidationError("--interval must be a non-negative number of milliseconds.");
63
+ }
64
+ const runId = opts.run ?? readCurrentRun(root);
65
+
66
+ const store = createSessionStore(sessionsDbPath(root));
67
+ const mail = createMailClient(root);
68
+ const events = createEventStore(eventsDbPath(root));
69
+
70
+ let stop = false;
71
+ const onSigint = (): void => {
72
+ stop = true;
73
+ };
74
+ process.once("SIGINT", onSigint);
75
+
76
+ let totalDriven = 0;
77
+ const drivenLog: Array<{ agent: string; state: string }> = [];
78
+ // Cap simultaneous turns at the fleet's maxConcurrent (>= 1).
79
+ const concurrency = Math.max(1, config.agents.maxConcurrent);
80
+
81
+ /** Drive one agent's next turn, recording the outcome. */
82
+ const driveOne = async (session: AgentSession): Promise<void> => {
83
+ try {
84
+ const { finalState } = await driveAgentTurn({
85
+ root,
86
+ config,
87
+ session,
88
+ store,
89
+ events,
90
+ mail,
91
+ });
92
+ totalDriven++;
93
+ drivenLog.push({ agent: session.agentName, state: finalState });
94
+ if (!useJson) printInfo(` ${brand(session.agentName)} → ${finalState}`);
95
+ } catch (error) {
96
+ const message = error instanceof Error ? error.message : String(error);
97
+ if (!useJson) printWarning(` ${session.agentName}: turn failed — ${message}`);
98
+ }
99
+ };
100
+
101
+ /**
102
+ * One pass: drive every idle agent that has unread mail, up to `concurrency`
103
+ * turns at a time. Returns the number driven.
104
+ */
105
+ const pass = async (): Promise<number> => {
106
+ const eligible = store
107
+ .listSessions({ runId, state: "idle" })
108
+ .filter((s) => mail.check(s.agentName, { unreadOnly: true }).length > 0);
109
+ let next = 0;
110
+ const worker = async (): Promise<void> => {
111
+ while (!stop) {
112
+ const session = eligible[next++];
113
+ if (!session) break;
114
+ await driveOne(session);
115
+ }
116
+ };
117
+ const lanes = Math.min(concurrency, eligible.length);
118
+ await Promise.all(Array.from({ length: lanes }, () => worker()));
119
+ return eligible.length;
120
+ };
121
+
122
+ try {
123
+ if (opts.once) {
124
+ await pass();
125
+ } else {
126
+ if (!useJson) {
127
+ printInfo(
128
+ `Watching${runId ? ` run ${muted(runId)}` : " all runs"} (every ${interval}ms; Ctrl-C to stop)…`,
129
+ );
130
+ }
131
+ while (!stop) {
132
+ await pass();
133
+ if (opts.untilIdle && store.countActive(runId) === 0) break;
134
+ if (stop) break;
135
+ await sleep(interval);
136
+ }
137
+ }
138
+ } finally {
139
+ process.removeListener("SIGINT", onSigint);
140
+ events.close();
141
+ mail.close();
142
+ store.close();
143
+ }
144
+
145
+ if (useJson) {
146
+ jsonOutput({ runId: runId ?? null, driven: totalDriven, turns: drivenLog });
147
+ return;
148
+ }
149
+ printSuccess(`Watch finished — drove ${totalDriven} turn(s).`);
150
+ });
151
+ }
@@ -92,6 +92,20 @@ describe("validateConfig", () => {
92
92
  cfg.merge.autoMerge = "always";
93
93
  expect(() => validateConfig(cfg)).toThrow();
94
94
  });
95
+
96
+ test("turn timeout + skip defaults are off", () => {
97
+ expect(DEFAULT_CONFIG.agents.turnTimeoutMinutes).toBe(0);
98
+ expect(DEFAULT_CONFIG.agents.skipScout).toBe(false);
99
+ expect(DEFAULT_CONFIG.agents.skipReview).toBe(false);
100
+ expect(DEFAULT_CONFIG.agents.skipGates).toBe(false);
101
+ expect(DEFAULT_CONFIG.agents.skipSkills).toBe(false);
102
+ });
103
+
104
+ test("rejects a negative turnTimeoutMinutes", () => {
105
+ const cfg = structuredClone(DEFAULT_CONFIG);
106
+ cfg.agents.turnTimeoutMinutes = -1;
107
+ expect(() => validateConfig(cfg)).toThrow();
108
+ });
95
109
  });
96
110
 
97
111
  describe("isInitialized / serializeConfig", () => {
package/src/config.ts CHANGED
@@ -43,6 +43,11 @@ export const DEFAULT_CONFIG: AgentplateConfig = {
43
43
  maxDepth: 2,
44
44
  maxAgentsPerLead: 5,
45
45
  idleTimeoutMinutes: 10,
46
+ turnTimeoutMinutes: 0,
47
+ skipScout: false,
48
+ skipReview: false,
49
+ skipGates: false,
50
+ skipSkills: false,
46
51
  },
47
52
  merge: {
48
53
  aiResolveEnabled: true,
@@ -176,6 +181,11 @@ export function validateConfig(config: AgentplateConfig): void {
176
181
  "config.agents.idleTimeoutMinutes must be >= 0 (0 disables idle reaping)",
177
182
  );
178
183
  }
184
+ if (config.agents.turnTimeoutMinutes < 0) {
185
+ throw new ConfigError(
186
+ "config.agents.turnTimeoutMinutes must be >= 0 (0 disables the per-turn cap)",
187
+ );
188
+ }
179
189
  const autoMergeModes: AutoMergeMode[] = ["off", "on-gates-pass", "on-complete"];
180
190
  if (!autoMergeModes.includes(config.merge.autoMerge)) {
181
191
  throw new ConfigError(`config.merge.autoMerge must be one of: ${autoMergeModes.join(", ")}`);
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ import { createStatusCommand } from "./commands/status.ts";
35
35
  import { createStopCommand } from "./commands/stop.ts";
36
36
  import { createTuiCommand } from "./commands/tui.ts";
37
37
  import { createTurnCommand } from "./commands/turn.ts";
38
+ import { createWatchCommand } from "./commands/watch.ts";
38
39
  import { createWorktreeCommand } from "./commands/worktree.ts";
39
40
  import { setProjectRootOverride } from "./config.ts";
40
41
  import { isAgentplateError } from "./errors.ts";
@@ -98,6 +99,7 @@ function buildProgram(): Command {
98
99
  program.addCommand(createCoordinatorCommand());
99
100
  program.addCommand(createSlingCommand());
100
101
  program.addCommand(createTurnCommand());
102
+ program.addCommand(createWatchCommand());
101
103
  program.addCommand(createSpecCommand());
102
104
  program.addCommand(createStatusCommand());
103
105
  program.addCommand(createMailCommand());
@@ -1,15 +1,29 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { ValidationError } from "../errors.ts";
3
- import type { ResolvedModel } from "../types.ts";
3
+ import type { ResolvedModel, RuntimeConfig } from "../types.ts";
4
4
  import { ClaudeRuntime } from "./claude.ts";
5
5
  import { CodexRuntime } from "./codex.ts";
6
6
  import { CursorRuntime } from "./cursor.ts";
7
7
  import { GeminiRuntime } from "./gemini.ts";
8
8
  import { MockRuntime } from "./mock.ts";
9
9
  import { OpenCodeRuntime, opencodeModel } from "./opencode.ts";
10
- import { getRuntime, getRuntimeNames } from "./registry.ts";
10
+ import { getRuntime, getRuntimeNames, runtimeNameForCapability } from "./registry.ts";
11
11
  import type { AgentEvent, DirectSpawnOpts } from "./types.ts";
12
12
 
13
+ describe("runtimeNameForCapability", () => {
14
+ const rt: RuntimeConfig = { default: "claude", capabilities: { scout: "codex" } };
15
+ test("explicit override wins over everything", () => {
16
+ expect(runtimeNameForCapability(rt, "scout", "gemini")).toBe("gemini");
17
+ });
18
+ test("per-capability override applies when set", () => {
19
+ expect(runtimeNameForCapability(rt, "scout")).toBe("codex");
20
+ });
21
+ test("falls back to the default runtime", () => {
22
+ expect(runtimeNameForCapability(rt, "builder")).toBe("claude");
23
+ expect(runtimeNameForCapability({ default: "opencode" }, "scout")).toBe("opencode");
24
+ });
25
+ });
26
+
13
27
  // Minimal DirectSpawnOpts builder for argv-shape assertions. `cwd` and
14
28
  // `instructionPath` are required by the type but irrelevant to argv here.
15
29
  function spawnOpts(overrides: Partial<DirectSpawnOpts> = {}): DirectSpawnOpts {
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { ValidationError } from "../errors.ts";
11
+ import type { Capability, RuntimeConfig } from "../types.ts";
11
12
  import { ClaudeRuntime } from "./claude.ts";
12
13
  import { CodexRuntime } from "./codex.ts";
13
14
  import { CursorRuntime } from "./cursor.ts";
@@ -54,6 +55,18 @@ export function getRuntime(name?: string, fallback?: string): AgentRuntime {
54
55
  return factory();
55
56
  }
56
57
 
58
+ /**
59
+ * Resolve which runtime adapter drives a given capability: an explicit `override`
60
+ * wins, then a per-capability entry in `runtime.capabilities`, then the default.
61
+ */
62
+ export function runtimeNameForCapability(
63
+ runtime: RuntimeConfig,
64
+ capability: Capability,
65
+ override?: string,
66
+ ): string {
67
+ return override ?? runtime.capabilities?.[capability] ?? runtime.default;
68
+ }
69
+
57
70
  /**
58
71
  * Names of all registered runtimes, in registration order. Used to validate a
59
72
  * user-supplied runtime name and to render the choices in help / errors.
package/src/types.ts CHANGED
@@ -153,6 +153,20 @@ export interface AgentsConfig {
153
153
  * `0` disables idle reaping. Default 10.
154
154
  */
155
155
  idleTimeoutMinutes: number;
156
+ /**
157
+ * Hard wall-clock cap on a SINGLE turn, in minutes. Unlike idle reaping (which
158
+ * needs inactivity), this kills a turn that keeps streaming but never finishes.
159
+ * `0` disables the cap. Default 0.
160
+ */
161
+ turnTimeoutMinutes: number;
162
+ /** Default: leads skip the scout step (go straight to builders). */
163
+ skipScout: boolean;
164
+ /** Default: leads skip the reviewer step before integrating. */
165
+ skipReview: boolean;
166
+ /** Skip the post-turn quality-gate run (speed; disables `on-gates-pass` merge). */
167
+ skipGates: boolean;
168
+ /** Skip the post-turn skill-distillation loop. */
169
+ skipSkills: boolean;
156
170
  }
157
171
 
158
172
  /**
package/src/version.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * Kept as a plain constant (not read from package.json at runtime) so the value
5
5
  * is available without filesystem access and survives bundling.
6
6
  */
7
- export const VERSION = "1.2.0";
7
+ export const VERSION = "1.3.0";
@@ -321,6 +321,71 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
321
321
  );
322
322
  }
323
323
 
324
+ // 6b. Advanced limits — gated so the common path stays short.
325
+ const agents = { ...currentConfig.agents };
326
+ const tuneAdvanced = ensure(
327
+ await p.confirm({
328
+ message: "Tune advanced limits (concurrency, timeouts, skip steps)?",
329
+ initialValue: false,
330
+ }),
331
+ );
332
+ if (tuneAdvanced) {
333
+ const posInt = (v: string | undefined): string | undefined =>
334
+ v && Number.isInteger(Number(v)) && Number(v) >= 0 ? undefined : "Enter a whole number ≥ 0";
335
+ agents.maxConcurrent = Number(
336
+ ensure(
337
+ await p.text({
338
+ message: "Max agents running at once",
339
+ initialValue: String(agents.maxConcurrent),
340
+ validate: (v) => (v && Number(v) >= 1 ? undefined : "Enter a number ≥ 1"),
341
+ }),
342
+ ),
343
+ );
344
+ agents.maxAgentsPerLead = Number(
345
+ ensure(
346
+ await p.text({
347
+ message: "Max workers per lead",
348
+ initialValue: String(agents.maxAgentsPerLead),
349
+ validate: (v) => (v && Number(v) >= 1 ? undefined : "Enter a number ≥ 1"),
350
+ }),
351
+ ),
352
+ );
353
+ agents.turnTimeoutMinutes = Number(
354
+ ensure(
355
+ await p.text({
356
+ message: "Per-turn timeout in minutes (0 = no cap)",
357
+ initialValue: String(agents.turnTimeoutMinutes),
358
+ validate: posInt,
359
+ }),
360
+ ),
361
+ );
362
+ const skips = ensure(
363
+ await p.multiselect({
364
+ message: "Default speed shortcuts (leave empty for none)",
365
+ options: [
366
+ {
367
+ value: "skipScout",
368
+ label: "Skip scout step",
369
+ hint: "leads dispatch builders directly",
370
+ },
371
+ { value: "skipReview", label: "Skip review step", hint: "no reviewer before integrate" },
372
+ {
373
+ value: "skipGates",
374
+ label: "Skip quality gates",
375
+ hint: "faster; disables on-gates-pass merge",
376
+ },
377
+ { value: "skipSkills", label: "Skip skill distillation" },
378
+ ],
379
+ initialValues: [],
380
+ required: false,
381
+ }),
382
+ ) as string[];
383
+ agents.skipScout = skips.includes("skipScout");
384
+ agents.skipReview = skips.includes("skipReview");
385
+ agents.skipGates = skips.includes("skipGates");
386
+ agents.skipSkills = skips.includes("skipSkills");
387
+ }
388
+
324
389
  // 7. Summary -----------------------------------------------------------
325
390
  const previewProvider = buildProviderConfig(spec, model, authMode, baseUrl);
326
391
  const authSummary: Record<AuthMode, string> = {
@@ -356,6 +421,7 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
356
421
  runtime,
357
422
  });
358
423
  config.merge = { ...config.merge, autoMerge };
424
+ config.agents = agents;
359
425
  if (qualityGates.length) config.project = { ...config.project, qualityGates };
360
426
  if (modelsByCapability) {
361
427
  const pc = config.providers[providerId];