Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
449 changes: 449 additions & 0 deletions services/bootloader/index.ts

Large diffs are not rendered by default.

157 changes: 157 additions & 0 deletions services/lieutenant/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* Lieutenant service — persistent, conversational agent sessions.
*
* Unlike ephemeral tasks (one pi process per prompt), lieutenants are
* long-lived agent sessions that persist across tasks, accumulate context,
* and support multi-turn interaction. They can run locally or on Vers VMs.
*
* Tools (8):
* reef_lt_create — Spawn a lieutenant (local or remote)
* reef_lt_send — Send a message (prompt, steer, followUp)
* reef_lt_read — Read current/historical output
* reef_lt_status — Overview of all lieutenants
* reef_lt_pause — Pause a VM lieutenant (preserves state)
* reef_lt_resume — Resume a paused lieutenant
* reef_lt_destroy — Tear down a lieutenant (or all)
* reef_lt_discover — Recover lieutenants from registry
*
* State: data/lieutenants.sqlite (via LieutenantStore)
* Events: lieutenant:created, lieutenant:completed, lieutenant:paused,
* lieutenant:resumed, lieutenant:destroyed
*/

import { Hono } from "hono";
import type { FleetClient, ServiceContext, ServiceModule } from "../../src/core/types.js";
import { ServiceEventBus } from "../../src/core/events.js";
import { createRoutes } from "./routes.js";
import { LieutenantRuntime } from "./runtime.js";
import { LieutenantStore } from "./store.js";
import { registerTools } from "./tools.js";

const store = new LieutenantStore();

// Create runtime with a placeholder event bus — will be replaced in init()
let runtime = new LieutenantRuntime({ events: new ServiceEventBus(), store });
const routes = createRoutes(store, runtime);

const lieutenant: ServiceModule = {
name: "lieutenant",
description: "Persistent agent sessions — long-lived lieutenants with multi-turn context",
routes,

init(ctx: ServiceContext) {
// Re-create runtime with the real event bus from the service context
runtime = new LieutenantRuntime({
events: ctx.events,
store,
});
// Update route handlers to use the real runtime
// The Hono routes capture `runtime` by reference through the closure in createRoutes,
// but since we re-assigned `runtime` above, we need to patch the routes object.
// Instead, we replace the routes property directly.
(lieutenant as any).routes = createRoutes(store, runtime);
},

store: {
flush() {
store.flush();
},
async close() {
await runtime.shutdown();
store.close();
},
},

registerTools(pi, client: FleetClient) {
registerTools(pi, client);
},

widget: {
async getLines(client: FleetClient) {
try {
const res = await client.api<{ lieutenants: any[]; count: number }>("GET", "/lieutenant/lieutenants");
if (res.count === 0) return [];
const working = res.lieutenants.filter((l) => l.status === "working").length;
const idle = res.lieutenants.filter((l) => l.status === "idle").length;
const paused = res.lieutenants.filter((l) => l.status === "paused").length;
const parts = [`${res.count} LT`];
if (working) parts.push(`${working} working`);
if (idle) parts.push(`${idle} idle`);
if (paused) parts.push(`${paused} paused`);
return [`Lieutenants: ${parts.join(", ")}`];
} catch {
return [];
}
},
},

dependencies: ["store"],
capabilities: ["agent.spawn", "agent.communicate", "agent.lifecycle"],

routeDocs: {
"POST /lieutenants": {
summary: "Create a new lieutenant",
body: {
name: { type: "string", required: true, description: "Lieutenant name" },
role: { type: "string", required: true, description: "Role description (becomes system prompt context)" },
local: { type: "boolean", description: "Run locally as subprocess (default: true)" },
model: { type: "string", description: "Model ID" },
commitId: { type: "string", description: "Golden image commit ID (remote mode)" },
},
response: "The created lieutenant object",
},
"GET /lieutenants": {
summary: "List all active lieutenants",
query: { status: { type: "string", description: "Filter by status" } },
response: "{ lieutenants: [...], count }",
},
"GET /lieutenants/:name": {
summary: "Get a lieutenant by name",
params: { name: { type: "string", required: true, description: "Lieutenant name" } },
response: "Lieutenant object",
},
"POST /lieutenants/:name/send": {
summary: "Send a message to a lieutenant",
params: { name: { type: "string", required: true, description: "Lieutenant name" } },
body: {
message: { type: "string", required: true, description: "Message to send" },
mode: { type: "string", description: "prompt | steer | followUp" },
},
response: "{ sent, mode, note? }",
},
"GET /lieutenants/:name/read": {
summary: "Read lieutenant output",
params: { name: { type: "string", required: true, description: "Lieutenant name" } },
query: {
tail: { type: "number", description: "Characters from end" },
history: { type: "number", description: "Previous responses to include" },
},
response: "{ name, status, taskCount, output, ... }",
},
"POST /lieutenants/:name/pause": {
summary: "Pause a VM lieutenant (preserves state)",
params: { name: { type: "string", required: true, description: "Lieutenant name" } },
},
"POST /lieutenants/:name/resume": {
summary: "Resume a paused lieutenant",
params: { name: { type: "string", required: true, description: "Lieutenant name" } },
},
"DELETE /lieutenants/:name": {
summary: "Destroy a lieutenant",
params: { name: { type: "string", required: true, description: "Lieutenant name" } },
},
"POST /lieutenants/destroy-all": {
summary: "Destroy all lieutenants",
},
"POST /lieutenants/discover": {
summary: "Discover lieutenants from the registry",
response: "{ results: [...] }",
},
"GET /_panel": {
summary: "HTML dashboard showing active lieutenants",
response: "text/html",
},
},
};

export default lieutenant;
214 changes: 214 additions & 0 deletions services/lieutenant/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/**
* Lieutenant HTTP routes — management, messaging, status.
*/

import { Hono } from "hono";
import type { LieutenantStore, LtStatus } from "./store.js";
import { ConflictError, NotFoundError, ValidationError } from "./store.js";
import type { LieutenantRuntime } from "./runtime.js";

export function createRoutes(store: LieutenantStore, runtime: LieutenantRuntime): Hono {
const routes = new Hono();

// POST /lieutenants — create a new lieutenant
routes.post("/lieutenants", async (c) => {
try {
const body = await c.req.json();
const { name, role, local, model, commitId } = body;

if (!name || typeof name !== "string") return c.json({ error: "name is required" }, 400);
if (!role || typeof role !== "string") return c.json({ error: "role is required" }, 400);

const lt = await runtime.create({ name, role, isLocal: !!local, model, commitId });
return c.json(lt, 201);
} catch (e) {
if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
if (e instanceof ConflictError) return c.json({ error: e.message }, 409);
throw e;
}
});

// GET /lieutenants — list all active lieutenants
routes.get("/lieutenants", (c) => {
const status = c.req.query("status") as LtStatus | undefined;
const lts = store.list(status ? { status } : undefined);
return c.json({ lieutenants: lts, count: lts.length });
});

// GET /lieutenants/:name — get a lieutenant by name
routes.get("/lieutenants/:name", (c) => {
const lt = store.getByName(c.req.param("name"));
if (!lt || lt.status === "destroyed") return c.json({ error: "Lieutenant not found" }, 404);
return c.json(lt);
});

// POST /lieutenants/:name/send — send a message to a lieutenant
routes.post("/lieutenants/:name/send", async (c) => {
try {
const body = await c.req.json();
const { message, mode } = body;
if (!message || typeof message !== "string") return c.json({ error: "message is required" }, 400);

const result = await runtime.send(c.req.param("name"), message, mode);
return c.json(result);
} catch (e) {
if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
throw e;
}
});

// GET /lieutenants/:name/read — read lieutenant output
routes.get("/lieutenants/:name/read", (c) => {
const name = c.req.param("name");
const tail = c.req.query("tail") ? parseInt(c.req.query("tail")!, 10) : undefined;
const history = c.req.query("history") ? parseInt(c.req.query("history")!, 10) : undefined;

const lt = store.getByName(name);
if (!lt || lt.status === "destroyed") return c.json({ error: "Lieutenant not found" }, 404);

let output = lt.lastOutput || "(no output yet)";
if (tail && output.length > tail) {
output = `...${output.slice(-tail)}`;
}

const parts: string[] = [];
if (history && history > 0) {
const count = Math.min(history, lt.outputHistory.length);
const start = lt.outputHistory.length - count;
for (let i = start; i < lt.outputHistory.length; i++) {
parts.push(`=== Response ${i + 1} ===\n${lt.outputHistory[i]}\n`);
}
parts.push(`=== Current (${lt.status}) ===\n${output}`);
} else {
parts.push(output);
}

return c.json({
name,
status: lt.status,
taskCount: lt.taskCount,
outputLength: lt.lastOutput.length,
historyCount: lt.outputHistory.length,
output: parts.join("\n"),
});
});

// POST /lieutenants/:name/pause — pause a lieutenant
routes.post("/lieutenants/:name/pause", async (c) => {
try {
const result = await runtime.pause(c.req.param("name"));
return c.json(result);
} catch (e) {
if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
throw e;
}
});

// POST /lieutenants/:name/resume — resume a paused lieutenant
routes.post("/lieutenants/:name/resume", async (c) => {
try {
const result = await runtime.resume(c.req.param("name"));
return c.json(result);
} catch (e) {
if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
throw e;
}
});

// DELETE /lieutenants/:name — destroy a lieutenant
routes.delete("/lieutenants/:name", async (c) => {
try {
const name = c.req.param("name");
const result = await runtime.destroy(name);
return c.json(result);
} catch (e) {
if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
throw e;
}
});

// POST /lieutenants/destroy-all — destroy all lieutenants
routes.post("/lieutenants/destroy-all", async (c) => {
const results = await runtime.destroyAll();
return c.json({ results });
});

// POST /lieutenants/discover — discover lieutenants from registry
routes.post("/lieutenants/discover", async (c) => {
const results = await runtime.discover();
return c.json({ results });
});

// GET /_panel — HTML dashboard
routes.get("/_panel", (c) => {
const lts = store.list();
const rows = lts
.map((lt) => {
const statusColor =
lt.status === "idle"
? "#4f9"
: lt.status === "working"
? "#ff9800"
: lt.status === "paused"
? "#888"
: lt.status === "error"
? "#f55"
: "#aaa";
const icon =
lt.status === "working"
? "&#x27F3;"
: lt.status === "idle"
? "&#x25CF;"
: lt.status === "paused"
? "&#x23F8;"
: lt.status === "error"
? "&#x2717;"
: "&#x25CB;";
const location = lt.isLocal ? "local" : `VM: ${lt.vmId.slice(0, 12)}`;
return `<tr>
<td><span style="color:${statusColor}">${icon}</span> ${lt.name}</td>
<td>${lt.role.slice(0, 50)}</td>
<td style="color:${statusColor}">${lt.status}</td>
<td>${location}</td>
<td>${lt.taskCount}</td>
<td>${lt.lastActivityAt ? new Date(lt.lastActivityAt).toLocaleTimeString() : "---"}</td>
</tr>`;
})
.join("\n");

const html = `<!DOCTYPE html>
<html>
<head>
<title>Lieutenant Dashboard</title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; background: #1a1a2e; color: #e0e0e0; }
h1 { color: #64b5f6; }
table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
th, td { padding: 0.6rem 1rem; text-align: left; border-bottom: 1px solid #333; }
th { background: #16213e; color: #90caf9; }
tr:hover { background: #1a1a3e; }
.count { color: #888; font-size: 0.9rem; }
</style>
</head>
<body>
<h1>Lieutenants</h1>
<p class="count">${lts.length} lieutenant${lts.length !== 1 ? "s" : ""} active</p>
<table>
<thead>
<tr><th>Name</th><th>Role</th><th>Status</th><th>Location</th><th>Tasks</th><th>Last Active</th></tr>
</thead>
<tbody>
${rows || '<tr><td colspan="6"><em>No lieutenants active</em></td></tr>'}
</tbody>
</table>
</body>
</html>`;

return c.html(html);
});

return routes;
}
Loading
Loading