@agentplate/cli 1.2.0 → 1.4.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 +43 -0
- package/package.json +1 -1
- package/src/agents/drive.ts +76 -6
- package/src/agents/turn-runner.test.ts +67 -0
- package/src/agents/turn-runner.ts +18 -1
- package/src/commands/reap.ts +29 -6
- package/src/commands/sling.ts +14 -3
- package/src/commands/stop.ts +43 -6
- package/src/commands/turn.ts +4 -29
- package/src/commands/watch.test.ts +136 -0
- package/src/commands/watch.ts +151 -0
- package/src/config.test.ts +14 -0
- package/src/config.ts +11 -0
- package/src/events/store.test.ts +22 -0
- package/src/events/store.ts +24 -1
- package/src/index.ts +2 -0
- package/src/merge/queue.test.ts +15 -0
- package/src/merge/queue.ts +20 -0
- package/src/runtimes/registry.test.ts +16 -2
- package/src/runtimes/registry.ts +13 -0
- package/src/serve/server.ts +7 -5
- package/src/sessions/purge.test.ts +159 -0
- package/src/sessions/purge.ts +138 -0
- package/src/sessions/reaper.test.ts +84 -2
- package/src/sessions/reaper.ts +73 -32
- package/src/sessions/store.test.ts +18 -0
- package/src/sessions/store.ts +12 -0
- package/src/types.ts +22 -0
- package/src/version.ts +1 -1
- package/src/wizard/setup.ts +74 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,49 @@ 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.4.0] — 2026-06-02
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Full purge of reaped agents** (`agentplate reap --purge`, `agentplate stop
|
|
12
|
+
<agent> --purge`) — beyond stopping an agent and removing its worktree, purge
|
|
13
|
+
erases every trace it left behind: mail, events, queued merges, the on-disk
|
|
14
|
+
`.agentplate/agents/<name>/` state dir, the task spec (only once no sibling
|
|
15
|
+
session still references it), and the session row itself. A `PurgeReport` records
|
|
16
|
+
what was removed.
|
|
17
|
+
- **`agents.purgeOnReap`** (default `false`) — when set, the `agentplate serve`
|
|
18
|
+
idle-reaper loop purges idle agents automatically (not just marks them stopped).
|
|
19
|
+
Surfaced as a gated toggle in `agentplate setup`.
|
|
20
|
+
- **Store deletion APIs** backing the purge: `SessionStore.deleteSession`,
|
|
21
|
+
`EventStore.deleteByAgent`, and `MergeQueue.deleteByAgent`.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- Plain `reap` / `serve` behavior is unchanged — reaping still keeps records for
|
|
26
|
+
history by default. Full erasure is strictly opt-in via `--purge` / `purgeOnReap`.
|
|
27
|
+
|
|
28
|
+
## [1.3.0] — 2026-06-02
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **`agentplate watch`** — the mail pump that makes warm-start automatic: it
|
|
33
|
+
advances every **idle** agent with unread mail to its next (resumed) turn,
|
|
34
|
+
driving eligible agents concurrently up to `agents.maxConcurrent`. Modes:
|
|
35
|
+
`--once`, `--until-idle`, or loop until Ctrl-C.
|
|
36
|
+
- **Hard per-turn timeout** (`agents.turnTimeoutMinutes`, 0 = off) — kills a turn
|
|
37
|
+
that runs past the cap even while still streaming (idle reaping only catches
|
|
38
|
+
inactivity).
|
|
39
|
+
- **Per-capability runtime** — `runtime.capabilities[capability]` now selects the
|
|
40
|
+
runtime adapter per role (previously defined but unused).
|
|
41
|
+
- **Speed shortcuts** — `agents.skipScout` / `skipReview` (surfaced as lead overlay
|
|
42
|
+
constraints) and `agents.skipGates` / `skipSkills` (honored on the turn path).
|
|
43
|
+
- **Wizard** — a gated "advanced limits" step (concurrency, turn-timeout, skips).
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- The turn path is shared via `driveTurn` / `driveAgentTurn`, used by `sling`
|
|
48
|
+
(turn 1), `agentplate turn` (single follow-up), and `agentplate watch`.
|
|
49
|
+
|
|
7
50
|
## [1.2.0] — 2026-06-02
|
|
8
51
|
|
|
9
52
|
### Added
|
package/package.json
CHANGED
package/src/agents/drive.ts
CHANGED
|
@@ -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
|
|
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" && (
|
|
173
|
+
if (finalState === "completed" && (wantGates || runSkills)) {
|
|
155
174
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
94
|
+
if (timer) clearTimeout(timer);
|
|
95
|
+
return { exitCode, runtimeSessionId, stderr, timedOut };
|
|
79
96
|
}
|
package/src/commands/reap.ts
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* live process, and removes its worktree + branch. The coordinator is never
|
|
7
7
|
* reaped. Run it manually or on a cron; `agentplate serve` also reaps on its own
|
|
8
8
|
* loop. Use `--dry-run` to preview and `--keep-worktrees` to leave worktrees.
|
|
9
|
+
*
|
|
10
|
+
* `--purge` goes further than a normal reap: it erases every trace of each reaped
|
|
11
|
+
* agent — mail, events, queued merges, the on-disk `.agentplate/agents/<name>/`
|
|
12
|
+
* state dir, and the session row itself — so the workspace is fully cleared. The
|
|
13
|
+
* plain reap keeps those records for history; purge is the opt-in clean slate.
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
16
|
import { Command } from "commander";
|
|
@@ -22,11 +27,21 @@ export function createReapCommand(): Command {
|
|
|
22
27
|
.description("Terminate agents idle past the timeout (stop + kill + remove worktree)")
|
|
23
28
|
.option("--minutes <n>", "idle timeout in minutes (default: config.agents.idleTimeoutMinutes)")
|
|
24
29
|
.option("--keep-worktrees", "mark stopped + kill process but keep worktrees/branches")
|
|
30
|
+
.option(
|
|
31
|
+
"--purge",
|
|
32
|
+
"fully erase each reaped agent (mail, events, merges, state dir, session row)",
|
|
33
|
+
)
|
|
25
34
|
.option("--dry-run", "list which agents would be reaped without changing anything")
|
|
26
35
|
.option("--json", "output JSON")
|
|
27
36
|
.action(
|
|
28
37
|
async (
|
|
29
|
-
opts: {
|
|
38
|
+
opts: {
|
|
39
|
+
minutes?: string;
|
|
40
|
+
keepWorktrees?: boolean;
|
|
41
|
+
purge?: boolean;
|
|
42
|
+
dryRun?: boolean;
|
|
43
|
+
json?: boolean;
|
|
44
|
+
},
|
|
30
45
|
command: Command,
|
|
31
46
|
) => {
|
|
32
47
|
const useJson = command.optsWithGlobals().json === true;
|
|
@@ -51,11 +66,12 @@ export function createReapCommand(): Command {
|
|
|
51
66
|
now: Date.now(),
|
|
52
67
|
}).map((s) => ({ agent: s.agentName, capability: s.capability, state: s.state }));
|
|
53
68
|
if (useJson) {
|
|
54
|
-
jsonOutput({ dryRun: true, minutes, candidates });
|
|
69
|
+
jsonOutput({ dryRun: true, minutes, purge: opts.purge === true, candidates });
|
|
55
70
|
} else if (candidates.length === 0) {
|
|
56
71
|
printInfo(`No agents idle longer than ${minutes}m.`);
|
|
57
72
|
} else {
|
|
58
|
-
|
|
73
|
+
const how = opts.purge ? "reap + purge" : "reap";
|
|
74
|
+
printInfo(`Would ${how} ${candidates.length} agent(s) idle >${minutes}m:`);
|
|
59
75
|
for (const c of candidates) {
|
|
60
76
|
process.stdout.write(` ${c.agent} ${muted(`(${c.capability}, ${c.state})`)}\n`);
|
|
61
77
|
}
|
|
@@ -66,17 +82,24 @@ export function createReapCommand(): Command {
|
|
|
66
82
|
const reaped = await reapIdleSessions(store, root, {
|
|
67
83
|
idleMs,
|
|
68
84
|
removeWorktrees: opts.keepWorktrees !== true,
|
|
85
|
+
purge: opts.purge === true,
|
|
69
86
|
});
|
|
70
87
|
|
|
71
88
|
if (useJson) {
|
|
72
|
-
jsonOutput({ minutes, reapedCount: reaped.length, reaped });
|
|
89
|
+
jsonOutput({ minutes, purge: opts.purge === true, reapedCount: reaped.length, reaped });
|
|
73
90
|
} else if (reaped.length === 0) {
|
|
74
91
|
printInfo(`No agents idle longer than ${minutes}m.`);
|
|
75
92
|
} else {
|
|
76
|
-
|
|
93
|
+
const verb = opts.purge ? "Reaped + purged" : "Reaped";
|
|
94
|
+
printSuccess(`${verb} ${reaped.length} idle agent(s) (>${minutes}m):`);
|
|
77
95
|
for (const r of reaped) {
|
|
78
96
|
const wt = r.worktreeRemoved ? "worktree removed" : "worktree kept";
|
|
79
|
-
|
|
97
|
+
const extra = r.purged
|
|
98
|
+
? `, purged ${r.purged.mailDeleted} mail/${r.purged.eventsDeleted} events/${r.purged.mergeDeleted} merges`
|
|
99
|
+
: "";
|
|
100
|
+
process.stdout.write(
|
|
101
|
+
` ${r.agentName} ${muted(`(${r.capability}, ${wt}${extra})`)}\n`,
|
|
102
|
+
);
|
|
80
103
|
}
|
|
81
104
|
}
|
|
82
105
|
} finally {
|
package/src/commands/sling.ts
CHANGED
|
@@ -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(
|
|
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
|
};
|
package/src/commands/stop.ts
CHANGED
|
@@ -7,22 +7,30 @@
|
|
|
7
7
|
import { Command } from "commander";
|
|
8
8
|
import { findProjectRoot, isInitialized } from "../config.ts";
|
|
9
9
|
import { NotFoundError, ValidationError } from "../errors.ts";
|
|
10
|
+
import { createEventStore } from "../events/store.ts";
|
|
10
11
|
import { jsonOutput } from "../json.ts";
|
|
11
12
|
import { printSuccess, printWarning } from "../logging/color.ts";
|
|
12
|
-
import {
|
|
13
|
+
import { createMailStore } from "../mail/store.ts";
|
|
14
|
+
import { createMergeQueue } from "../merge/queue.ts";
|
|
15
|
+
import { eventsDbPath, mailDbPath, mergeDbPath, sessionsDbPath } from "../paths.ts";
|
|
16
|
+
import { type PurgeReport, purgeAgentData } from "../sessions/purge.ts";
|
|
13
17
|
import { createSessionStore } from "../sessions/store.ts";
|
|
14
|
-
import { removeWorktree } from "../worktree/manager.ts";
|
|
18
|
+
import { deleteBranch, removeWorktree } from "../worktree/manager.ts";
|
|
15
19
|
|
|
16
20
|
export function createStopCommand(): Command {
|
|
17
21
|
return new Command("stop")
|
|
18
22
|
.description("Terminate an agent session")
|
|
19
23
|
.argument("<agent>", "agent name")
|
|
20
24
|
.option("--clean-worktree", "also remove the agent's worktree")
|
|
25
|
+
.option(
|
|
26
|
+
"--purge",
|
|
27
|
+
"fully erase the agent (worktree + mail, events, merges, state dir, session row)",
|
|
28
|
+
)
|
|
21
29
|
.option("--json", "output JSON")
|
|
22
30
|
.action(
|
|
23
31
|
async (
|
|
24
32
|
agent: string,
|
|
25
|
-
opts: { cleanWorktree?: boolean; json?: boolean },
|
|
33
|
+
opts: { cleanWorktree?: boolean; purge?: boolean; json?: boolean },
|
|
26
34
|
command: Command,
|
|
27
35
|
) => {
|
|
28
36
|
const useJson = command.optsWithGlobals().json === true;
|
|
@@ -30,6 +38,8 @@ export function createStopCommand(): Command {
|
|
|
30
38
|
if (!isInitialized(root)) {
|
|
31
39
|
throw new ValidationError("Not initialized. Run `agentplate setup` first.");
|
|
32
40
|
}
|
|
41
|
+
// --purge implies removing the worktree: a full wipe leaves no worktree.
|
|
42
|
+
const cleanWorktree = opts.cleanWorktree || opts.purge === true;
|
|
33
43
|
const store = createSessionStore(sessionsDbPath(root));
|
|
34
44
|
try {
|
|
35
45
|
const session = store.getSessionByAgent(agent);
|
|
@@ -37,17 +47,44 @@ export function createStopCommand(): Command {
|
|
|
37
47
|
store.updateSessionState(session.id, "stopped");
|
|
38
48
|
|
|
39
49
|
let worktreeRemoved = false;
|
|
40
|
-
if (
|
|
50
|
+
if (cleanWorktree) {
|
|
41
51
|
try {
|
|
42
52
|
await removeWorktree(root, session.worktreePath, { force: true });
|
|
43
53
|
worktreeRemoved = true;
|
|
54
|
+
// Best-effort branch cleanup (may be merged/shared — leave it then).
|
|
55
|
+
try {
|
|
56
|
+
await deleteBranch(root, session.branchName);
|
|
57
|
+
} catch {
|
|
58
|
+
// Branch kept; not fatal.
|
|
59
|
+
}
|
|
44
60
|
} catch (error) {
|
|
45
61
|
printWarning(`Could not remove worktree: ${(error as Error).message}`);
|
|
46
62
|
}
|
|
47
63
|
}
|
|
48
64
|
|
|
49
|
-
|
|
50
|
-
|
|
65
|
+
let purged: PurgeReport | null = null;
|
|
66
|
+
if (opts.purge) {
|
|
67
|
+
const events = createEventStore(eventsDbPath(root));
|
|
68
|
+
const merge = createMergeQueue(mergeDbPath(root));
|
|
69
|
+
const mail = createMailStore(mailDbPath(root));
|
|
70
|
+
try {
|
|
71
|
+
purged = purgeAgentData(root, session, { sessions: store, events, merge, mail });
|
|
72
|
+
} finally {
|
|
73
|
+
events.close();
|
|
74
|
+
merge.close();
|
|
75
|
+
mail.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (useJson) {
|
|
80
|
+
jsonOutput({ agent, stopped: true, worktreeRemoved, purged });
|
|
81
|
+
} else if (purged) {
|
|
82
|
+
printSuccess(
|
|
83
|
+
`Purged ${agent} (worktree removed, ${purged.mailDeleted} mail/${purged.eventsDeleted} events/${purged.mergeDeleted} merges erased)`,
|
|
84
|
+
);
|
|
85
|
+
} else {
|
|
86
|
+
printSuccess(`Stopped ${agent}${worktreeRemoved ? " (worktree removed)" : ""}`);
|
|
87
|
+
}
|
|
51
88
|
} finally {
|
|
52
89
|
store.close();
|
|
53
90
|
}
|
package/src/commands/turn.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
|
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
|
-
|
|
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) {
|