@agentplate/cli 1.0.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 +54 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/agents/architect.md +108 -0
- package/agents/builder.md +97 -0
- package/agents/coordinator.md +113 -0
- package/agents/deployer.md +117 -0
- package/agents/devops.md +114 -0
- package/agents/lead.md +107 -0
- package/agents/merger.md +103 -0
- package/agents/reviewer.md +90 -0
- package/agents/scout.md +95 -0
- package/agents/verifier.md +106 -0
- package/package.json +64 -0
- package/src/agents/guard-rules.ts +55 -0
- package/src/agents/identity.test.ts +161 -0
- package/src/agents/identity.ts +229 -0
- package/src/agents/manifest.test.ts +260 -0
- package/src/agents/manifest.ts +286 -0
- package/src/agents/overlay.test.ts +190 -0
- package/src/agents/overlay.ts +212 -0
- package/src/agents/system-prompt.test.ts +53 -0
- package/src/agents/system-prompt.ts +95 -0
- package/src/agents/turn-runner.ts +79 -0
- package/src/commands/coordinator.test.ts +75 -0
- package/src/commands/coordinator.ts +259 -0
- package/src/commands/deploy.test.ts +504 -0
- package/src/commands/deploy.ts +874 -0
- package/src/commands/doctor.test.ts +106 -0
- package/src/commands/doctor.ts +208 -0
- package/src/commands/init.ts +71 -0
- package/src/commands/log.ts +51 -0
- package/src/commands/mail.ts +197 -0
- package/src/commands/merge.ts +127 -0
- package/src/commands/model.ts +58 -0
- package/src/commands/prime.ts +61 -0
- package/src/commands/reap.ts +87 -0
- package/src/commands/serve.ts +61 -0
- package/src/commands/setup.ts +48 -0
- package/src/commands/ship.test.ts +106 -0
- package/src/commands/ship.ts +202 -0
- package/src/commands/skill.test.ts +458 -0
- package/src/commands/skill.ts +730 -0
- package/src/commands/sling.ts +365 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/stop.ts +56 -0
- package/src/commands/tui.ts +199 -0
- package/src/commands/worktree.ts +77 -0
- package/src/config.test.ts +92 -0
- package/src/config.ts +202 -0
- package/src/db/sqlite.test.ts +77 -0
- package/src/db/sqlite.ts +102 -0
- package/src/deploy/audit.test.ts +233 -0
- package/src/deploy/audit.ts +245 -0
- package/src/deploy/context.test.ts +243 -0
- package/src/deploy/context.ts +72 -0
- package/src/deploy/registry.test.ts +101 -0
- package/src/deploy/registry.ts +86 -0
- package/src/deploy/secrets.test.ts +129 -0
- package/src/deploy/secrets.ts +69 -0
- package/src/deploy/targets/docker-gha.test.ts +323 -0
- package/src/deploy/targets/docker-gha.ts +841 -0
- package/src/deploy/types.ts +153 -0
- package/src/errors.test.ts +42 -0
- package/src/errors.ts +69 -0
- package/src/events/store.test.ts +183 -0
- package/src/events/store.ts +201 -0
- package/src/index.ts +137 -0
- package/src/insights/quality-gates.ts +73 -0
- package/src/json.test.ts +28 -0
- package/src/json.ts +50 -0
- package/src/logging/color.ts +62 -0
- package/src/logging/logger.ts +60 -0
- package/src/logging/sanitizer.test.ts +36 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/client.test.ts +192 -0
- package/src/mail/client.ts +188 -0
- package/src/mail/store.test.ts +279 -0
- package/src/mail/store.ts +311 -0
- package/src/merge/lock.test.ts +88 -0
- package/src/merge/lock.ts +84 -0
- package/src/merge/queue.test.ts +136 -0
- package/src/merge/queue.ts +177 -0
- package/src/merge/resolver.test.ts +219 -0
- package/src/merge/resolver.ts +274 -0
- package/src/paths.ts +36 -0
- package/src/providers/apply.test.ts +90 -0
- package/src/providers/apply.ts +66 -0
- package/src/providers/registry.test.ts +74 -0
- package/src/providers/registry.ts +254 -0
- package/src/runtimes/claude.ts +313 -0
- package/src/runtimes/codex.ts +280 -0
- package/src/runtimes/cursor.ts +247 -0
- package/src/runtimes/gemini.ts +173 -0
- package/src/runtimes/mock.ts +71 -0
- package/src/runtimes/opencode.ts +259 -0
- package/src/runtimes/registry.test.ts +924 -0
- package/src/runtimes/registry.ts +63 -0
- package/src/runtimes/resolve.ts +45 -0
- package/src/runtimes/types.ts +97 -0
- package/src/scaffold.ts +68 -0
- package/src/secrets.test.ts +51 -0
- package/src/secrets.ts +78 -0
- package/src/serve/api.ts +667 -0
- package/src/serve/server.test.ts +433 -0
- package/src/serve/server.ts +271 -0
- package/src/serve/system.ts +90 -0
- package/src/serve/weather.ts +140 -0
- package/src/sessions/reaper.test.ts +162 -0
- package/src/sessions/reaper.ts +149 -0
- package/src/sessions/store.test.ts +351 -0
- package/src/sessions/store.ts +350 -0
- package/src/skills/distiller.test.ts +498 -0
- package/src/skills/distiller.ts +426 -0
- package/src/skills/feedback.test.ts +300 -0
- package/src/skills/feedback.ts +168 -0
- package/src/skills/lifecycle.ts +169 -0
- package/src/skills/retrieval.test.ts +421 -0
- package/src/skills/retrieval.ts +365 -0
- package/src/skills/safety.test.ts +335 -0
- package/src/skills/safety.ts +216 -0
- package/src/skills/store.test.ts +425 -0
- package/src/skills/store.ts +684 -0
- package/src/skills/types.ts +107 -0
- package/src/types.ts +442 -0
- package/src/utils/detect.test.ts +35 -0
- package/src/utils/detect.ts +82 -0
- package/src/version.test.ts +19 -0
- package/src/version.ts +7 -0
- package/src/wizard/setup.ts +254 -0
- package/src/worktree/manager.test.ts +181 -0
- package/src/worktree/manager.ts +229 -0
- package/templates/overlay.md.tmpl +102 -0
- package/ui/dist/assets/index-C7rXIMER.css +1 -0
- package/ui/dist/assets/index-W4kbr4by.js +4526 -0
- package/ui/dist/favicon.svg +21 -0
- package/ui/dist/index.html +16 -0
- package/ui/dist/logo-clay.svg +21 -0
- package/ui/dist/logo.svg +18 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentplate sling <task-id>` — spawn a worker agent.
|
|
3
|
+
*
|
|
4
|
+
* The end-to-end spawn path: resolve run + manifest → create an isolated git
|
|
5
|
+
* worktree → write the per-task overlay (base definition + assignment) → create
|
|
6
|
+
* the agent identity + session row → dispatch the task over mail → run the first
|
|
7
|
+
* headless turn → observe the agent's terminal mail to transition the session.
|
|
8
|
+
*
|
|
9
|
+
* Headless spawn-per-turn: this runs the FIRST turn. Subsequent turns are driven
|
|
10
|
+
* by new mail (a later refinement); the basic core proves the single-turn loop.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { Command } from "commander";
|
|
15
|
+
import { createIdentity, updateIdentity } from "../agents/identity.ts";
|
|
16
|
+
import { buildDefaultManifest, getDefinition, loadManifest } from "../agents/manifest.ts";
|
|
17
|
+
import { writeOverlay } from "../agents/overlay.ts";
|
|
18
|
+
import { runTurn } from "../agents/turn-runner.ts";
|
|
19
|
+
import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
|
|
20
|
+
import { ValidationError } from "../errors.ts";
|
|
21
|
+
import { createEventStore } from "../events/store.ts";
|
|
22
|
+
import { runQualityGates } from "../insights/quality-gates.ts";
|
|
23
|
+
import { jsonOutput } from "../json.ts";
|
|
24
|
+
import { brand, muted, printHint, printInfo, printSuccess } from "../logging/color.ts";
|
|
25
|
+
import { createMailClient } from "../mail/client.ts";
|
|
26
|
+
import { createMailStore } from "../mail/store.ts";
|
|
27
|
+
import {
|
|
28
|
+
currentRunPath,
|
|
29
|
+
eventsDbPath,
|
|
30
|
+
mailDbPath,
|
|
31
|
+
manifestFilePath,
|
|
32
|
+
packageAgentDefPath,
|
|
33
|
+
sessionsDbPath,
|
|
34
|
+
} from "../paths.ts";
|
|
35
|
+
import { getRuntime } from "../runtimes/registry.ts";
|
|
36
|
+
import { resolveModel } from "../runtimes/resolve.ts";
|
|
37
|
+
import { createSessionStore } from "../sessions/store.ts";
|
|
38
|
+
import { retrieveSkillsForSpawn, runSkillFeedbackAndDistill } from "../skills/lifecycle.ts";
|
|
39
|
+
import type { AgentManifest, AgentSession, Capability, OverlayConfig } from "../types.ts";
|
|
40
|
+
import { SUPPORTED_CAPABILITIES } from "../types.ts";
|
|
41
|
+
import { createWorktree } from "../worktree/manager.ts";
|
|
42
|
+
|
|
43
|
+
export interface SlingOptions {
|
|
44
|
+
capability?: string;
|
|
45
|
+
name?: string;
|
|
46
|
+
spec?: string;
|
|
47
|
+
files?: string;
|
|
48
|
+
parent?: string;
|
|
49
|
+
depth?: string;
|
|
50
|
+
run?: string;
|
|
51
|
+
runtime?: string;
|
|
52
|
+
baseBranch?: string;
|
|
53
|
+
siblings?: string;
|
|
54
|
+
json?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readCurrentRun(root: string): string | null {
|
|
58
|
+
const path = currentRunPath(root);
|
|
59
|
+
return existsSync(path) ? readFileSync(path, "utf8").trim() || null : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeCurrentRun(root: string, runId: string): void {
|
|
63
|
+
writeFileSync(currentRunPath(root), `${runId}\n`, "utf8");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function loadOrBuildManifest(root: string): AgentManifest {
|
|
67
|
+
const path = manifestFilePath(root);
|
|
68
|
+
return existsSync(path) ? loadManifest(path) : buildDefaultManifest();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Read a base agent definition, preferring a project-deployed copy over the bundled one. */
|
|
72
|
+
function readBaseDefinition(root: string, file: string): string {
|
|
73
|
+
const deployed = `${root}/.agentplate/agent-defs/${file}`;
|
|
74
|
+
if (existsSync(deployed)) return readFileSync(deployed, "utf8");
|
|
75
|
+
const bundled = packageAgentDefPath(file);
|
|
76
|
+
if (existsSync(bundled)) return readFileSync(bundled, "utf8");
|
|
77
|
+
return "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Generate a collision-free agent name for a capability + task. */
|
|
81
|
+
function uniqueName(store: ReturnType<typeof createSessionStore>, preferred: string): string {
|
|
82
|
+
if (!store.getSessionByAgent(preferred)) return preferred;
|
|
83
|
+
for (let i = 2; i < 100; i++) {
|
|
84
|
+
const candidate = `${preferred}-${i}`;
|
|
85
|
+
if (!store.getSessionByAgent(candidate)) return candidate;
|
|
86
|
+
}
|
|
87
|
+
return `${preferred}-${preferred.length}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createSlingCommand(): Command {
|
|
91
|
+
return new Command("sling")
|
|
92
|
+
.description("Spawn a worker agent")
|
|
93
|
+
.argument("<task-id>", "task identifier")
|
|
94
|
+
.option("--capability <type>", "scout | builder | reviewer | lead | merger", "builder")
|
|
95
|
+
.option("--name <name>", "unique agent name (auto-generated if omitted)")
|
|
96
|
+
.option("--spec <path>", "path to a task spec file")
|
|
97
|
+
.option("--files <list>", "comma-separated exclusive file scope")
|
|
98
|
+
.option("--parent <agent>", "parent agent (for hierarchy)")
|
|
99
|
+
.option("--depth <n>", "hierarchy depth", "0")
|
|
100
|
+
.option("--run <id>", "attach to an existing run")
|
|
101
|
+
.option("--runtime <name>", "runtime adapter (default: config)")
|
|
102
|
+
.option("--base-branch <branch>", "base branch for the worktree")
|
|
103
|
+
.option("--siblings <names>", "comma-separated parallel sibling names")
|
|
104
|
+
.option("--json", "output JSON")
|
|
105
|
+
.action(async (taskId: string, opts: SlingOptions, command: Command) => {
|
|
106
|
+
const useJson = command.optsWithGlobals().json === true;
|
|
107
|
+
const root = findProjectRoot();
|
|
108
|
+
if (!isInitialized(root)) {
|
|
109
|
+
throw new ValidationError("Not initialized. Run `agentplate setup` first.");
|
|
110
|
+
}
|
|
111
|
+
const config = loadConfig(root);
|
|
112
|
+
|
|
113
|
+
const capability = (opts.capability ?? "builder") as Capability;
|
|
114
|
+
if (!SUPPORTED_CAPABILITIES.includes(capability)) {
|
|
115
|
+
throw new ValidationError(
|
|
116
|
+
`Unknown capability "${capability}". One of: ${SUPPORTED_CAPABILITIES.join(", ")}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const manifest = loadOrBuildManifest(root);
|
|
120
|
+
const def = getDefinition(manifest, capability); // throws NotFoundError if absent
|
|
121
|
+
|
|
122
|
+
const store = createSessionStore(sessionsDbPath(root));
|
|
123
|
+
const mail = createMailClient(root);
|
|
124
|
+
const events = createEventStore(eventsDbPath(root));
|
|
125
|
+
try {
|
|
126
|
+
// Resolve the run this agent belongs to.
|
|
127
|
+
const runId = resolveRun(store, root, opts);
|
|
128
|
+
|
|
129
|
+
const name = uniqueName(store, opts.name ?? `${capability}-${taskId}`);
|
|
130
|
+
const branchName = `agentplate/${name}`;
|
|
131
|
+
|
|
132
|
+
// 1. Isolated worktree.
|
|
133
|
+
const worktree = await createWorktree(root, name, branchName, opts.baseBranch);
|
|
134
|
+
|
|
135
|
+
// 2. Retrieve relevant skills (records applied-skills.json for feedback).
|
|
136
|
+
const fileScope = opts.files ? opts.files.split(",").map((f) => f.trim()) : [];
|
|
137
|
+
const skillsOverlay = retrieveSkillsForSpawn({
|
|
138
|
+
root,
|
|
139
|
+
agentName: name,
|
|
140
|
+
capability,
|
|
141
|
+
taskId,
|
|
142
|
+
fileScope,
|
|
143
|
+
taskText: `${taskId} ${capability}`,
|
|
144
|
+
skills: config.skills,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// 3. Overlay (base definition + assignment + skills) → instruction file.
|
|
148
|
+
const runtime = getRuntime(opts.runtime ?? config.runtime.default, config.runtime.default);
|
|
149
|
+
const overlayConfig: OverlayConfig = {
|
|
150
|
+
agentName: name,
|
|
151
|
+
capability,
|
|
152
|
+
taskId,
|
|
153
|
+
specPath: opts.spec,
|
|
154
|
+
branchName,
|
|
155
|
+
worktreePath: worktree.path,
|
|
156
|
+
parentAgent: opts.parent ?? null,
|
|
157
|
+
depth: Number(opts.depth ?? "0"),
|
|
158
|
+
fileScope,
|
|
159
|
+
baseDefinition: readBaseDefinition(root, def.file),
|
|
160
|
+
canSpawn: def.canSpawn,
|
|
161
|
+
qualityGates: config.project.qualityGates ?? [],
|
|
162
|
+
constraints: def.constraints,
|
|
163
|
+
siblings: opts.siblings ? opts.siblings.split(",").map((s) => s.trim()) : undefined,
|
|
164
|
+
skillsOverlay: skillsOverlay || undefined,
|
|
165
|
+
};
|
|
166
|
+
writeOverlay(overlayConfig, runtime.instructionPath);
|
|
167
|
+
|
|
168
|
+
// 3. Identity + session row.
|
|
169
|
+
createIdentity(root, name, capability);
|
|
170
|
+
const now = new Date().toISOString();
|
|
171
|
+
const session: AgentSession = {
|
|
172
|
+
id: `session-${name}-${Date.now()}`,
|
|
173
|
+
agentName: name,
|
|
174
|
+
capability,
|
|
175
|
+
taskId,
|
|
176
|
+
runId,
|
|
177
|
+
worktreePath: worktree.path,
|
|
178
|
+
branchName,
|
|
179
|
+
state: "booting",
|
|
180
|
+
parentAgent: opts.parent ?? null,
|
|
181
|
+
depth: Number(opts.depth ?? "0"),
|
|
182
|
+
pid: null,
|
|
183
|
+
runtimeSessionId: null,
|
|
184
|
+
startedAt: now,
|
|
185
|
+
lastActivity: now,
|
|
186
|
+
};
|
|
187
|
+
store.upsertSession(session);
|
|
188
|
+
|
|
189
|
+
// 4. Dispatch over mail (before the turn, so checkInject sees it).
|
|
190
|
+
mail.send({
|
|
191
|
+
from: opts.parent ?? "operator",
|
|
192
|
+
to: name,
|
|
193
|
+
subject: `Dispatch: ${taskId}`,
|
|
194
|
+
body: dispatchBody(taskId, capability, overlayConfig),
|
|
195
|
+
type: "dispatch",
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// 5. Run the first headless turn.
|
|
199
|
+
const resolved = resolveModel(config, root, def.model);
|
|
200
|
+
store.updateSessionState(session.id, "working");
|
|
201
|
+
const prompt = buildInitialPrompt(mail.checkInject(name), runtime.instructionPath);
|
|
202
|
+
let sawError = false;
|
|
203
|
+
const turn = await runTurn({
|
|
204
|
+
runtime,
|
|
205
|
+
worktreePath: worktree.path,
|
|
206
|
+
model: resolved.model,
|
|
207
|
+
prompt,
|
|
208
|
+
env: resolved.env,
|
|
209
|
+
onEvent: (event) => {
|
|
210
|
+
if (event.error || event.type === "error") sawError = true;
|
|
211
|
+
// Prefer the error message (so a failed agent's reason is visible in
|
|
212
|
+
// the feed/logs), else the token/cost JSON the Costs page aggregates.
|
|
213
|
+
const detail = event.error
|
|
214
|
+
? event.error
|
|
215
|
+
: event.usage
|
|
216
|
+
? JSON.stringify({ tokens: event.usage.tokens, cost: event.usage.costUsd })
|
|
217
|
+
: null;
|
|
218
|
+
events.record({
|
|
219
|
+
agentName: name,
|
|
220
|
+
runId,
|
|
221
|
+
type: event.type,
|
|
222
|
+
tool: event.tool ?? null,
|
|
223
|
+
detail,
|
|
224
|
+
});
|
|
225
|
+
// Bump last_activity on every streamed event so a long but active
|
|
226
|
+
// turn keeps itself fresh and is never reaped as "idle".
|
|
227
|
+
store.touch(session.id);
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
if (turn.runtimeSessionId) store.setRuntimeSessionId(session.id, turn.runtimeSessionId);
|
|
231
|
+
|
|
232
|
+
// A non-zero exit with no error event means the runtime failed via stderr
|
|
233
|
+
// (e.g. Pi's "No API key found for anthropic"). Record that stderr so the
|
|
234
|
+
// failure reason is visible in the feed/logs instead of a blank "failed".
|
|
235
|
+
if (turn.exitCode !== 0 && !sawError) {
|
|
236
|
+
const reason = turn.stderr.trim();
|
|
237
|
+
if (reason) {
|
|
238
|
+
events.record({
|
|
239
|
+
agentName: name,
|
|
240
|
+
runId,
|
|
241
|
+
type: "error",
|
|
242
|
+
tool: null,
|
|
243
|
+
detail: reason.length > 1000 ? `${reason.slice(0, 1000)}…` : reason,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 6. Observe terminal mail to transition the session.
|
|
249
|
+
const finalState = resolveFinalState(root, name, capability, turn.exitCode);
|
|
250
|
+
store.updateSessionState(session.id, finalState);
|
|
251
|
+
store.touch(session.id);
|
|
252
|
+
updateIdentity(root, name, {
|
|
253
|
+
taskId,
|
|
254
|
+
summary: `${capability} ran a turn for ${taskId} → ${finalState}`,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// 7. Self-improving loop: score quality gates, append outcomes to
|
|
258
|
+
// applied skills, and distill a skill when the work passed. Only
|
|
259
|
+
// runs for a completed task; best-effort (never fails the spawn).
|
|
260
|
+
if (finalState === "completed" && config.skills.enabled) {
|
|
261
|
+
try {
|
|
262
|
+
const gateOutcome = await runQualityGates(
|
|
263
|
+
config.project.qualityGates ?? [],
|
|
264
|
+
worktree.path,
|
|
265
|
+
);
|
|
266
|
+
await runSkillFeedbackAndDistill({
|
|
267
|
+
root,
|
|
268
|
+
agentName: name,
|
|
269
|
+
capability,
|
|
270
|
+
taskId,
|
|
271
|
+
worktreePath: worktree.path,
|
|
272
|
+
baseRef: config.project.canonicalBranch,
|
|
273
|
+
runtime,
|
|
274
|
+
outcomeStatus: gateOutcome?.status ?? null,
|
|
275
|
+
skills: config.skills,
|
|
276
|
+
model: resolved.model,
|
|
277
|
+
});
|
|
278
|
+
} catch {
|
|
279
|
+
// Skill loop is advisory; a failure here must not fail the spawn.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (useJson) {
|
|
284
|
+
jsonOutput({
|
|
285
|
+
agent: name,
|
|
286
|
+
capability,
|
|
287
|
+
taskId,
|
|
288
|
+
runId,
|
|
289
|
+
branchName,
|
|
290
|
+
worktreePath: worktree.path,
|
|
291
|
+
state: finalState,
|
|
292
|
+
exitCode: turn.exitCode,
|
|
293
|
+
});
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
printSuccess(`${brand(name)} [${capability}] → ${finalState}`);
|
|
297
|
+
printInfo(` task: ${taskId}`);
|
|
298
|
+
printInfo(` branch: ${branchName}`);
|
|
299
|
+
printInfo(` worktree:${muted(` ${worktree.path}`)}`);
|
|
300
|
+
if (turn.exitCode !== 0) {
|
|
301
|
+
printHint(` turn exited ${turn.exitCode}; see \`agentplate mail list --from ${name}\``);
|
|
302
|
+
}
|
|
303
|
+
} finally {
|
|
304
|
+
events.close();
|
|
305
|
+
mail.close();
|
|
306
|
+
store.close();
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function resolveRun(
|
|
312
|
+
store: ReturnType<typeof createSessionStore>,
|
|
313
|
+
root: string,
|
|
314
|
+
opts: SlingOptions,
|
|
315
|
+
): string {
|
|
316
|
+
if (opts.run && store.getRun(opts.run)) return opts.run;
|
|
317
|
+
if (opts.parent) {
|
|
318
|
+
const parent = store.getSessionByAgent(opts.parent);
|
|
319
|
+
if (parent && store.getRun(parent.runId)) return parent.runId;
|
|
320
|
+
}
|
|
321
|
+
const fromFile = readCurrentRun(root);
|
|
322
|
+
if (fromFile && store.getRun(fromFile)) return fromFile;
|
|
323
|
+
const run = store.createRun();
|
|
324
|
+
writeCurrentRun(root, run.id);
|
|
325
|
+
return run.id;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function dispatchBody(taskId: string, capability: Capability, cfg: OverlayConfig): string {
|
|
329
|
+
const lines = [
|
|
330
|
+
`You are ${cfg.agentName}, a ${capability} agent.`,
|
|
331
|
+
`Task: ${taskId}`,
|
|
332
|
+
cfg.specPath ? `Spec: ${cfg.specPath}` : undefined,
|
|
333
|
+
cfg.fileScope.length ? `File scope: ${cfg.fileScope.join(", ")}` : undefined,
|
|
334
|
+
`Your full instructions are in ${cfg.worktreePath}.`,
|
|
335
|
+
];
|
|
336
|
+
return lines.filter(Boolean).join("\n");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function buildInitialPrompt(injected: string, instructionPath: string): string {
|
|
340
|
+
const header = `Read your instructions at ${instructionPath}, then begin your task.`;
|
|
341
|
+
return injected ? `${injected}\n\n${header}` : header;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Terminal mail types that mark a capability's work complete. */
|
|
345
|
+
function terminalTypesFor(capability: Capability): string[] {
|
|
346
|
+
return capability === "merger" ? ["merged", "merge_failed"] : ["worker_done"];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function resolveFinalState(
|
|
350
|
+
root: string,
|
|
351
|
+
name: string,
|
|
352
|
+
capability: Capability,
|
|
353
|
+
exitCode: number,
|
|
354
|
+
): AgentSession["state"] {
|
|
355
|
+
const terminal = terminalTypesFor(capability);
|
|
356
|
+
const store = createMailStore(mailDbPath(root));
|
|
357
|
+
try {
|
|
358
|
+
const sent = store.list({ from: name });
|
|
359
|
+
if (sent.some((m) => terminal.includes(m.type))) return "completed";
|
|
360
|
+
} finally {
|
|
361
|
+
store.close();
|
|
362
|
+
}
|
|
363
|
+
if (exitCode === 0) return "idle";
|
|
364
|
+
return "failed";
|
|
365
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentplate status` — show runs, active agent sessions, and worktrees.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
|
|
7
|
+
import { ValidationError } from "../errors.ts";
|
|
8
|
+
import { jsonOutput } from "../json.ts";
|
|
9
|
+
import { brand, muted, printInfo } from "../logging/color.ts";
|
|
10
|
+
import { sessionsDbPath } from "../paths.ts";
|
|
11
|
+
import { createSessionStore } from "../sessions/store.ts";
|
|
12
|
+
import { listWorktrees } from "../worktree/manager.ts";
|
|
13
|
+
|
|
14
|
+
export function createStatusCommand(): Command {
|
|
15
|
+
return new Command("status")
|
|
16
|
+
.description("Show runs, agent sessions, and worktrees")
|
|
17
|
+
.option("--all", "show sessions from all runs (default: current run only)")
|
|
18
|
+
.option("--json", "output JSON")
|
|
19
|
+
.action(async (opts: { all?: boolean; json?: boolean }, command: Command) => {
|
|
20
|
+
const useJson = command.optsWithGlobals().json === true;
|
|
21
|
+
const root = findProjectRoot();
|
|
22
|
+
if (!isInitialized(root)) {
|
|
23
|
+
throw new ValidationError("Not initialized. Run `agentplate setup` first.");
|
|
24
|
+
}
|
|
25
|
+
loadConfig(root);
|
|
26
|
+
const store = createSessionStore(sessionsDbPath(root));
|
|
27
|
+
try {
|
|
28
|
+
const runs = store.listRuns(10);
|
|
29
|
+
const currentRun = runs[0];
|
|
30
|
+
const sessions = store.listSessions(
|
|
31
|
+
opts.all || !currentRun ? undefined : { runId: currentRun.id },
|
|
32
|
+
);
|
|
33
|
+
const worktrees = await listWorktrees(root);
|
|
34
|
+
|
|
35
|
+
if (useJson) {
|
|
36
|
+
jsonOutput({ runs, sessions, worktrees });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
printInfo(brand("agentplate status"));
|
|
41
|
+
printInfo(`\nruns (${runs.length})`);
|
|
42
|
+
for (const run of runs) {
|
|
43
|
+
printInfo(` ${run.id} ${run.status} ${muted(run.createdAt)}`);
|
|
44
|
+
}
|
|
45
|
+
printInfo(`\nsessions (${sessions.length})`);
|
|
46
|
+
if (sessions.length === 0) printInfo(muted(" none"));
|
|
47
|
+
for (const s of sessions) {
|
|
48
|
+
printInfo(
|
|
49
|
+
` ${s.agentName} [${s.capability}] ${s.state} task:${s.taskId} ${muted(s.branchName)}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
printInfo(`\nworktrees (${worktrees.length})`);
|
|
53
|
+
for (const w of worktrees) {
|
|
54
|
+
printInfo(` ${w.branch || "(detached)"} ${muted(w.path)}`);
|
|
55
|
+
}
|
|
56
|
+
} finally {
|
|
57
|
+
store.close();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentplate stop <agent>` — mark an agent session stopped (and optionally remove
|
|
3
|
+
* its worktree). Headless agents are spawn-per-turn, so there is no long-lived
|
|
4
|
+
* process to kill; stopping records the terminal state so no further turns run.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import { findProjectRoot, isInitialized } from "../config.ts";
|
|
9
|
+
import { NotFoundError, ValidationError } from "../errors.ts";
|
|
10
|
+
import { jsonOutput } from "../json.ts";
|
|
11
|
+
import { printSuccess, printWarning } from "../logging/color.ts";
|
|
12
|
+
import { sessionsDbPath } from "../paths.ts";
|
|
13
|
+
import { createSessionStore } from "../sessions/store.ts";
|
|
14
|
+
import { removeWorktree } from "../worktree/manager.ts";
|
|
15
|
+
|
|
16
|
+
export function createStopCommand(): Command {
|
|
17
|
+
return new Command("stop")
|
|
18
|
+
.description("Terminate an agent session")
|
|
19
|
+
.argument("<agent>", "agent name")
|
|
20
|
+
.option("--clean-worktree", "also remove the agent's worktree")
|
|
21
|
+
.option("--json", "output JSON")
|
|
22
|
+
.action(
|
|
23
|
+
async (
|
|
24
|
+
agent: string,
|
|
25
|
+
opts: { cleanWorktree?: boolean; json?: boolean },
|
|
26
|
+
command: Command,
|
|
27
|
+
) => {
|
|
28
|
+
const useJson = command.optsWithGlobals().json === true;
|
|
29
|
+
const root = findProjectRoot();
|
|
30
|
+
if (!isInitialized(root)) {
|
|
31
|
+
throw new ValidationError("Not initialized. Run `agentplate setup` first.");
|
|
32
|
+
}
|
|
33
|
+
const store = createSessionStore(sessionsDbPath(root));
|
|
34
|
+
try {
|
|
35
|
+
const session = store.getSessionByAgent(agent);
|
|
36
|
+
if (!session) throw new NotFoundError(`No session for agent "${agent}"`);
|
|
37
|
+
store.updateSessionState(session.id, "stopped");
|
|
38
|
+
|
|
39
|
+
let worktreeRemoved = false;
|
|
40
|
+
if (opts.cleanWorktree) {
|
|
41
|
+
try {
|
|
42
|
+
await removeWorktree(root, session.worktreePath, { force: true });
|
|
43
|
+
worktreeRemoved = true;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
printWarning(`Could not remove worktree: ${(error as Error).message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (useJson) jsonOutput({ agent, stopped: true, worktreeRemoved });
|
|
50
|
+
else printSuccess(`Stopped ${agent}${worktreeRemoved ? " (worktree removed)" : ""}`);
|
|
51
|
+
} finally {
|
|
52
|
+
store.close();
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentplate tui` / `agentplate dashboard` — a live terminal dashboard.
|
|
3
|
+
*
|
|
4
|
+
* A dependency-free 3-pane TUI (no Ink/blessed), repainting on an interval:
|
|
5
|
+
* ┌──────────────── Active agents (full width) ─────────────────┐
|
|
6
|
+
* ├──────────── Live feed ───────────┬──────── Tasks ───────────┤
|
|
7
|
+
* └──────────────────────────────────┴──────────────────────────┘
|
|
8
|
+
* Reads the same stores the web UI uses, via the shared build* helpers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Command } from "commander";
|
|
12
|
+
import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
|
|
13
|
+
import { ValidationError } from "../errors.ts";
|
|
14
|
+
import { accent, brand, chalk, muted } from "../logging/color.ts";
|
|
15
|
+
import { sessionsDbPath } from "../paths.ts";
|
|
16
|
+
import { type ApiContext, buildFeed, buildTasks, resolveCurrentRunId } from "../serve/api.ts";
|
|
17
|
+
import { createSessionStore } from "../sessions/store.ts";
|
|
18
|
+
import type { AgentSession, SessionState } from "../types.ts";
|
|
19
|
+
|
|
20
|
+
const STATE_COLOR: Record<SessionState, (s: string) => string> = {
|
|
21
|
+
booting: chalk.yellow,
|
|
22
|
+
working: chalk.green,
|
|
23
|
+
idle: chalk.cyan,
|
|
24
|
+
completed: chalk.blue,
|
|
25
|
+
failed: chalk.red,
|
|
26
|
+
stopped: muted,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const TASK_COLOR: Record<string, (s: string) => string> = {
|
|
30
|
+
active: chalk.green,
|
|
31
|
+
pending: chalk.cyan,
|
|
32
|
+
done: chalk.blue,
|
|
33
|
+
failed: chalk.red,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// --- text helpers -----------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI SGR codes.
|
|
39
|
+
const ANSI = /\[[0-9;]*m/g;
|
|
40
|
+
|
|
41
|
+
/** Visible length, ignoring ANSI escape sequences. */
|
|
42
|
+
function visLen(s: string): number {
|
|
43
|
+
return s.replace(ANSI, "").length;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Truncate to `width` visible chars. ANSI-styled input is flattened to plain. */
|
|
47
|
+
function clip(s: string, width: number): string {
|
|
48
|
+
if (visLen(s) <= width) return s;
|
|
49
|
+
const plain = s.replace(ANSI, "");
|
|
50
|
+
return `${plain.slice(0, Math.max(0, width - 1))}…`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Pad a string (ANSI-aware) to exactly `width` visible chars. */
|
|
54
|
+
function pad(s: string, width: number): string {
|
|
55
|
+
const clipped = clip(s, width);
|
|
56
|
+
return clipped + " ".repeat(Math.max(0, width - visLen(clipped)));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- box drawing ------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
function topBorder(width: number, title: string): string {
|
|
62
|
+
const t = ` ${title} `;
|
|
63
|
+
const fill = "─".repeat(Math.max(0, width - 2 - visLen(t)));
|
|
64
|
+
return muted("┌") + t + muted(`${fill}┐`);
|
|
65
|
+
}
|
|
66
|
+
function botBorder(width: number): string {
|
|
67
|
+
return muted(`└${"─".repeat(Math.max(0, width - 2))}┘`);
|
|
68
|
+
}
|
|
69
|
+
function row(width: number, content: string): string {
|
|
70
|
+
return `${muted("│")} ${pad(content, width - 4)} ${muted("│")}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- pane content -----------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
function agentLine(s: AgentSession, width: number): string {
|
|
76
|
+
const color = STATE_COLOR[s.state] ?? muted;
|
|
77
|
+
const name = pad(s.agentName, 18);
|
|
78
|
+
const cap = pad(s.capability, 11);
|
|
79
|
+
const state = color(pad(s.state, 10));
|
|
80
|
+
const task = muted(s.taskId);
|
|
81
|
+
return clip(`${name} ${cap} ${state} ${task}`, width);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function render(root: string): string {
|
|
85
|
+
const cols = Math.max(80, process.stdout.columns ?? 100);
|
|
86
|
+
const width = cols - 1;
|
|
87
|
+
const ctx: ApiContext = { root };
|
|
88
|
+
const config = loadConfig(root);
|
|
89
|
+
const store = createSessionStore(sessionsDbPath(root));
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const currentRunId = resolveCurrentRunId(store, root);
|
|
93
|
+
const currentRun = currentRunId ? store.getRun(currentRunId) : null;
|
|
94
|
+
const sessions = currentRunId ? store.listSessions({ runId: currentRunId }) : [];
|
|
95
|
+
// Show ALL agents in the run, active first — agents spend most of their life
|
|
96
|
+
// idle between turns, so filtering to working/booting made the pane look empty
|
|
97
|
+
// even with a full team. Sort active → idle → done so live ones stay on top.
|
|
98
|
+
const stateRank = (s: AgentSession): number =>
|
|
99
|
+
s.state === "working" || s.state === "booting" ? 0 : s.state === "idle" ? 1 : 2;
|
|
100
|
+
const agentsSorted = [...sessions].sort((a, b) => stateRank(a) - stateRank(b));
|
|
101
|
+
const activeCount = sessions.filter(
|
|
102
|
+
(s) => s.state === "working" || s.state === "booting",
|
|
103
|
+
).length;
|
|
104
|
+
const tasks = buildTasks(ctx);
|
|
105
|
+
const feed = buildFeed(ctx, 40);
|
|
106
|
+
|
|
107
|
+
const lines: string[] = [];
|
|
108
|
+
|
|
109
|
+
// Header
|
|
110
|
+
lines.push(
|
|
111
|
+
`${brand("⚒ Agentplate")} ${muted("—")} ${config.project.name} ${muted(
|
|
112
|
+
`runtime ${config.runtime.default} · provider ${config.activeProvider} · ${
|
|
113
|
+
currentRun ? currentRun.id : "no run"
|
|
114
|
+
}`,
|
|
115
|
+
)}`,
|
|
116
|
+
);
|
|
117
|
+
lines.push("");
|
|
118
|
+
|
|
119
|
+
// ── Top pane: Agents (full width) — all agents in the run, active first ──
|
|
120
|
+
lines.push(topBorder(width, accent(`Agents (${sessions.length} · ${activeCount} active)`)));
|
|
121
|
+
if (agentsSorted.length === 0) {
|
|
122
|
+
lines.push(row(width, muted("No agents yet. Spawn one with `agentplate sling <task>`.")));
|
|
123
|
+
} else {
|
|
124
|
+
for (const s of agentsSorted.slice(0, 8)) lines.push(row(width, agentLine(s, width - 4)));
|
|
125
|
+
if (agentsSorted.length > 8)
|
|
126
|
+
lines.push(row(width, muted(`…and ${agentsSorted.length - 8} more`)));
|
|
127
|
+
}
|
|
128
|
+
lines.push(botBorder(width));
|
|
129
|
+
lines.push("");
|
|
130
|
+
|
|
131
|
+
// ── Split: Live feed (left) | Tasks (right) ──
|
|
132
|
+
const leftW = Math.floor((width - 1) * 0.58);
|
|
133
|
+
const rightW = width - 1 - leftW;
|
|
134
|
+
const bodyRows = Math.max(8, (process.stdout.rows ?? 30) - lines.length - 4);
|
|
135
|
+
|
|
136
|
+
const feedLines = feed.slice(0, bodyRows).map((f) => {
|
|
137
|
+
// Terminal-feed style: colored 5-char label + agent + summary.
|
|
138
|
+
const labelColor =
|
|
139
|
+
f.level === "error" ? chalk.red : f.level === "warn" ? chalk.yellow : chalk.cyan;
|
|
140
|
+
return clip(`${labelColor(f.label)} ${muted(f.agent)} ${f.summary}`, leftW - 6);
|
|
141
|
+
});
|
|
142
|
+
const taskLines = tasks.slice(0, bodyRows).map((t) => {
|
|
143
|
+
const color = TASK_COLOR[t.status] ?? muted;
|
|
144
|
+
return `${color(pad(t.status, 8))} ${clip(t.taskId, rightW - 12)}`;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const paneRows = Math.max(feedLines.length, taskLines.length, 1);
|
|
148
|
+
lines.push(
|
|
149
|
+
`${topBorder(leftW, accent(`Live feed (${feed.length})`))}${topBorder(
|
|
150
|
+
rightW,
|
|
151
|
+
accent(`Tasks (${tasks.length})`),
|
|
152
|
+
)}`,
|
|
153
|
+
);
|
|
154
|
+
for (let i = 0; i < paneRows; i++) {
|
|
155
|
+
const l = feedLines[i] ?? (i === 0 && feed.length === 0 ? muted("No activity yet.") : "");
|
|
156
|
+
const r = taskLines[i] ?? (i === 0 && tasks.length === 0 ? muted("No tasks yet.") : "");
|
|
157
|
+
lines.push(`${row(leftW, l)}${row(rightW, r)}`);
|
|
158
|
+
}
|
|
159
|
+
lines.push(`${botBorder(leftW)}${botBorder(rightW)}`);
|
|
160
|
+
|
|
161
|
+
lines.push("");
|
|
162
|
+
lines.push(muted(`Updated ${new Date().toLocaleTimeString()} · refresh 5s · Ctrl+C to exit`));
|
|
163
|
+
return lines.join("\n");
|
|
164
|
+
} finally {
|
|
165
|
+
store.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function createTuiCommand(): Command {
|
|
170
|
+
return new Command("tui")
|
|
171
|
+
.aliases(["dashboard"])
|
|
172
|
+
.description("Live 3-pane terminal dashboard (agents / feed / tasks)")
|
|
173
|
+
.option("--interval <ms>", "refresh interval", "5000")
|
|
174
|
+
.option("--once", "render a single frame and exit")
|
|
175
|
+
.action((opts: { interval: string; once?: boolean }) => {
|
|
176
|
+
const root = findProjectRoot();
|
|
177
|
+
if (!isInitialized(root)) {
|
|
178
|
+
throw new ValidationError("Not initialized. Run `agentplate setup` first.");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const paint = () => {
|
|
182
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
183
|
+
process.stdout.write(`${render(root)}\n`);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
paint();
|
|
187
|
+
if (opts.once) return;
|
|
188
|
+
|
|
189
|
+
const interval = Math.max(1000, Number(opts.interval) || 5000);
|
|
190
|
+
const timer = setInterval(paint, interval);
|
|
191
|
+
const stop = () => {
|
|
192
|
+
clearInterval(timer);
|
|
193
|
+
process.stdout.write("\n");
|
|
194
|
+
process.exit(0);
|
|
195
|
+
};
|
|
196
|
+
process.on("SIGINT", stop);
|
|
197
|
+
process.on("SIGTERM", stop);
|
|
198
|
+
});
|
|
199
|
+
}
|