Merge remote-tracking branch 'origin/main' into feat/telegram-channel

# Conflicts:
#	apps/desktop/electron/electron-env.d.ts
#	apps/desktop/electron/ipc/index.ts
#	apps/desktop/electron/preload.ts
#	apps/desktop/src/App.tsx
#	apps/desktop/src/pages/layout.tsx
#	src/agent/async-agent.ts
#	src/agent/runner.ts
#	src/hub/hub.ts
This commit is contained in:
Naiyuan Qing 2026-02-09 13:44:08 +08:00
commit 23905daaa1
85 changed files with 7368 additions and 470 deletions

View file

@ -0,0 +1,264 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { AsyncAgent } from "./async-agent.js";
const subscribeCallbacks: Array<(event: any) => void> = [];
const internalRunState = { value: false };
const runMock = vi.fn(async () => ({ text: "", thinking: undefined, error: undefined }));
const runInternalMock = vi.fn(async () => ({ text: "", thinking: undefined, error: undefined }));
const flushSessionMock = vi.fn(async () => {});
const persistAssistantSummaryMock = vi.fn();
const subscribeAllMock = vi.fn((fn: (event: any) => void) => {
subscribeCallbacks.push(fn);
return () => {};
});
vi.mock("./runner.js", () => ({
Agent: class MockAgent {
sessionId = "test-session";
subscribeAll = subscribeAllMock;
run = runMock;
runInternal = runInternalMock;
flushSession = flushSessionMock;
persistAssistantSummary = persistAssistantSummaryMock;
get isInternalRun() {
return internalRunState.value;
}
getMessages() {
return [];
}
loadSessionMessages() {
return [];
}
async ensureInitialized() {}
getActiveTools() {
return [];
}
reloadTools() {
return [];
}
getSkillsWithStatus() {
return [];
}
getEligibleSkills() {
return [];
}
reloadSkills() {}
setToolStatus() {
return undefined;
}
getProfileId() {
return undefined;
}
getAgentName() {
return undefined;
}
setAgentName() {}
getUserContent() {
return undefined;
}
setUserContent() {}
getAgentStyle() {
return undefined;
}
setAgentStyle() {}
reloadSystemPrompt() {}
getProviderInfo() {
return { provider: "test", model: "test-model" };
}
setProvider() {
return { provider: "test", model: "test-model" };
}
},
}));
async function nextWithTimeout<T>(iter: AsyncIterator<T>, timeoutMs = 40): Promise<"timeout" | T> {
return await Promise.race([
iter.next().then((result) => (result.done ? "timeout" : result.value)),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), timeoutMs)),
]);
}
describe("AsyncAgent internal flow", () => {
afterEach(() => {
subscribeCallbacks.length = 0;
internalRunState.value = false;
runMock.mockReset();
runInternalMock.mockReset();
flushSessionMock.mockReset();
persistAssistantSummaryMock.mockReset();
subscribeAllMock.mockClear();
runMock.mockResolvedValue({ text: "", thinking: undefined, error: undefined });
runInternalMock.mockResolvedValue({ text: "", thinking: undefined, error: undefined });
flushSessionMock.mockResolvedValue(undefined);
});
it("filters internal events in direct subscribe stream", () => {
const agent = new AsyncAgent();
const events: Array<{ type: string }> = [];
const unsubscribe = agent.subscribe((event) => {
events.push(event as { type: string });
});
// subscribeAll is called twice:
// 1) constructor for read() channel forwarding
// 2) subscribe() for direct callback forwarding
const subscribeCallback = subscribeCallbacks[1];
expect(subscribeCallback).toBeDefined();
internalRunState.value = true;
subscribeCallback!({ type: "message_end" });
expect(events).toHaveLength(0);
internalRunState.value = false;
subscribeCallback!({ type: "message_end" });
expect(events).toHaveLength(1);
unsubscribe();
agent.close();
});
it("does not leak internal run errors to read() stream", async () => {
runInternalMock.mockResolvedValueOnce({ text: "", thinking: undefined, error: "internal failed" });
const agent = new AsyncAgent();
const iter = agent.read()[Symbol.asyncIterator]();
agent.writeInternal("test internal");
await agent.waitForIdle();
const value = await nextWithTimeout(iter);
expect(value).toBe("timeout");
agent.close();
});
it("does not leak internal run exceptions to read() stream", async () => {
runInternalMock.mockRejectedValueOnce(new Error("internal exception"));
const agent = new AsyncAgent();
const iter = agent.read()[Symbol.asyncIterator]();
agent.writeInternal("test internal");
await agent.waitForIdle();
const value = await nextWithTimeout(iter);
expect(value).toBe("timeout");
agent.close();
});
it("forwards assistant message stream (start/update/end) when writeInternal opts in", async () => {
let resolveRunInternal: ((value: { text: string; thinking: undefined; error: undefined }) => void) | undefined;
runInternalMock.mockImplementationOnce(
() => new Promise((resolve) => {
resolveRunInternal = resolve as typeof resolveRunInternal;
}),
);
const agent = new AsyncAgent();
const iter = agent.read()[Symbol.asyncIterator]();
const streamCallback = subscribeCallbacks[0];
expect(streamCallback).toBeDefined();
agent.writeInternal("announce", { forwardAssistant: true });
await Promise.resolve();
internalRunState.value = true;
streamCallback!({
type: "message_start",
message: { role: "assistant", content: [] },
});
streamCallback!({
type: "message_update",
message: { role: "assistant", content: [{ type: "text", text: "partial" }] },
});
streamCallback!({
type: "message_end",
message: { role: "user", content: [{ type: "text", text: "hidden internal prompt" }] },
});
streamCallback!({
type: "message_end",
message: { role: "assistant", content: [{ type: "text", text: "visible summary" }] },
});
const first = await nextWithTimeout(iter);
expect(first).not.toBe("timeout");
if (first !== "timeout") {
expect((first as { type: string }).type).toBe("message_start");
expect((first as { message: { role: string } }).message.role).toBe("assistant");
}
const second = await nextWithTimeout(iter);
expect(second).not.toBe("timeout");
if (second !== "timeout") {
expect((second as { type: string }).type).toBe("message_update");
expect((second as { message: { role: string } }).message.role).toBe("assistant");
}
const third = await nextWithTimeout(iter);
expect(third).not.toBe("timeout");
if (third !== "timeout") {
expect((third as { type: string }).type).toBe("message_end");
expect((third as { message: { role: string } }).message.role).toBe("assistant");
}
const fourth = await nextWithTimeout(iter);
expect(fourth).toBe("timeout");
resolveRunInternal!({ text: "", thinking: undefined, error: undefined });
await agent.waitForIdle();
internalRunState.value = false;
agent.close();
});
it("persists assistant summary when persistResponse is true and result has text", async () => {
runInternalMock.mockResolvedValueOnce({ text: "Summary of findings", thinking: undefined, error: undefined });
const agent = new AsyncAgent();
agent.writeInternal("announce findings", { forwardAssistant: true, persistResponse: true });
await agent.waitForIdle();
expect(persistAssistantSummaryMock).toHaveBeenCalledOnce();
expect(persistAssistantSummaryMock).toHaveBeenCalledWith("Summary of findings");
// flushSession called twice: once after runInternal, once after persistAssistantSummary
expect(flushSessionMock).toHaveBeenCalledTimes(2);
agent.close();
});
it("does not persist assistant summary when result text is NO_REPLY", async () => {
runInternalMock.mockResolvedValueOnce({ text: "NO_REPLY", thinking: undefined, error: undefined });
const agent = new AsyncAgent();
agent.writeInternal("announce findings", { forwardAssistant: true, persistResponse: true });
await agent.waitForIdle();
expect(persistAssistantSummaryMock).not.toHaveBeenCalled();
agent.close();
});
it("does not persist assistant summary when result text is empty", async () => {
runInternalMock.mockResolvedValueOnce({ text: " ", thinking: undefined, error: undefined });
const agent = new AsyncAgent();
agent.writeInternal("announce findings", { forwardAssistant: true, persistResponse: true });
await agent.waitForIdle();
expect(persistAssistantSummaryMock).not.toHaveBeenCalled();
agent.close();
});
it("does not persist assistant summary when persistResponse is not set", async () => {
runInternalMock.mockResolvedValueOnce({ text: "Summary of findings", thinking: undefined, error: undefined });
const agent = new AsyncAgent();
agent.writeInternal("announce findings", { forwardAssistant: true });
await agent.waitForIdle();
expect(persistAssistantSummaryMock).not.toHaveBeenCalled();
agent.close();
});
});

View file

@ -10,12 +10,21 @@ const devNull = { write: () => true } as unknown as NodeJS.WritableStream;
/** Discriminated union of legacy Message, raw AgentEvent, and MulticaEvent */
export type ChannelItem = Message | AgentEvent | MulticaEvent;
export interface WriteInternalOptions {
/** Forward assistant message_end events to realtime stream during internal runs */
forwardAssistant?: boolean | undefined;
/** After internal run completes, persist the LLM's summary as a non-internal assistant message */
persistResponse?: boolean | undefined;
}
export class AsyncAgent {
private readonly agent: Agent;
private readonly channel = new Channel<ChannelItem>();
private _closed = false;
private queue: Promise<void> = Promise.resolve();
private pendingWrites = 0;
private closeCallbacks: Array<() => void> = [];
private forwardInternalAssistant = false;
readonly sessionId: string;
constructor(options?: AgentOptions) {
@ -25,8 +34,11 @@ export class AsyncAgent {
});
this.sessionId = this.agent.sessionId;
// Forward raw AgentEvent and MulticaEvent into the channel
// Forward raw AgentEvent and MulticaEvent into the channel.
// Suppress forwarding during internal runs to avoid leaking
// orchestration messages to the frontend/real-time stream.
this.agent.subscribeAll((event: AgentEvent | MulticaEvent) => {
if (!this.shouldForwardEvent(event)) return;
this.channel.send(event);
});
}
@ -43,6 +55,7 @@ export class AsyncAgent {
/** Enqueue an agent run, handling errors and session flush */
private enqueue(runFn: () => ReturnType<Agent["run"]>): void {
if (this._closed) throw new Error("Agent is closed");
this.pendingWrites += 1;
this.queue = this.queue
.then(async () => {
@ -63,6 +76,47 @@ export class AsyncAgent {
console.error(`[AsyncAgent] Agent run exception: ${message}`);
this.channel.send({ id: uuidv7(), content: `[error] ${message}` });
this.agent.emitMulticaEvent({ type: "agent_error", error: message });
})
.finally(() => {
this.pendingWrites = Math.max(0, this.pendingWrites - 1);
});
}
/**
* Write an internal message to agent (non-blocking, serialized queue).
* Messages are persisted with `internal: true` and rolled back from
* in-memory state. Events are suppressed from the real-time stream by default.
*/
writeInternal(content: string, options?: WriteInternalOptions): void {
if (this._closed) throw new Error("Agent is closed");
const forwardAssistant = options?.forwardAssistant === true;
const persistResponse = options?.persistResponse === true;
this.queue = this.queue
.then(async () => {
if (this._closed) return;
const prevForward = this.forwardInternalAssistant;
this.forwardInternalAssistant = forwardAssistant;
try {
const result = await this.agent.runInternal(content);
await this.agent.flushSession();
if (result.error) {
// Internal run errors are for diagnostics only; do not leak to user stream.
console.error(`[AsyncAgent] Internal run error: ${result.error}`);
}
// Persist the LLM summary so it remains in parent context for future turns
if (persistResponse && result.text?.trim() && result.text.trim() !== "NO_REPLY") {
this.agent.persistAssistantSummary(result.text.trim());
await this.agent.flushSession();
}
} finally {
this.forwardInternalAssistant = prevForward;
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : String(err);
// Internal run exceptions are for diagnostics only; do not leak to user stream.
console.error(`[AsyncAgent] Internal run failed: ${message}`);
});
}
@ -79,6 +133,7 @@ export class AsyncAgent {
subscribe(callback: (event: AgentEvent | MulticaEvent) => void): () => void {
console.log(`[AsyncAgent] Adding subscriber for agent: ${this.sessionId}`);
const unsubscribe = this.agent.subscribeAll((event) => {
if (!this.shouldForwardEvent(event)) return;
console.log(`[AsyncAgent] Event received: ${event.type}`);
callback(event);
});
@ -93,6 +148,18 @@ export class AsyncAgent {
return this.queue;
}
private shouldForwardEvent(event: AgentEvent | MulticaEvent): boolean {
if (!this.agent.isInternalRun) return true;
if (!this.forwardInternalAssistant) return false;
if (event.type !== "message_start" && event.type !== "message_update" && event.type !== "message_end") {
return false;
}
const maybeMessage = (event as { message?: unknown }).message;
if (!maybeMessage || typeof maybeMessage !== "object") return false;
return (maybeMessage as { role?: unknown }).role === "assistant";
}
/** Register a callback to be invoked when the agent is closed */
onClose(callback: () => void): void {
if (this._closed) {
@ -179,6 +246,34 @@ export class AsyncAgent {
return this.agent.getProfileId();
}
/**
* Get profile directory path, if profile is enabled.
*/
getProfileDir(): string | undefined {
return this.agent.getProfileDir();
}
/**
* Get heartbeat configuration from profile config.
*/
getHeartbeatConfig():
| {
enabled?: boolean | undefined;
every?: string | undefined;
prompt?: string | undefined;
ackMaxChars?: number | undefined;
}
| undefined {
return this.agent.getHeartbeatConfig();
}
/**
* Number of queued/in-flight writes.
*/
getPendingWrites(): number {
return this.pendingWrites;
}
/**
* Get agent display name from profile config.
*/
@ -235,12 +330,20 @@ export class AsyncAgent {
}
/**
* Get all messages from the current session.
* Get all messages from the current session (in-memory state).
*/
getMessages(): AgentMessage[] {
return this.agent.getMessages();
}
/**
* Load messages from session storage with filtering.
* By default, internal messages are excluded.
*/
loadSessionMessages(options?: { includeInternal?: boolean }): AgentMessage[] {
return this.agent.loadSessionMessages(options);
}
/**
* Get current provider and model information.
*/

View file

@ -0,0 +1,466 @@
/**
* Cron command - Manage scheduled tasks
*
* Usage:
* multica cron status Show cron service status
* multica cron list List all jobs
* multica cron add <options> Add a new job
* multica cron run <id> Run a job immediately
* multica cron enable <id> Enable a job
* multica cron disable <id> Disable a job
* multica cron remove <id> Remove a job
* multica cron logs <id> Show job run logs
*/
import { cyan, yellow, green, dim, red, brightCyan } from "../colors.js";
import {
getCronService,
formatSchedule,
formatDuration,
parseTimeInput,
parseIntervalInput,
isValidCronExpr,
type CronSchedule,
type CronJobInput,
} from "../../../cron/index.js";
type Command = "status" | "list" | "add" | "run" | "enable" | "disable" | "remove" | "logs" | "help";
function printHelp() {
console.log(`
${brightCyan("Cron")} - Scheduled Task Management
${cyan("Usage:")} multica cron <command> [options]
${cyan("Commands:")}
${yellow("status")} Show cron service status
${yellow("list")} List all scheduled jobs
${yellow("add")} [options] Create a new scheduled job
${yellow("run")} <id> Run a job immediately
${yellow("enable")} <id> Enable a disabled job
${yellow("disable")} <id> Disable a job (keeps schedule)
${yellow("remove")} <id> Delete a job
${yellow("logs")} <id> Show run history for a job
${yellow("help")} Show this help
${cyan("Add Options:")}
${yellow("-n, --name")} <name> Job name (required)
${yellow("--at")} <time> One-time at ISO timestamp or relative (e.g., "10m", "2h")
${yellow("--every")} <interval> Repeat interval (e.g., "30m", "1h", "1d")
${yellow("--cron")} <expr> Cron expression (5-field, e.g., "0 9 * * *")
${yellow("--tz")} <timezone> Timezone for cron expression (e.g., "Asia/Shanghai")
${yellow("--message")} <text> Message to inject or prompt for agent
${yellow("--isolated")} Run in isolated session (default: main)
${yellow("--delete-after-run")} Delete after one-time run completes
${cyan("Examples:")}
${dim("# Show service status")}
multica cron status
${dim("# 10 minutes from now (one-shot)")}
multica cron add -n "Reminder" --at "10m" --message "Time to take a break!"
${dim("# Every day at 9am Beijing time")}
multica cron add -n "Morning check" --cron "0 9 * * *" --tz "Asia/Shanghai" \\
--message "Good morning! Check your tasks."
${dim("# Every 30 minutes")}
multica cron add -n "Health check" --every "30m" --message "System health check"
${dim("# Run a job now")}
multica cron run abc12345
${dim("# View job logs")}
multica cron logs abc12345
`);
}
function cmdStatus() {
const service = getCronService();
const status = service.status();
console.log(`\n${brightCyan("Cron Service Status")}\n`);
console.log(` ${cyan("Running:")} ${status.running ? green("Yes") : red("No")}`);
console.log(` ${cyan("Enabled:")} ${status.enabled ? green("Yes") : red("No")}`);
console.log(` ${cyan("Jobs:")} ${status.jobCount} total, ${status.enabledJobCount} enabled`);
if (status.nextWakeAtMs) {
const nextWake = new Date(status.nextWakeAtMs);
const relativeMs = status.nextWakeAtMs - Date.now();
console.log(` ${cyan("Next wake:")} ${nextWake.toLocaleString()} (in ${formatDuration(relativeMs)})`);
} else {
console.log(` ${cyan("Next wake:")} ${dim("none scheduled")}`);
}
console.log(` ${cyan("Store:")} ${dim(status.storePath)}`);
console.log("");
}
function cmdList(args: string[]) {
const service = getCronService();
const showEnabled = args.includes("--enabled");
const showDisabled = args.includes("--disabled");
let filter: { enabled?: boolean } | undefined;
if (showEnabled) filter = { enabled: true };
else if (showDisabled) filter = { enabled: false };
const jobs = service.list(filter);
if (jobs.length === 0) {
console.log("\nNo cron jobs found.");
console.log(`${dim("Create one with:")} multica cron add -n "Name" --at "10m" --message "Hello"`);
return;
}
console.log(`\n${brightCyan("Scheduled Jobs")}\n`);
for (const job of jobs) {
const statusIcon = job.enabled ? green("✓") : red("✗");
const shortId = job.id.slice(0, 8);
console.log(`${statusIcon} ${yellow(job.name)} ${dim(`(${shortId})`)}`);
console.log(` ${cyan("Schedule:")} ${formatSchedule(job.schedule)}`);
console.log(` ${cyan("Target:")} ${job.sessionTarget}`);
if (job.state.nextRunAtMs) {
const nextRun = new Date(job.state.nextRunAtMs);
const relativeMs = job.state.nextRunAtMs - Date.now();
if (relativeMs > 0) {
console.log(` ${cyan("Next run:")} ${nextRun.toLocaleString()} ${dim(`(in ${formatDuration(relativeMs)})`)}`);
} else {
console.log(` ${cyan("Next run:")} ${dim("pending execution")}`);
}
}
if (job.state.lastRunAtMs) {
const lastRun = new Date(job.state.lastRunAtMs);
const statusColor = job.state.lastStatus === "ok" ? green : job.state.lastStatus === "error" ? red : yellow;
console.log(` ${cyan("Last run:")} ${lastRun.toLocaleString()} ${statusColor(`[${job.state.lastStatus}]`)} ${dim(`(${formatDuration(job.state.lastDurationMs ?? 0)})`)}`);
if (job.state.lastError) {
console.log(` ${red("Error:")} ${job.state.lastError}`);
}
}
console.log("");
}
console.log(dim(`Total: ${jobs.length} job(s)`));
}
function cmdAdd(args: string[]) {
const service = getCronService();
// Parse arguments
let name: string | undefined;
let at: string | undefined;
let every: string | undefined;
let cronExpr: string | undefined;
let tz: string | undefined;
let message: string | undefined;
let isolated = false;
let deleteAfterRun = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
switch (arg) {
case "-n":
case "--name":
name = args[++i];
break;
case "--at":
at = args[++i];
break;
case "--every":
every = args[++i];
break;
case "--cron":
cronExpr = args[++i];
break;
case "--tz":
tz = args[++i];
break;
case "--message":
message = args[++i];
break;
case "--isolated":
isolated = true;
break;
case "--delete-after-run":
deleteAfterRun = true;
break;
}
}
// Validate
if (!name) {
console.error(`${red("Error:")} --name is required`);
console.error(`${dim("Usage:")} multica cron add -n "Job name" --at "10m" --message "Hello"`);
process.exit(1);
}
if (!message) {
console.error(`${red("Error:")} --message is required`);
process.exit(1);
}
// Parse schedule
let schedule: CronSchedule;
if (at) {
const atMs = parseTimeInput(at);
if (!atMs) {
console.error(`${red("Error:")} Invalid time format: ${at}`);
console.error(`${dim("Examples:")} "10m", "2h", "2024-12-31T23:59:00Z"`);
process.exit(1);
}
schedule = { kind: "at", atMs };
} else if (every) {
const everyMs = parseIntervalInput(every);
if (!everyMs) {
console.error(`${red("Error:")} Invalid interval format: ${every}`);
console.error(`${dim("Examples:")} "30s", "5m", "2h", "1d"`);
process.exit(1);
}
schedule = { kind: "every", everyMs };
} else if (cronExpr) {
if (!isValidCronExpr(cronExpr, tz)) {
console.error(`${red("Error:")} Invalid cron expression: ${cronExpr}`);
console.error(`${dim("Format:")} "minute hour day month weekday" (e.g., "0 9 * * *")`);
process.exit(1);
}
// Only include tz if it's defined (exactOptionalPropertyTypes)
schedule = tz ? { kind: "cron", expr: cronExpr, tz } : { kind: "cron", expr: cronExpr };
} else {
console.error(`${red("Error:")} Must specify --at, --every, or --cron`);
process.exit(1);
}
// Create job
const input: CronJobInput = {
name,
enabled: true,
deleteAfterRun,
schedule,
sessionTarget: isolated ? "isolated" : "main",
wakeMode: "now",
payload: {
kind: "system-event",
text: message,
},
};
const job = service.add(input);
console.log(`\n${green("Created job:")} ${job.name} ${dim(`(${job.id.slice(0, 8)})`)}`);
console.log(` ${cyan("Schedule:")} ${formatSchedule(job.schedule)}`);
if (job.state.nextRunAtMs) {
const nextRun = new Date(job.state.nextRunAtMs);
console.log(` ${cyan("Next run:")} ${nextRun.toLocaleString()}`);
}
console.log("");
}
async function cmdRun(args: string[]) {
const service = getCronService();
const jobId = args[0];
const force = args.includes("--force");
if (!jobId) {
console.error(`${red("Error:")} Job ID is required`);
console.error(`${dim("Usage:")} multica cron run <id> [--force]`);
process.exit(1);
}
// Find job by partial ID
const jobs = service.list();
const matches = jobs.filter((j) => j.id.startsWith(jobId));
if (matches.length === 0) {
console.error(`${red("Error:")} Job not found: ${jobId}`);
process.exit(1);
}
if (matches.length > 1) {
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
for (const j of matches) {
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
}
console.error("Please provide a more specific ID.");
process.exit(1);
}
const job = matches[0]!;
console.log(`Running job: ${job.name} (${job.id.slice(0, 8)})...`);
const result = await service.run(job.id, force);
if (result.ok) {
console.log(`${green("Success:")} Job executed`);
} else {
console.error(`${red("Error:")} ${result.reason}`);
process.exit(1);
}
}
function cmdEnableDisable(args: string[], enabled: boolean) {
const service = getCronService();
const jobId = args[0];
if (!jobId) {
console.error(`${red("Error:")} Job ID is required`);
process.exit(1);
}
// Find job by partial ID
const jobs = service.list();
const matches = jobs.filter((j) => j.id.startsWith(jobId));
if (matches.length === 0) {
console.error(`${red("Error:")} Job not found: ${jobId}`);
process.exit(1);
}
if (matches.length > 1) {
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
for (const j of matches) {
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
}
process.exit(1);
}
const job = matches[0]!;
service.update(job.id, { enabled });
const action = enabled ? "Enabled" : "Disabled";
console.log(`${green(action + ":")} ${job.name} (${job.id.slice(0, 8)})`);
}
function cmdRemove(args: string[]) {
const service = getCronService();
const jobId = args[0];
if (!jobId) {
console.error(`${red("Error:")} Job ID is required`);
process.exit(1);
}
// Find job by partial ID
const jobs = service.list();
const matches = jobs.filter((j) => j.id.startsWith(jobId));
if (matches.length === 0) {
console.error(`${red("Error:")} Job not found: ${jobId}`);
process.exit(1);
}
if (matches.length > 1) {
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
for (const j of matches) {
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
}
process.exit(1);
}
const job = matches[0]!;
service.remove(job.id);
console.log(`${green("Removed:")} ${job.name} (${job.id.slice(0, 8)})`);
}
function cmdLogs(args: string[]) {
const service = getCronService();
const jobId = args[0];
const limitArg = args.indexOf("--limit");
const limitStr = limitArg !== -1 ? args[limitArg + 1] : undefined;
const limit = limitStr ? parseInt(limitStr, 10) : 20;
if (!jobId) {
console.error(`${red("Error:")} Job ID is required`);
console.error(`${dim("Usage:")} multica cron logs <id> [--limit N]`);
process.exit(1);
}
// Find job by partial ID
const jobs = service.list();
const matches = jobs.filter((j) => j.id.startsWith(jobId));
if (matches.length === 0) {
console.error(`${red("Error:")} Job not found: ${jobId}`);
process.exit(1);
}
if (matches.length > 1) {
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
for (const j of matches) {
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
}
process.exit(1);
}
const job = matches[0]!;
const logs = service.getRunLogs(job.id, limit);
console.log(`\n${brightCyan("Run Logs:")} ${job.name} ${dim(`(${job.id.slice(0, 8)})`)}\n`);
if (logs.length === 0) {
console.log(dim("No run logs found."));
return;
}
for (const log of logs) {
const timestamp = new Date(log.ts).toLocaleString();
const statusColor = log.status === "ok" ? green : log.status === "error" ? red : yellow;
const duration = log.durationMs ? formatDuration(log.durationMs) : "-";
console.log(` ${dim(timestamp)} ${statusColor(`[${log.status}]`)} ${dim(`(${duration})`)}`);
if (log.error) {
console.log(` ${red("Error:")} ${log.error}`);
}
if (log.summary) {
console.log(` ${dim(log.summary)}`);
}
}
console.log(`\n${dim(`Showing ${logs.length} most recent entries`)}`);
}
export async function cronCommand(args: string[]): Promise<void> {
const command = (args[0] || "help") as Command;
const restArgs = args.slice(1);
if (args.includes("--help") || args.includes("-h")) {
printHelp();
return;
}
// Ensure service is started
const service = getCronService();
await service.start();
switch (command) {
case "status":
cmdStatus();
break;
case "list":
cmdList(restArgs);
break;
case "add":
cmdAdd(restArgs);
break;
case "run":
await cmdRun(restArgs);
break;
case "enable":
cmdEnableDisable(restArgs, true);
break;
case "disable":
cmdEnableDisable(restArgs, false);
break;
case "remove":
cmdRemove(restArgs);
break;
case "logs":
cmdLogs(restArgs);
break;
case "help":
default:
printHelp();
break;
}
}

View file

@ -22,7 +22,7 @@ ${cyan("Usage:")} multica session <command> [options]
${cyan("Commands:")}
${yellow("list")} List all sessions
${yellow("show")} <id> Show session details
${yellow("show")} <id> Show session details (use --show-internal to include internal messages)
${yellow("delete")} <id> Delete a session
${yellow("help")} Show this help
@ -122,7 +122,7 @@ function cmdList() {
console.log(`${dim("Resume with:")} multica --session <id>`);
}
function cmdShow(sessionId: string | undefined) {
function cmdShow(sessionId: string | undefined, showInternal = false) {
if (!sessionId) {
console.error("Error: Session ID is required");
console.error("Usage: multica session show <id>");
@ -160,14 +160,25 @@ function cmdShow(sessionId: string | undefined) {
console.log(cyan("─".repeat(60)));
console.log("");
// Parse and display messages
// Parse and display messages as SessionEntry objects
for (const line of lines) {
try {
const msg = JSON.parse(line);
const entry = JSON.parse(line);
// Only display message entries
if (entry.type !== "message") continue;
// Skip internal messages unless --show-internal
if (entry.internal && !showInternal) continue;
const msg = entry.message;
if (!msg) continue;
const role = msg.role || "unknown";
const roleColor = role === "user" ? green : role === "assistant" ? cyan : dim;
const internalTag = entry.internal ? dim(" [internal]") : "";
console.log(`${roleColor(`[${role}]`)}`);
console.log(`${roleColor(`[${role}]`)}${internalTag}`);
if (typeof msg.content === "string") {
// Truncate long content
@ -238,6 +249,7 @@ function cmdDelete(sessionId: string | undefined) {
export async function sessionCommand(args: string[]): Promise<void> {
const command = (args[0] || "help") as Command;
const arg1 = args[1];
const showInternal = args.includes("--show-internal");
if (args.includes("--help") || args.includes("-h")) {
printHelp();
@ -249,7 +261,7 @@ export async function sessionCommand(args: string[]): Promise<void> {
cmdList();
break;
case "show":
cmdShow(arg1);
cmdShow(arg1, showInternal);
break;
case "delete":
cmdDelete(arg1);

View file

@ -11,6 +11,7 @@
* multica skills <cmd> Skills management
* multica tools <cmd> Tool policy inspection
* multica credentials <cmd> Credentials management
* multica cron <cmd> Scheduled task management
* multica dev [service] Development servers
* multica help Show help
*/
@ -29,6 +30,7 @@ const subcommands: Record<string, () => Promise<SubcommandHandler>> = {
tools: async () => (await import("./commands/tools.js")).toolsCommand,
credentials: async () => (await import("./commands/credentials.js")).credentialsCommand,
dev: async () => (await import("./commands/dev.js")).devCommand,
cron: async () => (await import("./commands/cron.js")).cronCommand,
};
function printHelp() {
@ -44,6 +46,7 @@ ${cyan("Usage:")}
${yellow("multica skills")} <command> Manage skills
${yellow("multica tools")} <command> Inspect tool policies
${yellow("multica credentials")} <command> Manage credentials
${yellow("multica cron")} <command> Manage scheduled tasks
${yellow("multica dev")} [service] Start development servers
${yellow("multica help")} Show this help
@ -85,6 +88,16 @@ ${cyan("Commands:")}
show Show credential paths
edit Open credentials in editor
${green("cron")}
status Show cron service status
list List all scheduled jobs
add [options] Create a new scheduled job
run <id> Run a job immediately
enable <id> Enable a job
disable <id> Disable a job
remove <id> Delete a job
logs <id> Show job run logs
${green("dev")}
${dim("(default)")} Start all services (gateway + console + web)
gateway Start gateway only (:3000)

View file

@ -50,6 +50,7 @@ export function createAgentProfile(
profile.user = DEFAULT_TEMPLATES.user;
profile.workspace = DEFAULT_TEMPLATES.workspace;
profile.memory = DEFAULT_TEMPLATES.memory;
profile.heartbeat = DEFAULT_TEMPLATES.heartbeat;
// 保存到文件
saveProfile(profile, { baseDir });
@ -150,6 +151,7 @@ export class ProfileManager {
user: profile.user,
workspace: profile.workspace,
memory: profile.memory,
heartbeat: profile.heartbeat,
config: profile.config,
},
profileDir: this.getProfileDir(),
@ -168,6 +170,19 @@ export class ProfileManager {
return profile?.config;
}
/** Get heartbeat configuration from profile config */
getHeartbeatConfig():
| {
enabled?: boolean | undefined;
every?: string | undefined;
prompt?: string | undefined;
ackMaxChars?: number | undefined;
}
| undefined {
const profile = this.getProfile();
return profile?.config?.heartbeat;
}
/** 更新 tools 配置 */
updateToolsConfig(toolsConfig: ToolsConfig): void {
const profile = this.getOrCreateProfile(false);

View file

@ -95,13 +95,14 @@ export function loadProfile(profileId: string, options?: StorageOptions): AgentP
user: readProfileFile(profileId, PROFILE_FILES.user, options),
workspace: readProfileFile(profileId, PROFILE_FILES.workspace, options),
memory: readProfileFile(profileId, PROFILE_FILES.memory, options),
heartbeat: readProfileFile(profileId, PROFILE_FILES.heartbeat, options),
config: readProfileConfig(profileId, options),
};
}
/** 保存 AgentProfile只写入非空字段 */
export function saveProfile(profile: AgentProfile, options?: StorageOptions): void {
const { id, soul, user, workspace, memory, config } = profile;
const { id, soul, user, workspace, memory, heartbeat, config } = profile;
if (soul !== undefined) {
writeProfileFile(id, PROFILE_FILES.soul, soul, options);
@ -115,6 +116,9 @@ export function saveProfile(profile: AgentProfile, options?: StorageOptions): vo
if (memory !== undefined) {
writeProfileFile(id, PROFILE_FILES.memory, memory, options);
}
if (heartbeat !== undefined) {
writeProfileFile(id, PROFILE_FILES.heartbeat, heartbeat, options);
}
if (config !== undefined) {
writeProfileConfig(id, config, options);
}

View file

@ -72,6 +72,7 @@ Your profile directory contains these files (use \`edit\` or \`write\` to update
| \`user.md\` | About your human | As you learn about them |
| \`workspace.md\` | This file — your rules | When you discover better conventions |
| \`memory.md\` | Long-term knowledge | Regularly — capture what matters |
| \`heartbeat.md\` | Background check instructions | When heartbeat behavior should change |
## Every Session
@ -89,6 +90,7 @@ You wake up fresh each session. These files are your continuity:
- **Long-term:** \`MEMORY.md\` — your curated memories, lessons learned
- **Daily notes:** \`memory/YYYY-MM-DD.md\` — raw logs of what happened (optional)
- **Heartbeat:** \`heartbeat.md\` — periodic check loop instructions
Capture what matters. Decisions, context, things to remember.
@ -101,6 +103,7 @@ Capture what matters. Decisions, context, things to remember.
- \`memory.md\` — Your learnings: decisions made, lessons learned, important context
- \`workspace.md\` — Your rules: conventions, workflows, how you should operate
- \`soul.md\` — Your identity: only change if user wants to reshape who you are
- \`heartbeat.md\` — Periodic background checks and alert rules
**Rules:**
- **DO NOT** say "I'll remember that" without ACTUALLY calling \`edit\` or \`write\` on a file
@ -148,5 +151,11 @@ _(Persistent knowledge will be stored here. Update this as you learn.)_
## Lessons Learned
## Important Context
`,
heartbeat: `# heartbeat.md
# Keep this file empty (or with only comments) to skip heartbeat API calls.
# Add tasks below when you want the agent to check something periodically.
`,
} as const;

View file

@ -11,6 +11,7 @@ export const PROFILE_FILES = {
user: "user.md",
workspace: "workspace.md",
memory: "memory.md",
heartbeat: "heartbeat.md",
config: "config.json",
} as const;
@ -42,6 +43,17 @@ export interface ProfileConfig {
reasoningMode?: "off" | "on" | "stream" | undefined;
/** Exec approval configuration (security level, ask mode, allowlist) */
execApproval?: ExecApprovalConfig | undefined;
/** Heartbeat configuration */
heartbeat?: {
/** Global heartbeat enable switch */
enabled?: boolean | undefined;
/** Interval, e.g. "30m", "1h" */
every?: string | undefined;
/** Optional prompt override */
prompt?: string | undefined;
/** Max chars after HEARTBEAT_OK to still treat as ack */
ackMaxChars?: number | undefined;
} | undefined;
}
/** Agent Profile configuration */
@ -56,6 +68,8 @@ export interface AgentProfile {
workspace?: string | undefined;
/** Persistent memory - long-term knowledge base */
memory?: string | undefined;
/** Periodic heartbeat instructions */
heartbeat?: string | undefined;
/** Profile configuration (from config.json) */
config?: ProfileConfig | undefined;
}

View file

@ -1,5 +1,4 @@
import { Agent as PiAgentCore, type AgentEvent, type AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai";
import { v7 as uuidv7 } from "uuid";
import type { AgentOptions, AgentRunResult, ReasoningMode } from "./types.js";
import type { MulticaEvent } from "./events.js";
@ -85,6 +84,10 @@ export class Agent {
private readonly stderr: NodeJS.WritableStream;
private initialized = false;
// Internal run state
private _internalRun = false;
private _runMutex: Promise<void> = Promise.resolve();
// MulticaEvent subscribers (parallel to PiAgentCore's subscriber list)
// Typed as AgentEvent | MulticaEvent to match subscribeAll() callback signature
private multicaListeners: Array<(event: AgentEvent | MulticaEvent) => void> = [];
@ -353,7 +356,49 @@ export class Agent {
}
}
async run(prompt: string, images?: ImageContent[]): Promise<AgentRunResult> {
async run(prompt: string): Promise<AgentRunResult> {
// Run-level mutex: prevents concurrent run/runInternal from mis-tagging messages
return this.withRunMutex(() => this._run(prompt));
}
/**
* Run a prompt as an internal turn.
* Messages are persisted with `internal: true` and rolled back from
* in-memory state after the turn completes, so they do not pollute
* the main conversation context.
*/
async runInternal(prompt: string): Promise<AgentRunResult> {
return this.withRunMutex(async () => {
const messageCountBefore = this.agent.state.messages.length;
this._internalRun = true;
try {
const result = await this._run(prompt);
return result;
} finally {
this._internalRun = false;
// Roll back internal messages from in-memory state
const current = this.agent.state.messages;
if (current.length > messageCountBefore) {
this.agent.replaceMessages(current.slice(0, messageCountBefore));
}
}
});
}
private async withRunMutex<T>(fn: () => Promise<T>): Promise<T> {
// Chain on the mutex so only one run executes at a time
const prev = this._runMutex;
let resolve: () => void;
this._runMutex = new Promise<void>((r) => { resolve = r; });
await prev;
try {
return await fn();
} finally {
resolve!();
}
}
private async _run(prompt: string): Promise<AgentRunResult> {
await this.ensureInitialized();
this.output.state.lastAssistantText = "";
@ -363,7 +408,7 @@ export class Agent {
// Loop to exhaust all candidate profiles on rotatable errors
while (true) {
try {
await this.agent.prompt(prompt, images);
await this.agent.prompt(prompt);
break; // success — exit loop
} catch (error) {
lastError = error;
@ -443,8 +488,10 @@ export class Agent {
private handleSessionEvent(event: AgentEvent) {
if (event.type === "message_end") {
const message = event.message as AgentMessage;
this.session.saveMessage(message);
if (message.role === "assistant") {
this.session.saveMessage(message, this._internalRun ? { internal: true } : undefined);
// Skip compaction during internal runs — internal messages will be
// rolled back from memory afterwards, so compacting now would be incorrect.
if (message.role === "assistant" && !this._internalRun) {
void this.maybeCompact();
}
}
@ -511,6 +558,40 @@ export class Agent {
return this.agent.state.tools?.map(t => t.name) ?? [];
}
/** Whether the agent is currently executing an internal run */
get isInternalRun(): boolean {
return this._internalRun;
}
/**
* Persist a synthetic assistant message into both in-memory state and session JSONL.
* Used after an internal run to keep the LLM summary visible in future turns
* while the internal prompt stays hidden.
*/
persistAssistantSummary(text: string): void {
const model = this.agent.state.model;
const message = {
role: "assistant" as const,
content: [{ type: "text" as const, text }],
api: model?.api ?? "openai-completions",
provider: model?.provider ?? "internal",
model: model?.id ?? "unknown",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop" as const,
timestamp: Date.now(),
};
this.agent.appendMessage(message);
this.session.saveMessage(message);
}
/** Ensure session messages are loaded from disk (idempotent) */
async ensureInitialized(): Promise<void> {
if (this.initialized) return;
@ -522,11 +603,19 @@ export class Agent {
this.initialized = true;
}
/** Get all messages from the current session */
/** Get all messages from the current session (in-memory state) */
getMessages(): AgentMessage[] {
return this.agent.state.messages.slice();
}
/**
* Load messages from session storage with filtering.
* By default, internal messages are excluded.
*/
loadSessionMessages(options?: { includeInternal?: boolean }): AgentMessage[] {
return this.session.loadMessages(options);
}
/**
* Get all skills with their eligibility status.
* Returns empty array if skills are disabled.
@ -596,6 +685,27 @@ export class Agent {
return this.profile?.getProfile()?.id;
}
/**
* Get profile directory path, if profile is enabled.
*/
getProfileDir(): string | undefined {
return this.profile?.getProfileDir();
}
/**
* Get heartbeat configuration from profile config.
*/
getHeartbeatConfig():
| {
enabled?: boolean | undefined;
every?: string | undefined;
prompt?: string | undefined;
ackMaxChars?: number | undefined;
}
| undefined {
return this.profile?.getHeartbeatConfig();
}
/**
* Get agent display name from profile config.
*/
@ -771,6 +881,7 @@ export class Agent {
user: profile.user,
workspace: profile.workspace,
memory: profile.memory,
heartbeat: profile.heartbeat,
config: profile.config,
},
profileDir: this.profile!.getProfileDir(),

View file

@ -167,11 +167,15 @@ export class SessionManager {
return repairSessionFileIfNeeded({ sessionFile: filePath, warn });
}
loadMessages(): AgentMessage[] {
loadMessages(options?: { includeInternal?: boolean }): AgentMessage[] {
const entries = this.loadEntries();
let messages = entries
.filter((entry) => entry.type === "message")
.map((entry) => entry.message);
.filter((entry) => {
if (entry.type !== "message") return false;
if (!options?.includeInternal && entry.internal) return false;
return true;
})
.map((entry) => (entry as { type: "message"; message: AgentMessage }).message);
messages = sanitizeToolCallInputs(messages);
messages = sanitizeToolUseResultPairing(messages);
return messages;
@ -203,11 +207,16 @@ export class SessionManager {
);
}
saveMessage(message: AgentMessage) {
saveMessage(message: AgentMessage, options?: { internal?: boolean }) {
void this.enqueue(() =>
appendEntry(
this.sessionId,
{ type: "message", message, timestamp: Date.now() },
{
type: "message",
message,
timestamp: Date.now(),
...(options?.internal ? { internal: true } : {}),
},
{ baseDir: this.baseDir },
),
);

View file

@ -11,7 +11,7 @@ export type SessionMeta = {
};
export type SessionEntry =
| { type: "message"; message: AgentMessage; timestamp: number }
| { type: "message"; message: AgentMessage; timestamp: number; internal?: boolean }
| { type: "meta"; meta: SessionMeta; timestamp: number }
| {
type: "compaction";

View file

@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const readEntriesMock = vi.fn();
vi.mock("../session/storage.js", () => ({
readEntries: (sessionId: string) => readEntriesMock(sessionId),
}));
import { readLatestAssistantReply } from "./announce.js";
describe("readLatestAssistantReply", () => {
beforeEach(() => {
readEntriesMock.mockReset();
});
it("returns the latest non-empty assistant text when the last assistant message is tool-only", () => {
readEntriesMock.mockReturnValue([
{
type: "message",
timestamp: 1,
message: {
role: "assistant",
content: [{ type: "text", text: "南京天气12°C。" }],
},
},
{
type: "message",
timestamp: 2,
message: {
role: "assistant",
content: [{ type: "toolCall", id: "tool-1", name: "weather", arguments: { city: "Nanjing" } }],
},
},
]);
const result = readLatestAssistantReply("child-session");
expect(result).toBe("南京天气12°C。");
});
it("falls back to latest toolResult text when no assistant text exists", () => {
readEntriesMock.mockReturnValue([
{
type: "message",
timestamp: 1,
message: {
role: "assistant",
content: [{ type: "toolCall", id: "tool-2", name: "weather", arguments: { city: "Nanjing" } }],
},
},
{
type: "message",
timestamp: 2,
message: {
role: "toolResult",
toolCallId: "tool-2",
toolName: "weather",
content: [{ type: "text", text: "{\"city\":\"Nanjing\",\"tempC\":12,\"condition\":\"Sunny\"}" }],
isError: false,
},
},
]);
const result = readLatestAssistantReply("child-session");
expect(result).toContain("\"city\":\"Nanjing\"");
expect(result).toContain("\"condition\":\"Sunny\"");
});
});

View file

@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { buildSubagentSystemPrompt, formatAnnouncementMessage } from "./announce.js";
import { buildSubagentSystemPrompt, formatAnnouncementMessage, formatCoalescedAnnouncementMessage } from "./announce.js";
import type { FormatAnnouncementParams } from "./announce.js";
import type { SubagentRunRecord } from "./types.js";
describe("buildSubagentSystemPrompt", () => {
it("includes task and session context", () => {
@ -126,3 +127,128 @@ describe("formatAnnouncementMessage", () => {
expect(msg).toContain("NO_REPLY");
});
});
describe("formatCoalescedAnnouncementMessage", () => {
function makeRecord(overrides: Partial<SubagentRunRecord> = {}): SubagentRunRecord {
return {
runId: "run-1",
childSessionId: "child-1",
requesterSessionId: "parent-1",
task: "Default task",
cleanup: "delete",
createdAt: 1000000,
startedAt: 1000000,
endedAt: 1030000,
outcome: { status: "ok" },
findings: "Some findings",
findingsCaptured: true,
announced: false,
...overrides,
};
}
it("delegates to formatAnnouncementMessage for a single record", () => {
const record = makeRecord({ label: "Code Analysis" });
const coalesced = formatCoalescedAnnouncementMessage([record]);
const direct = formatAnnouncementMessage({
runId: record.runId,
childSessionId: record.childSessionId,
requesterSessionId: record.requesterSessionId,
task: record.task,
label: record.label,
cleanup: record.cleanup,
outcome: record.outcome,
startedAt: record.startedAt,
endedAt: record.endedAt,
findings: record.findings,
});
expect(coalesced).toBe(direct);
});
it("formats multiple records with all task findings and stats", () => {
const records = [
makeRecord({
runId: "run-1",
childSessionId: "child-1",
label: "Task A",
findings: "Found issue A",
startedAt: 1000000,
endedAt: 1030000,
}),
makeRecord({
runId: "run-2",
childSessionId: "child-2",
label: "Task B",
findings: "Found issue B",
startedAt: 1000000,
endedAt: 1045000, // 45 seconds
}),
];
const msg = formatCoalescedAnnouncementMessage(records);
expect(msg).toContain("All 2 background tasks have completed");
expect(msg).toContain('Task 1: "Task A"');
expect(msg).toContain("Found issue A");
expect(msg).toContain('Task 2: "Task B"');
expect(msg).toContain("Found issue B");
expect(msg).toContain("Total wall time: 45s");
expect(msg).toContain("2 succeeded, 0 failed");
});
it("reports mixed outcomes correctly", () => {
const records = [
makeRecord({ runId: "run-1", label: "OK Task", outcome: { status: "ok" } }),
makeRecord({ runId: "run-2", label: "Failed Task", outcome: { status: "error", error: "crash" } }),
makeRecord({ runId: "run-3", label: "Timeout Task", outcome: { status: "timeout" } }),
];
const msg = formatCoalescedAnnouncementMessage(records);
expect(msg).toContain("completed successfully");
expect(msg).toContain("failed: crash");
expect(msg).toContain("timed out");
expect(msg).toContain("1 succeeded, 2 failed");
});
it("shows (no output) for missing findings", () => {
const records = [
makeRecord({ runId: "run-1", findings: undefined }),
makeRecord({ runId: "run-2", findings: "Has output" }),
];
const msg = formatCoalescedAnnouncementMessage(records);
expect(msg).toContain("(no output)");
expect(msg).toContain("Has output");
});
it("includes combined summary instruction for multi-record", () => {
const records = [
makeRecord({ runId: "run-1" }),
makeRecord({ runId: "run-2" }),
];
const msg = formatCoalescedAnnouncementMessage(records);
expect(msg).toContain("MUST include findings from every task item above");
expect(msg).toContain("NO_REPLY");
});
it("includes raw findings for every task in coalesced payload", () => {
const records = [
makeRecord({ runId: "run-1", label: "南京天气", findings: "南京12°C" }),
makeRecord({ runId: "run-2", label: "上海天气", findings: "上海多云9°C" }),
];
const msg = formatCoalescedAnnouncementMessage(records);
expect(msg).toContain("Raw findings from each task (MUST cover all items):");
expect(msg).toContain("[1] 南京天气:");
expect(msg).toContain("南京12°C");
expect(msg).toContain("[2] 上海天气:");
expect(msg).toContain("上海多云9°C");
expect(msg).toContain("MUST include findings from every task item above");
});
});

View file

@ -13,6 +13,7 @@ import { buildSystemPrompt } from "../system-prompt/index.js";
import type {
SubagentAnnounceParams,
SubagentRunOutcome,
SubagentRunRecord,
SubagentSystemPromptParams,
} from "./types.js";
@ -38,19 +39,29 @@ export function buildSubagentSystemPrompt(params: SubagentSystemPromptParams): s
*/
export function readLatestAssistantReply(sessionId: string): string | undefined {
const entries = readEntries(sessionId);
let latestToolResultText: string | undefined;
// Walk backwards to find last assistant message
// Walk backwards to find the last non-empty assistant reply.
// If no assistant text exists (e.g. run ended after tool execution),
// fall back to the latest non-empty toolResult content.
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i]!;
if (entry.type !== "message") continue;
const message = entry.message;
if (message.role !== "assistant") continue;
if (message.role === "assistant") {
const text = extractAssistantText(message);
if (text) return text;
continue;
}
return extractAssistantText(message);
if (message.role === "toolResult" && !latestToolResultText) {
const text = extractToolResultText(message);
if (text) latestToolResultText = text;
}
}
return undefined;
return latestToolResultText;
}
/**
@ -58,7 +69,17 @@ export function readLatestAssistantReply(sessionId: string): string | undefined
* AgentMessage.content for assistant is (TextContent | ThinkingContent | ToolCall)[].
*/
function extractAssistantText(message: { role: string; content: unknown }): string {
const content = message.content;
return extractTextLikeContent(message.content);
}
/**
* Extract text content from a toolResult message.
*/
function extractToolResultText(message: { role: string; content: unknown }): string {
return extractTextLikeContent(message.content);
}
function extractTextLikeContent(content: unknown): string {
if (typeof content === "string") {
return sanitizeText(content);
}
@ -67,8 +88,9 @@ function extractAssistantText(message: { role: string; content: unknown }): stri
const textParts: string[] = [];
for (const block of content) {
if (block && typeof block === "object" && "type" in block && block.type === "text" && "text" in block) {
textParts.push(String(block.text));
if (!block || typeof block !== "object") continue;
if ("text" in block) {
textParts.push(String((block as { text: unknown }).text));
}
}
@ -167,11 +189,126 @@ export function formatAnnouncementMessage(params: FormatAnnouncementParams): str
return parts.join("\n");
}
/**
* Format a coalesced announcement message from multiple completed subagent runs.
* When only one record is provided, delegates to formatAnnouncementMessage.
*/
export function formatCoalescedAnnouncementMessage(
records: SubagentRunRecord[],
): string {
// Single record: delegate to existing format for backward-compatible behavior
if (records.length === 1) {
const r = records[0]!;
return formatAnnouncementMessage({
runId: r.runId,
childSessionId: r.childSessionId,
requesterSessionId: r.requesterSessionId,
task: r.task,
label: r.label,
cleanup: r.cleanup,
outcome: r.outcome,
startedAt: r.startedAt,
endedAt: r.endedAt,
findings: r.findings,
});
}
// Multiple records: build combined message.
// Include a strict raw-findings section so parent can reliably cover every task result.
const parts: string[] = [
`All ${records.length} background tasks have completed. Here are the combined results:`,
"",
];
for (let i = 0; i < records.length; i++) {
const r = records[i]!;
const displayName = r.label || r.task.slice(0, 60);
const statusLabel = formatStatusLabel(r.outcome);
const durationStr = (r.startedAt && r.endedAt)
? ` (${formatDuration(r.startedAt, r.endedAt)})`
: "";
parts.push(
`### Task ${i + 1}: "${displayName}"`,
`Status: ${statusLabel}${durationStr}`,
"",
"Findings:",
r.findings || "(no output)",
"",
);
}
// Overall stats
const allStartTimes = records.map(r => r.startedAt).filter(Boolean) as number[];
const allEndTimes = records.map(r => r.endedAt).filter(Boolean) as number[];
if (allStartTimes.length > 0 && allEndTimes.length > 0) {
const wallTime = formatDuration(Math.min(...allStartTimes), Math.max(...allEndTimes));
parts.push(`Total wall time: ${wallTime}`);
}
const okCount = records.filter(r => r.outcome?.status === "ok").length;
const failCount = records.length - okCount;
parts.push(`Results: ${okCount} succeeded, ${failCount} failed/timed out`);
parts.push("", "Raw findings from each task (MUST cover all items):", "");
for (let i = 0; i < records.length; i++) {
const r = records[i]!;
const displayName = r.label || r.task.slice(0, 60);
parts.push(
`[${i + 1}] ${displayName}:`,
r.findings || "(no output)",
"",
);
}
parts.push(
"",
"Summarize these results naturally for the user.",
"You MUST include findings from every task item above, without omission.",
"Keep it concise, but preserve concrete findings from each task.",
"Do not mention technical details like session IDs or that these were background tasks.",
"You can respond with NO_REPLY if no announcement is needed.",
);
return parts.join("\n");
}
/**
* Run the coalesced announcement flow for all completed runs of a requester.
* Formats a single combined message and delivers it to the parent agent.
*/
export function runCoalescedAnnounceFlow(
requesterSessionId: string,
records: SubagentRunRecord[],
): boolean {
const message = formatCoalescedAnnouncementMessage(records);
try {
const hub = getHub();
const parentAgent = hub.getAgent(requesterSessionId);
if (!parentAgent || parentAgent.closed) {
console.warn(
`[SubagentAnnounce] Parent agent not found or closed: ${requesterSessionId}`,
);
return false;
}
parentAgent.writeInternal(message, { forwardAssistant: true, persistResponse: true });
return true;
} catch (err) {
console.error(`[SubagentAnnounce] Failed to coalesced-announce to parent:`, err);
return false;
}
}
/**
* Run the full subagent announcement flow:
* 1. Read child's last assistant reply
* 2. Format announcement message
* 3. Send to parent agent via Hub
*
* @deprecated Use runCoalescedAnnounceFlow instead, which supports
* batching multiple completed runs into a single announcement.
*/
export function runSubagentAnnounceFlow(params: SubagentAnnounceParams): boolean {
const { requesterSessionId, childSessionId } = params;
@ -204,7 +341,7 @@ export function runSubagentAnnounceFlow(params: SubagentAnnounceParams): boolean
return false;
}
parentAgent.write(message);
parentAgent.writeInternal(message, { forwardAssistant: true, persistResponse: true });
return true;
} catch (err) {
console.error(`[SubagentAnnounce] Failed to announce to parent:`, err);

View file

@ -28,6 +28,8 @@ export {
readLatestAssistantReply,
formatAnnouncementMessage,
runSubagentAnnounceFlow,
formatCoalescedAnnouncementMessage,
runCoalescedAnnounceFlow,
} from "./announce.js";
export type { FormatAnnouncementParams } from "./announce.js";

View file

@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SubagentRunRecord } from "./types.js";
const loadSubagentRunsMock = vi.fn<() => Map<string, SubagentRunRecord>>();
const saveSubagentRunsMock = vi.fn();
const readLatestAssistantReplyMock = vi.fn();
const runCoalescedAnnounceFlowMock = vi.fn(() => false);
const resolveSessionDirMock = vi.fn((sessionId: string) => `/tmp/${sessionId}`);
const closeAgentMock = vi.fn();
const getHubMock = vi.fn(() => ({ closeAgent: closeAgentMock }));
const rmSyncMock = vi.fn();
vi.mock("./registry-store.js", () => ({
loadSubagentRuns: loadSubagentRunsMock,
saveSubagentRuns: saveSubagentRunsMock,
}));
vi.mock("./announce.js", () => ({
readLatestAssistantReply: readLatestAssistantReplyMock,
runCoalescedAnnounceFlow: runCoalescedAnnounceFlowMock,
}));
vi.mock("../session/storage.js", () => ({
resolveSessionDir: resolveSessionDirMock,
}));
vi.mock("../../hub/hub-singleton.js", () => ({
getHub: getHubMock,
isHubInitialized: vi.fn(() => false),
}));
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
rmSync: rmSyncMock,
};
});
describe("subagent registry recovery cleanup", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
loadSubagentRunsMock.mockReturnValue(new Map());
runCoalescedAnnounceFlowMock.mockReturnValue(false);
});
it("deletes child session on recovery even when findings were already captured", async () => {
const now = Date.now();
const record: SubagentRunRecord = {
runId: "run-1",
childSessionId: "child-1",
requesterSessionId: "parent-1",
task: "task",
cleanup: "delete",
createdAt: now - 1000,
startedAt: now - 900,
endedAt: now - 100,
outcome: { status: "ok" },
findings: "done",
findingsCaptured: true,
cleanupHandled: false,
announced: false,
};
loadSubagentRunsMock.mockReturnValue(new Map([["run-1", record]]));
const registry = await import("./registry.js");
registry.initSubagentRegistry();
expect(readLatestAssistantReplyMock).not.toHaveBeenCalled();
expect(resolveSessionDirMock).toHaveBeenCalledWith("child-1");
expect(rmSyncMock).toHaveBeenCalledWith("/tmp/child-1", { recursive: true, force: true });
});
});

View file

@ -78,4 +78,50 @@ describe("registry-store serialization", () => {
expect(parsed.outcome?.status).toBe("error");
expect(parsed.outcome?.error).toBe("Something went wrong");
});
it("round-trips coalescing fields (findings, findingsCaptured, announced)", () => {
const record: SubagentRunRecord = {
runId: "run-coalesce",
childSessionId: "child-1",
requesterSessionId: "parent-1",
task: "Coalesce test",
cleanup: "delete",
createdAt: Date.now(),
endedAt: Date.now() + 5000,
outcome: { status: "ok" },
findings: "Found 3 issues in auth module.",
findingsCaptured: true,
announced: true,
};
const json = JSON.stringify({ version: 1, runs: { "run-coalesce": record } });
const parsed = JSON.parse(json);
const restored = parsed.runs["run-coalesce"] as SubagentRunRecord;
expect(restored.findings).toBe("Found 3 issues in auth module.");
expect(restored.findingsCaptured).toBe(true);
expect(restored.announced).toBe(true);
});
it("round-trips record with undefined coalescing fields", () => {
const record: SubagentRunRecord = {
runId: "run-old",
childSessionId: "child-1",
requesterSessionId: "parent-1",
task: "Old record",
cleanup: "delete",
createdAt: Date.now(),
cleanupHandled: true,
// No findings, findingsCaptured, or announced fields (old format)
};
const json = JSON.stringify({ version: 1, runs: { "run-old": record } });
const parsed = JSON.parse(json);
const restored = parsed.runs["run-old"] as SubagentRunRecord;
expect(restored.findings).toBeUndefined();
expect(restored.findingsCaptured).toBeUndefined();
expect(restored.announced).toBeUndefined();
expect(restored.cleanupHandled).toBe(true);
});
});

View file

@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from "vitest";
import { describe, it, expect, beforeEach, vi } from "vitest";
import {
registerSubagentRun,
listSubagentRuns,
@ -159,3 +159,118 @@ describe("subagent registry", () => {
expect(getSubagentRun("run-1")).toBeUndefined();
});
});
describe("subagent registry — coalescing", () => {
// Without Hub, watchChildAgent ends runs immediately with "Hub not initialized".
// This allows us to test the coalescing state transitions.
it("captures findings when a run completes (no Hub)", () => {
registerSubagentRun({
runId: "run-1",
childSessionId: "child-1",
requesterSessionId: "parent-1",
task: "Task 1",
});
const record = getSubagentRun("run-1");
// Run ended immediately due to no Hub
expect(record?.endedAt).toBeGreaterThan(0);
expect(record?.findingsCaptured).toBe(true);
});
it("does not announce while sibling runs are still pending", () => {
// Register first run — ends immediately (no Hub)
registerSubagentRun({
runId: "run-1",
childSessionId: "child-1",
requesterSessionId: "parent-1",
task: "Task 1",
});
const record1 = getSubagentRun("run-1");
expect(record1?.findingsCaptured).toBe(true);
// Register second run — also ends immediately
registerSubagentRun({
runId: "run-2",
childSessionId: "child-2",
requesterSessionId: "parent-1",
task: "Task 2",
});
const record2 = getSubagentRun("run-2");
expect(record2?.findingsCaptured).toBe(true);
// Both ended, but announce fails because no Hub for parent agent.
// The key check: both records should have findings captured.
// announced will be false because runCoalescedAnnounceFlow fails (no Hub).
expect(record1?.announced).toBeUndefined();
expect(record2?.announced).toBeUndefined();
});
it("single run captures findings immediately", () => {
registerSubagentRun({
runId: "run-solo",
childSessionId: "child-solo",
requesterSessionId: "parent-solo",
task: "Solo task",
});
const record = getSubagentRun("run-solo");
expect(record?.endedAt).toBeGreaterThan(0);
expect(record?.findingsCaptured).toBe(true);
expect(record?.outcome?.status).toBe("error");
expect(record?.outcome?.error).toContain("Hub not initialized");
});
it("shutdownSubagentRegistry captures findings for ended-but-uncaptured runs", () => {
registerSubagentRun({
runId: "run-1",
childSessionId: "child-1",
requesterSessionId: "parent-1",
task: "Task",
});
const record = getSubagentRun("run-1");
if (record) {
// Simulate: run ended but findings not yet captured
record.endedAt = Date.now();
record.outcome = { status: "ok" };
record.findingsCaptured = undefined;
}
shutdownSubagentRegistry();
expect(record?.findingsCaptured).toBe(true);
});
});
describe("subagent registry — post-announce cleanup", () => {
it("removes runs from registry after successful announcement", async () => {
// Mock runCoalescedAnnounceFlow to succeed
const announceModule = await import("./announce.js");
const spy = vi.spyOn(announceModule, "runCoalescedAnnounceFlow").mockReturnValue(true);
// Register two runs for the same parent — both end immediately (no Hub)
registerSubagentRun({
runId: "run-a",
childSessionId: "child-a",
requesterSessionId: "parent-1",
task: "Task A",
});
registerSubagentRun({
runId: "run-b",
childSessionId: "child-b",
requesterSessionId: "parent-1",
task: "Task B",
});
// Both runs should have been announced and removed from registry
expect(spy).toHaveBeenCalled();
expect(getSubagentRun("run-a")).toBeUndefined();
expect(getSubagentRun("run-b")).toBeUndefined();
expect(listSubagentRuns("parent-1")).toHaveLength(0);
spy.mockRestore();
});
});

View file

@ -7,7 +7,7 @@
import { getHub, isHubInitialized } from "../../hub/hub-singleton.js";
import { loadSubagentRuns, saveSubagentRuns } from "./registry-store.js";
import { runSubagentAnnounceFlow } from "./announce.js";
import { readLatestAssistantReply, runCoalescedAnnounceFlow } from "./announce.js";
import type {
RegisterSubagentRunParams,
SubagentRunRecord,
@ -27,7 +27,7 @@ const SWEEP_INTERVAL_MS = 60 * 1000;
const subagentRuns = new Map<string, SubagentRunRecord>();
let sweepTimer: ReturnType<typeof setInterval> | undefined;
const resumedRuns = new Set<string>();
const resumedRequesters = new Set<string>();
// ============================================================================
// Public API
@ -39,25 +39,45 @@ export function initSubagentRegistry(): void {
for (const [runId, record] of persisted) {
subagentRuns.set(runId, record);
// Resume incomplete runs
if (!record.cleanupHandled) {
if (record.endedAt) {
// Completed but cleanup not done — run announce flow
if (!resumedRuns.has(runId)) {
resumedRuns.add(runId);
handleRunCompletion(record);
}
} else {
// If not ended, the child agent session is lost on restart —
// mark as ended with unknown outcome
record.endedAt = Date.now();
record.outcome = { status: "unknown" };
persist();
if (!resumedRuns.has(runId)) {
resumedRuns.add(runId);
handleRunCompletion(record);
}
}
// Backward compat: old records with cleanupHandled but no announced field
if (record.cleanupHandled && record.announced === undefined) {
record.announced = true;
record.findingsCaptured = true;
}
}
// Process incomplete runs
const affectedRequesters = new Set<string>();
for (const record of subagentRuns.values()) {
if (record.announced && record.cleanupHandled) continue; // Already fully done
if (!record.endedAt) {
// Child was running when process crashed — mark as ended/unknown
record.endedAt = Date.now();
record.outcome = { status: "unknown" };
}
if (!record.findingsCaptured) {
captureFindings(record);
}
// Recovery cleanup must be independent from findings capture:
// the process may crash after captureFindings() persisted but before deletion.
if (record.cleanup === "delete" && !record.cleanupHandled) {
deleteChildSession(record.childSessionId);
}
affectedRequesters.add(record.requesterSessionId);
}
persist();
// For each affected requester, check if coalesced announcement is needed
for (const requesterId of affectedRequesters) {
if (!resumedRequesters.has(requesterId)) {
resumedRequesters.add(requesterId);
checkAndAnnounce(requesterId);
}
}
@ -138,11 +158,17 @@ export function shutdownSubagentRegistry(): void {
record.outcome = { status: "unknown" };
updated++;
}
// Opportunistically capture findings for ended-but-uncaptured runs
if (record.endedAt && !record.findingsCaptured) {
captureFindings(record);
updated++;
}
}
if (updated > 0) {
persist();
console.log(`[SubagentRegistry] Marked ${updated} active run(s) as ended during shutdown`);
console.log(`[SubagentRegistry] Processed ${updated} run(s) during shutdown`);
}
stopSweeper();
@ -151,7 +177,7 @@ export function shutdownSubagentRegistry(): void {
/** Reset all state (for testing). */
export function resetSubagentRegistryForTests(): void {
subagentRuns.clear();
resumedRuns.clear();
resumedRequesters.clear();
stopSweeper();
}
@ -222,44 +248,76 @@ function watchChildAgent(record: SubagentRunRecord, timeoutSeconds?: number): vo
}
// ============================================================================
// Cleanup + Announce
// Cleanup + Announce (two-phase: capture findings, then coalesced announce)
// ============================================================================
function handleRunCompletion(record: SubagentRunRecord): void {
if (record.cleanupHandled) return;
record.cleanupHandled = true;
/** Phase 1: Capture child's findings before session deletion. */
function captureFindings(record: SubagentRunRecord): void {
try {
const findings = readLatestAssistantReply(record.childSessionId);
record.findings = findings ?? undefined;
} catch {
record.findings = "(failed to read findings)";
}
record.findingsCaptured = true;
persist();
}
// Run announce flow
const announced = runSubagentAnnounceFlow({
runId: record.runId,
childSessionId: record.childSessionId,
requesterSessionId: record.requesterSessionId,
task: record.task,
label: record.label,
cleanup: record.cleanup,
outcome: record.outcome,
startedAt: record.startedAt,
endedAt: record.endedAt,
});
/**
* Phase 2: Check if all unannounced runs for this requester have completed.
* If so, send a single coalesced announcement to the parent.
*/
function checkAndAnnounce(requesterSessionId: string): void {
const allRuns = listSubagentRuns(requesterSessionId);
if (!announced) {
console.warn(`[SubagentRegistry] Announce flow failed for run ${record.runId}`);
// Allow retry on next restart if announce failed.
record.cleanupHandled = false;
// Only consider unannounced runs
const pending = allRuns.filter(r => !r.announced);
if (pending.length === 0) return;
// Are all unannounced runs done?
const allDone = pending.every(r => r.endedAt !== undefined);
if (!allDone) return;
// Have all had findings captured?
const allCaptured = pending.every(r => r.findingsCaptured);
if (!allCaptured) return;
// All done — send coalesced announcement
const announced = runCoalescedAnnounceFlow(requesterSessionId, pending);
if (announced) {
for (const r of pending) {
r.announced = true;
r.cleanupHandled = true;
// Remove from registry immediately — findings already delivered to parent
subagentRuns.delete(r.runId);
}
persist();
return;
if (subagentRuns.size === 0) {
stopSweeper();
}
} else {
console.warn(
`[SubagentRegistry] Coalesced announce failed for requester ${requesterSessionId}`,
);
// Leave announced=false so initSubagentRegistry() can retry on restart
}
}
/** Entry point: called when a child completes. */
function handleRunCompletion(record: SubagentRunRecord): void {
// Phase 1: capture findings (before session deletion)
if (!record.findingsCaptured) {
captureFindings(record);
// Session cleanup (safe now that findings are persisted)
if (record.cleanup === "delete") {
deleteChildSession(record.childSessionId);
}
}
// Handle session cleanup
if (record.cleanup === "delete") {
deleteChildSession(record.childSessionId);
}
// Schedule archive
record.archiveAtMs = Date.now() + DEFAULT_ARCHIVE_AFTER_MS;
record.cleanupCompletedAt = Date.now();
persist();
// Phase 2: coalesced announce check
checkAndAnnounce(record.requesterSessionId);
}
function deleteChildSession(sessionId: string): void {
@ -305,7 +363,6 @@ function sweep(): void {
for (const [runId, record] of subagentRuns) {
if (record.archiveAtMs !== undefined && record.archiveAtMs <= now) {
subagentRuns.delete(runId);
resumedRuns.delete(runId);
removed++;
}
}

View file

@ -39,6 +39,12 @@ export type SubagentRunRecord = {
cleanupHandled?: boolean | undefined;
/** Timestamp when cleanup completed */
cleanupCompletedAt?: number | undefined;
/** Captured findings from the child session's last assistant reply */
findings?: string | undefined;
/** Whether findings have been captured (safe to delete session after this) */
findingsCaptured?: boolean | undefined;
/** Whether the coalesced announcement has been sent to parent */
announced?: boolean | undefined;
};
/** Parameters for registering a new subagent run */

View file

@ -10,6 +10,7 @@ import type {
SystemPromptReport,
} from "./types.js";
import {
buildHeartbeatSection,
buildConditionalToolSections,
buildExtraPromptSection,
buildIdentitySection,
@ -58,6 +59,7 @@ export function buildSystemPromptWithReport(options: SystemPromptOptions): {
{ name: "user", lines: buildUserSection(profile, mode) },
{ name: "workspace", lines: buildWorkspaceSection(profile, mode, profileDir) },
{ name: "memory", lines: buildMemoryFileSection(profile, mode) },
{ name: "heartbeat", lines: buildHeartbeatSection(profile, mode) },
{ name: "safety", lines: buildSafetySection(includeSafety) },
{ name: "tooling", lines: buildToolingSummary(tools, mode) },
{ name: "tool-call-style", lines: buildToolCallStyleSection(mode) },

View file

@ -6,6 +6,7 @@
import { SAFETY_CONSTITUTION } from "./constitution.js";
import { formatRuntimeLine } from "./runtime-info.js";
import { resolveHeartbeatPrompt } from "../../heartbeat/heartbeat-text.js";
import type {
ProfileContent,
RuntimeInfo,
@ -97,13 +98,14 @@ export function buildWorkspaceSection(
"## Profile",
"",
`Your profile directory: \`${profileDir}\``,
"Use this as the base path for profile files (soul.md, user.md, memory.md, memory/*.md).",
"Use this as the base path for profile files (soul.md, user.md, memory.md, heartbeat.md, memory/*.md).",
"",
"Profile files:",
"- `soul.md` — Your identity and values",
"- `user.md` — Information about your user",
"- `workspace.md` — Guidelines and conventions (below)",
"- `memory.md` — Persistent knowledge",
"- `heartbeat.md` — Background heartbeat loop instructions",
"",
);
}
@ -128,6 +130,26 @@ export function buildMemoryFileSection(
return [];
}
/**
* Heartbeat section full mode only.
* Keeps heartbeat protocol explicit in the agent instructions.
*/
export function buildHeartbeatSection(
profile: ProfileContent | undefined,
mode: SystemPromptMode,
): string[] {
if (mode !== "full") return [];
const prompt = resolveHeartbeatPrompt(profile?.config?.heartbeat?.prompt);
return [
"## Heartbeats",
`Heartbeat prompt: ${prompt}`,
'If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:',
"HEARTBEAT_OK",
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
"",
];
}
/**
* Safety constitution always included.
*/

View file

@ -53,6 +53,7 @@ export interface ProfileContent {
user?: string | undefined;
workspace?: string | undefined;
memory?: string | undefined;
heartbeat?: string | undefined;
config?: ProfileConfig | undefined;
}

View file

@ -7,7 +7,9 @@ import { createProcessTool } from "./tools/process.js";
import { createGlobTool } from "./tools/glob.js";
import { createWebFetchTool, createWebSearchTool } from "./tools/web/index.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn.js";
import { createSessionsListTool } from "./tools/sessions-list.js";
import { createMemorySearchTool } from "./tools/memory-search.js";
import { createCronTool } from "./tools/cron/index.js";
import { filterTools } from "./tools/policy.js";
import { isMulticaError, isRetryableError } from "../shared/errors.js";
import type { ExecApprovalCallback } from "./tools/exec-approval-types.js";
@ -107,6 +109,8 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
const webFetchTool = createWebFetchTool();
const webSearchTool = createWebSearchTool();
const cronTool = createCronTool();
const tools: AgentTool<any>[] = [
...baseTools,
execTool as AgentTool<any>,
@ -114,6 +118,7 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
globTool as AgentTool<any>,
webFetchTool as AgentTool<any>,
webSearchTool as AgentTool<any>,
cronTool as AgentTool<any>,
];
// Add memory_search tool if profileDir is provided
@ -129,6 +134,10 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
});
tools.push(sessionsSpawnTool as AgentTool<any>);
// Add sessions_list tool
const sessionsListTool = createSessionsListTool({ sessionId });
tools.push(sessionsListTool as AgentTool<any>);
return tools;
}

View file

@ -0,0 +1,422 @@
/**
* Cron Tool for Agent
*
* Allows agents to create, manage, and execute scheduled tasks.
* Based on OpenClaw's implementation (MIT License)
*/
import { Type } from "@sinclair/typebox";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import {
getCronService,
formatSchedule,
formatDuration,
parseTimeInput,
parseIntervalInput,
isValidCronExpr,
type CronSchedule,
type CronJobInput,
} from "../../../cron/index.js";
// NOTE: Avoid Type.Union([Type.Literal(...)]) which compiles to anyOf.
// Some providers reject anyOf in tool schemas; a flat string enum is safer.
function stringEnum<T extends readonly string[]>(values: T, options: { description?: string } = {}) {
return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
}
const CRON_ACTIONS = ["status", "list", "add", "update", "remove", "run", "logs"] as const;
// Flattened schema: runtime validates per-action requirements.
const CronSchema = Type.Object({
action: stringEnum(CRON_ACTIONS),
enabled: Type.Optional(Type.Boolean({ description: "Filter by enabled status (for list action)" })),
name: Type.Optional(Type.String({ description: "Job name (for add action)" })),
description: Type.Optional(Type.String({ description: "Job description (for add action)" })),
schedule: Type.Optional(Type.Object({
kind: stringEnum(["at", "every", "cron"] as const),
at: Type.Optional(Type.String({ description: "Time for one-shot (ISO 8601 or relative like '10m')" })),
every: Type.Optional(Type.String({ description: "Interval (e.g., '30m', '2h')" })),
expr: Type.Optional(Type.String({ description: "Cron expression (5-field)" })),
tz: Type.Optional(Type.String({ description: "Timezone for cron expression" })),
})),
sessionTarget: stringEnum(["main", "isolated"] as const, { description: "Where to run: main session or isolated" }),
payload: Type.Optional(Type.Object({
kind: stringEnum(["system-event", "agent-turn"] as const),
text: Type.Optional(Type.String({ description: "Text for system-event" })),
message: Type.Optional(Type.String({ description: "Prompt for agent-turn" })),
timeoutSeconds: Type.Optional(Type.Number({ description: "Timeout for agent-turn" })),
})),
deleteAfterRun: Type.Optional(Type.Boolean({ description: "Delete after one-time run" })),
wakeMode: stringEnum(["next-heartbeat", "now"] as const, { description: "When to wake after job execution" }),
jobId: Type.Optional(Type.String({ description: "Job ID (for update/remove/run/logs actions)" })),
force: Type.Optional(Type.Boolean({ description: "Force run even if disabled (for run action)" })),
limit: Type.Optional(Type.Number({ description: "Number of log entries to return (for logs action)" })),
});
type CronArgs = {
action: "status" | "list" | "add" | "update" | "remove" | "run" | "logs";
enabled?: boolean;
name?: string;
description?: string;
schedule?: {
kind: "at" | "every" | "cron";
at?: string;
every?: string;
expr?: string;
tz?: string;
};
sessionTarget?: "main" | "isolated";
payload?: {
kind: "system-event" | "agent-turn";
text?: string;
message?: string;
timeoutSeconds?: number;
};
deleteAfterRun?: boolean;
wakeMode?: "next-heartbeat" | "now";
jobId?: string;
force?: boolean;
limit?: number;
};
export type CronResult = {
success: boolean;
message: string;
data?: unknown;
};
/** Parse schedule from tool parameters */
function parseSchedule(schedule: CronArgs["schedule"]): CronSchedule | { error: string } {
if (!schedule) {
return { error: "schedule is required" };
}
switch (schedule.kind) {
case "at": {
const at = schedule.at;
if (!at) {
return { error: "schedule.at is required for kind='at'" };
}
const atMs = parseTimeInput(at);
if (!atMs) {
return { error: `Invalid time format: ${at}` };
}
return { kind: "at", atMs };
}
case "every": {
const every = schedule.every;
if (!every) {
return { error: "schedule.every is required for kind='every'" };
}
const everyMs = parseIntervalInput(every);
if (!everyMs) {
return { error: `Invalid interval format: ${every}` };
}
return { kind: "every", everyMs };
}
case "cron": {
const expr = schedule.expr;
if (!expr) {
return { error: "schedule.expr is required for kind='cron'" };
}
const tz = schedule.tz;
if (!isValidCronExpr(expr, tz)) {
return { error: `Invalid cron expression: ${expr}` };
}
// Only include tz if defined (exactOptionalPropertyTypes)
if (tz) {
return { kind: "cron", expr, tz };
}
return { kind: "cron", expr };
}
default:
return { error: `Unknown schedule kind: ${schedule.kind}` };
}
}
const TOOL_DESCRIPTION = `Manage cron jobs (status/list/add/update/remove/run/logs).
ACTIONS:
- status: Check cron scheduler status
- list: List jobs (use enabled:true/false to filter)
- add: Create job (requires name, schedule, payload, sessionTarget)
- update: Modify job (requires jobId, plus fields to update)
- remove: Delete job (requires jobId)
- run: Trigger job immediately (requires jobId, optional force:true)
- logs: Get job run history (requires jobId, optional limit)
SCHEDULE TYPES (schedule.kind):
- "at": One-shot at time
{ "kind": "at", "at": "10m" } or { "kind": "at", "at": "2024-12-31T23:59:00Z" }
- "every": Recurring interval
{ "kind": "every", "every": "30m" }
- "cron": Cron expression
{ "kind": "cron", "expr": "0 9 * * *", "tz": "Asia/Shanghai" }
PAYLOAD TYPES (payload.kind):
- "system-event": Injects text into main session (like a reminder, triggers main agent to respond)
{ "kind": "system-event", "text": "<message>" }
- "agent-turn": Spawns an isolated agent that can use ALL tools (exec, write, web_fetch, etc.) to autonomously complete a task
{ "kind": "agent-turn", "message": "<prompt>", "timeoutSeconds": 300 }
USE "agent-turn" when the job needs to perform actions (run commands, write files, fetch data, etc.).
USE "system-event" when the job only needs to remind/notify the user in the current chat.
CRITICAL CONSTRAINTS:
- sessionTarget="main" REQUIRES payload.kind="system-event"
- sessionTarget="isolated" REQUIRES payload.kind="agent-turn"
- Default sessionTarget is "main", default wakeMode is "now"`;
/** Create the cron tool */
export function createCronTool(): AgentTool<typeof CronSchema, CronResult> {
return {
name: "cron",
label: "Cron",
description: TOOL_DESCRIPTION,
parameters: CronSchema,
execute: async (_toolCallId, args) => {
const { action } = args as CronArgs;
const service = getCronService();
try {
switch (action) {
case "status": {
const status = service.status();
const output = JSON.stringify({
running: status.running,
enabled: status.enabled,
jobCount: status.jobCount,
enabledJobCount: status.enabledJobCount,
nextWakeAt: status.nextWakeAtMs ? new Date(status.nextWakeAtMs).toISOString() : null,
storePath: status.storePath,
}, null, 2);
return {
content: [{ type: "text", text: output }],
details: { success: true, message: "Status retrieved", data: status },
};
}
case "list": {
const params = args as CronArgs;
const filter = params.enabled !== undefined ? { enabled: params.enabled } : undefined;
const jobs = service.list(filter);
const formatted = jobs.map((job) => ({
id: job.id,
name: job.name,
enabled: job.enabled,
schedule: formatSchedule(job.schedule),
sessionTarget: job.sessionTarget,
nextRunAt: job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : null,
lastStatus: job.state.lastStatus,
lastRunAt: job.state.lastRunAtMs ? new Date(job.state.lastRunAtMs).toISOString() : null,
}));
const output = JSON.stringify(formatted, null, 2);
return {
content: [{ type: "text", text: output }],
details: { success: true, message: `Found ${jobs.length} job(s)`, data: formatted },
};
}
case "add": {
const params = args as CronArgs;
if (!params.name) {
return {
content: [{ type: "text", text: "Error: name is required" }],
details: { success: false, message: "name is required" },
};
}
const schedule = parseSchedule(params.schedule);
if ("error" in schedule) {
return {
content: [{ type: "text", text: `Error: ${schedule.error}` }],
details: { success: false, message: schedule.error },
};
}
if (!params.payload) {
return {
content: [{ type: "text", text: "Error: payload is required" }],
details: { success: false, message: "payload is required" },
};
}
const { payload } = params;
let jobPayload;
if (payload.kind === "system-event") {
if (!payload.text) {
return {
content: [{ type: "text", text: "Error: payload.text is required for system-event" }],
details: { success: false, message: "payload.text is required for system-event" },
};
}
jobPayload = { kind: "system-event" as const, text: payload.text };
} else if (payload.kind === "agent-turn") {
if (!payload.message) {
return {
content: [{ type: "text", text: "Error: payload.message is required for agent-turn" }],
details: { success: false, message: "payload.message is required for agent-turn" },
};
}
const agentPayload: { kind: "agent-turn"; message: string; timeoutSeconds?: number } = {
kind: "agent-turn",
message: payload.message,
};
if (payload.timeoutSeconds !== undefined) {
agentPayload.timeoutSeconds = payload.timeoutSeconds;
}
jobPayload = agentPayload;
} else {
return {
content: [{ type: "text", text: `Error: Unknown payload kind` }],
details: { success: false, message: "Unknown payload kind" },
};
}
const input: CronJobInput = {
name: params.name,
enabled: true,
schedule,
sessionTarget: params.sessionTarget ?? "main",
wakeMode: params.wakeMode ?? "now",
payload: jobPayload,
};
if (params.description !== undefined) {
input.description = params.description;
}
if (params.deleteAfterRun !== undefined) {
input.deleteAfterRun = params.deleteAfterRun;
}
const job = service.add(input);
const output = `Created job: ${job.name} (${job.id})\nSchedule: ${formatSchedule(job.schedule)}\nNext run: ${job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : "none"}`;
return {
content: [{ type: "text", text: output }],
details: { success: true, message: "Job created", data: job },
};
}
case "update": {
const params = args as CronArgs;
if (!params.jobId) {
return {
content: [{ type: "text", text: "Error: jobId is required" }],
details: { success: false, message: "jobId is required" },
};
}
const patch: Record<string, unknown> = {};
if (params.name !== undefined) patch.name = params.name;
if (params.description !== undefined) patch.description = params.description;
if (params.enabled !== undefined) patch.enabled = params.enabled;
if (params.schedule !== undefined) {
const schedule = parseSchedule(params.schedule);
if ("error" in schedule) {
return {
content: [{ type: "text", text: `Error: ${schedule.error}` }],
details: { success: false, message: schedule.error },
};
}
patch.schedule = schedule;
}
const updated = service.update(params.jobId, patch);
if (!updated) {
return {
content: [{ type: "text", text: `Error: Job not found: ${params.jobId}` }],
details: { success: false, message: "Job not found" },
};
}
return {
content: [{ type: "text", text: `Updated job: ${updated.name} (${updated.id})` }],
details: { success: true, message: "Job updated", data: updated },
};
}
case "remove": {
const params = args as CronArgs;
if (!params.jobId) {
return {
content: [{ type: "text", text: "Error: jobId is required" }],
details: { success: false, message: "jobId is required" },
};
}
const removed = service.remove(params.jobId);
if (!removed) {
return {
content: [{ type: "text", text: `Error: Job not found: ${params.jobId}` }],
details: { success: false, message: "Job not found" },
};
}
return {
content: [{ type: "text", text: `Removed job: ${params.jobId}` }],
details: { success: true, message: "Job removed" },
};
}
case "run": {
const params = args as CronArgs;
if (!params.jobId) {
return {
content: [{ type: "text", text: "Error: jobId is required" }],
details: { success: false, message: "jobId is required" },
};
}
const result = await service.run(params.jobId, params.force);
if (!result.ok) {
return {
content: [{ type: "text", text: `Error: ${result.reason}` }],
details: { success: false, message: result.reason ?? "Run failed" },
};
}
return {
content: [{ type: "text", text: "Job executed successfully" }],
details: { success: true, message: "Job executed" },
};
}
case "logs": {
const params = args as CronArgs;
if (!params.jobId) {
return {
content: [{ type: "text", text: "Error: jobId is required" }],
details: { success: false, message: "jobId is required" },
};
}
const logs = service.getRunLogs(params.jobId, params.limit);
const formatted = logs.map((log) => ({
timestamp: new Date(log.ts).toISOString(),
status: log.status,
duration: log.durationMs ? formatDuration(log.durationMs) : undefined,
error: log.error,
summary: log.summary,
}));
const output = JSON.stringify(formatted, null, 2);
return {
content: [{ type: "text", text: output }],
details: { success: true, message: `Found ${logs.length} log entries`, data: formatted },
};
}
default:
return {
content: [{ type: "text", text: `Error: Unknown action: ${action}` }],
details: { success: false, message: `Unknown action: ${action}` },
};
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text", text: `Error: ${message}` }],
details: { success: false, message },
};
}
},
};
}

View file

@ -0,0 +1,5 @@
/**
* Cron Tools
*/
export { createCronTool } from "./cron-tool.js";

View file

@ -50,8 +50,8 @@ export function createCliApprovalCallback(
const runtimeConfig = { ...config, allowlist: [...(config.allowlist ?? [])] };
return async (command: string, cwd: string | undefined): Promise<ApprovalResult> => {
const security = runtimeConfig.security ?? "allowlist";
const ask = runtimeConfig.ask ?? "on-miss";
const security = runtimeConfig.security ?? "full";
const ask = runtimeConfig.ask ?? "off";
const timeoutMs = runtimeConfig.timeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
// Security: deny blocks everything
@ -137,13 +137,15 @@ function promptTerminal(
rl.close();
};
// Timeout: auto-deny
const timer = setTimeout(() => {
if (resolved) return;
process.stderr.write(dim(`\n Approval timed out (${timeoutMs / 1000}s). Denying.\n\n`));
cleanup();
resolve("deny");
}, timeoutMs);
// Timeout: auto-deny (skip if timeoutMs is -1 for no timeout)
const timer = timeoutMs >= 0
? setTimeout(() => {
if (resolved) return;
process.stderr.write(dim(`\n Approval timed out (${timeoutMs / 1000}s). Denying.\n\n`));
cleanup();
resolve("deny");
}, timeoutMs)
: null;
// Display approval prompt
process.stderr.write("\n");
@ -161,7 +163,7 @@ function promptTerminal(
rl.question(
` ${bold("[a]")}llow once / ${bold("[A]")}llow always / ${bold("[d]")}eny (default: deny): `,
(answer) => {
clearTimeout(timer);
if (timer) clearTimeout(timer);
cleanup();
const trimmed = answer.trim();
@ -177,7 +179,7 @@ function promptTerminal(
// Handle Ctrl+C gracefully
rl.on("close", () => {
clearTimeout(timer);
if (timer) clearTimeout(timer);
if (!resolved) {
resolved = true;
resolve("deny");

View file

@ -32,7 +32,7 @@ export interface ExecApprovalRequest {
riskLevel: "safe" | "needs-review" | "dangerous";
/** Reasons for the risk assessment */
riskReasons: string[];
/** When this approval expires (ms since epoch) */
/** When this approval expires (ms since epoch). -1 means no timeout. */
expiresAtMs: number;
}
@ -50,7 +50,7 @@ export interface ExecApprovalConfig {
security?: ExecSecurity;
/** Ask mode: "off" never asks, "on-miss" asks when allowlist misses, "always" always asks */
ask?: ExecAsk;
/** Timeout before auto-deny in milliseconds (default: 60_000) */
/** Timeout before auto-deny in milliseconds (default: 60_000). Set to -1 for no timeout. */
timeoutMs?: number;
/** Fallback security level on timeout (default: "deny" — fail-closed) */
askFallback?: ExecSecurity;
@ -58,8 +58,8 @@ export interface ExecApprovalConfig {
allowlist?: ExecAllowlistEntry[];
}
/** Default timeout for approval requests (60 seconds) */
export const DEFAULT_APPROVAL_TIMEOUT_MS = 60_000;
/** Default timeout for approval requests (-1 = no timeout, wait indefinitely) */
export const DEFAULT_APPROVAL_TIMEOUT_MS = -1;
// ============ Allowlist ============

View file

@ -164,9 +164,22 @@ export function createExecTool(
// Don't reject, let close event handle
});
// Signal handling: don't kill if already backgrounded
const onAbort = signal ? () => {
if (yielded) return; // Already backgrounded, ignore abort
if (timeout) clearTimeout(timeout);
if (yieldTimer) clearTimeout(yieldTimer);
child.kill("SIGTERM");
} : undefined;
if (signal && onAbort) {
signal.addEventListener("abort", onAbort, { once: true });
}
child.on("close", (code) => {
if (timeout) clearTimeout(timeout);
if (yieldTimer) clearTimeout(yieldTimer);
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
// If already backgrounded, don't resolve again
if (yielded) return;
@ -202,16 +215,6 @@ export function createExecTool(
},
});
});
// Signal handling: don't kill if already backgrounded
if (signal) {
signal.addEventListener("abort", () => {
if (yielded) return; // Already backgrounded, ignore abort
if (timeout) clearTimeout(timeout);
if (yieldTimer) clearTimeout(yieldTimer);
child.kill("SIGTERM");
});
}
});
},
};

View file

@ -34,7 +34,10 @@ export const TOOL_GROUPS: Record<string, string[]> = {
"group:memory": ["memory_search"],
// Subagent tools
"group:subagent": ["sessions_spawn"],
"group:subagent": ["sessions_spawn", "sessions_list"],
// Cron/scheduling tools
"group:cron": ["cron"],
// All core tools
"group:core": [

View file

@ -7,6 +7,8 @@ export { createExecTool } from "./exec.js";
export { createProcessTool } from "./process.js";
export { createGlobTool } from "./glob.js";
export { createWebFetchTool, createWebSearchTool } from "./web/index.js";
export { createCronTool } from "./cron/index.js";
export { createSessionsListTool } from "./sessions-list.js";
// Tool groups
export {

View file

@ -112,7 +112,7 @@ export function createProcessTool(defaultCwd?: string): AgentTool<typeof Process
if (signal) {
signal.addEventListener("abort", () => {
child.kill("SIGTERM");
});
}, { once: true });
}
resolve({ success: true });

View file

@ -0,0 +1,169 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { SubagentRunRecord } from "../subagent/types.js";
// Mock the registry module before importing the tool
vi.mock("../subagent/registry.js", () => ({
listSubagentRuns: vi.fn(),
getSubagentRun: vi.fn(),
}));
import { createSessionsListTool } from "./sessions-list.js";
import { listSubagentRuns, getSubagentRun } from "../subagent/registry.js";
const mockListSubagentRuns = vi.mocked(listSubagentRuns);
const mockGetSubagentRun = vi.mocked(getSubagentRun);
function makeRecord(overrides: Partial<SubagentRunRecord> = {}): SubagentRunRecord {
return {
runId: "run-001",
childSessionId: "child-001",
requesterSessionId: "parent-001",
task: "Test task",
cleanup: "delete",
createdAt: 1700000000000,
...overrides,
};
}
describe("sessions_list tool", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns empty message when no runs exist", async () => {
mockListSubagentRuns.mockReturnValue([]);
const tool = createSessionsListTool({ sessionId: "parent-001" });
const result = await tool.execute("call-1", {});
expect(result.content[0]).toEqual({
type: "text",
text: "No subagent runs for this session.",
});
expect(result.details).toEqual({ runs: [] });
});
it("lists multiple runs with correct status mapping", async () => {
const now = Date.now();
const runs: SubagentRunRecord[] = [
makeRecord({
runId: "run-aaa",
label: "Code Review",
startedAt: now - 45000,
}),
makeRecord({
runId: "run-bbb",
label: "Test Analysis",
startedAt: now - 60000,
endedAt: now - 30000,
outcome: { status: "ok" },
}),
makeRecord({
runId: "run-ccc",
label: "Lint Check",
startedAt: now - 60000,
endedAt: now,
outcome: { status: "error", error: "timeout" },
}),
];
mockListSubagentRuns.mockReturnValue(runs);
const tool = createSessionsListTool({ sessionId: "parent-001" });
const result = await tool.execute("call-1", {});
const text = result.content[0]!;
expect(text.type).toBe("text");
expect((text as { text: string }).text).toContain("3 total");
expect((text as { text: string }).text).toContain("[running]");
expect((text as { text: string }).text).toContain("[ok]");
expect((text as { text: string }).text).toContain("[error]");
expect((text as { text: string }).text).toContain("Code Review");
expect((text as { text: string }).text).toContain("Test Analysis");
expect((text as { text: string }).text).toContain("Lint Check");
expect(result.details!.runs).toHaveLength(3);
expect(result.details!.runs[0]!.status).toBe("running");
expect(result.details!.runs[1]!.status).toBe("ok");
expect(result.details!.runs[2]!.status).toBe("error");
});
it("returns detail for a specific runId", async () => {
const now = Date.now();
const record = makeRecord({
runId: "run-detail",
label: "Deep Analysis",
task: "Analyze the authentication module thoroughly",
startedAt: now - 90000,
endedAt: now - 10000,
outcome: { status: "ok" },
findings: "Found 2 potential issues in token validation.",
findingsCaptured: true,
});
mockGetSubagentRun.mockReturnValue(record);
const tool = createSessionsListTool({ sessionId: "parent-001" });
const result = await tool.execute("call-1", { runId: "run-detail" });
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("Run: run-detail");
expect(text).toContain("Label: Deep Analysis");
expect(text).toContain("Status: ok");
expect(text).toContain("Found 2 potential issues");
expect(text).toContain("Duration:");
expect(result.details!.runs).toHaveLength(1);
expect(result.details!.runs[0]!.runId).toBe("run-detail");
});
it("returns not found for unknown runId", async () => {
mockGetSubagentRun.mockReturnValue(undefined);
const tool = createSessionsListTool({ sessionId: "parent-001" });
const result = await tool.execute("call-1", { runId: "nonexistent" });
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("Run not found");
expect(result.details).toEqual({ runs: [] });
});
it("rejects runId belonging to a different requester", async () => {
const record = makeRecord({
runId: "run-other",
requesterSessionId: "other-parent",
});
mockGetSubagentRun.mockReturnValue(record);
const tool = createSessionsListTool({ sessionId: "parent-001" });
const result = await tool.execute("call-1", { runId: "run-other" });
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("Run not found");
expect(result.details).toEqual({ runs: [] });
});
it("handles missing sessionId gracefully", async () => {
const tool = createSessionsListTool({});
const result = await tool.execute("call-1", {});
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("No session ID available");
expect(result.details).toEqual({ runs: [] });
});
it("shows findings status for running task", async () => {
const now = Date.now();
const record = makeRecord({
runId: "run-running",
label: "Still Running",
startedAt: now - 30000,
// no endedAt
});
mockGetSubagentRun.mockReturnValue(record);
const tool = createSessionsListTool({ sessionId: "parent-001" });
const result = await tool.execute("call-1", { runId: "run-running" });
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("Status: running");
expect(text).toContain("Findings: (still running)");
});
});

View file

@ -0,0 +1,187 @@
/**
* sessions_list tool allows an agent to view its spawned subagent runs.
*
* Lists all subagent runs for the current session, or shows details for a
* specific run when a runId is provided.
*/
import { Type } from "@sinclair/typebox";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { listSubagentRuns, getSubagentRun } from "../subagent/registry.js";
import type { SubagentRunRecord } from "../subagent/types.js";
const SessionsListSchema = Type.Object({
runId: Type.Optional(
Type.String({ description: "Optional run ID to get details for a specific run. If omitted, lists all runs." }),
),
});
type SessionsListArgs = {
runId?: string;
};
export type SessionsListResult = {
runs: Array<{
runId: string;
label?: string;
task: string;
status: "running" | "ok" | "error" | "timeout" | "unknown";
startedAt?: number;
endedAt?: number;
findings?: string;
}>;
};
export interface CreateSessionsListToolOptions {
/** Session ID of the current (requester) agent */
sessionId?: string;
}
function resolveStatus(record: SubagentRunRecord): "running" | "ok" | "error" | "timeout" | "unknown" {
if (!record.endedAt) return "running";
return record.outcome?.status ?? "unknown";
}
function formatElapsed(ms: number): string {
const totalSeconds = Math.round(ms / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) return seconds > 0 ? `${minutes}m${seconds}s` : `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes > 0 ? `${hours}h${remainingMinutes}m` : `${hours}h`;
}
function formatRunSummary(record: SubagentRunRecord, index: number, now: number): string {
const status = resolveStatus(record);
const displayName = record.label || record.task.slice(0, 60);
const statusTag = `[${status}]`.padEnd(10);
let timing = "";
if (status === "running" && record.startedAt) {
timing = `started ${formatElapsed(now - record.startedAt)} ago`;
} else if (record.startedAt && record.endedAt) {
timing = `completed in ${formatElapsed(record.endedAt - record.startedAt)}`;
}
const parts = [`#${index + 1} ${statusTag} "${displayName}"`];
if (timing) parts.push(`(${record.runId.slice(0, 8)}…, ${timing})`);
else parts.push(`(${record.runId.slice(0, 8)}…)`);
return parts.join(" ");
}
function formatRunDetail(record: SubagentRunRecord, now: number): string {
const status = resolveStatus(record);
const lines: string[] = [
`Run: ${record.runId}`,
];
if (record.label) lines.push(`Label: ${record.label}`);
lines.push(`Task: ${record.task}`);
lines.push(`Status: ${status}${record.outcome?.error ? `${record.outcome.error}` : ""}`);
lines.push(`Child Session: ${record.childSessionId}`);
lines.push(`Created: ${new Date(record.createdAt).toISOString()} (${formatElapsed(now - record.createdAt)} ago)`);
if (record.startedAt) {
lines.push(`Started: ${new Date(record.startedAt).toISOString()} (${formatElapsed(now - record.startedAt)} ago)`);
}
if (record.endedAt) {
lines.push(`Ended: ${new Date(record.endedAt).toISOString()}`);
if (record.startedAt) {
lines.push(`Duration: ${formatElapsed(record.endedAt - record.startedAt)}`);
}
}
if (record.findingsCaptured) {
lines.push(`Findings: ${record.findings || "(no output)"}`);
} else if (record.endedAt) {
lines.push("Findings: (not yet captured)");
} else {
lines.push("Findings: (still running)");
}
if (record.announced) lines.push("Announced: yes");
return lines.join("\n");
}
function toResultRun(record: SubagentRunRecord) {
return {
runId: record.runId,
label: record.label,
task: record.task,
status: resolveStatus(record),
startedAt: record.startedAt,
endedAt: record.endedAt,
findings: record.findings,
};
}
export function createSessionsListTool(
options: CreateSessionsListToolOptions,
): AgentTool<typeof SessionsListSchema, SessionsListResult> {
return {
name: "sessions_list",
label: "List Subagent Runs",
description:
"List all subagent runs spawned by this session and their current status. " +
"Optionally pass a runId to get detailed information about a specific run.",
parameters: SessionsListSchema,
execute: async (_toolCallId, args) => {
const { runId } = args as SessionsListArgs;
const requesterSessionId = options.sessionId;
if (!requesterSessionId) {
return {
content: [{ type: "text", text: "No session ID available. Cannot list subagent runs." }],
details: { runs: [] },
};
}
const now = Date.now();
// Detail mode: specific run
if (runId) {
const record = getSubagentRun(runId);
if (!record) {
return {
content: [{ type: "text", text: `Run not found: ${runId}` }],
details: { runs: [] },
};
}
if (record.requesterSessionId !== requesterSessionId) {
return {
content: [{ type: "text", text: `Run not found: ${runId}` }],
details: { runs: [] },
};
}
return {
content: [{ type: "text", text: formatRunDetail(record, now) }],
details: { runs: [toResultRun(record)] },
};
}
// List mode: all runs for this session
const runs = listSubagentRuns(requesterSessionId);
if (runs.length === 0) {
return {
content: [{ type: "text", text: "No subagent runs for this session." }],
details: { runs: [] },
};
}
const lines = [`Subagent runs for this session: ${runs.length} total`, ""];
for (let i = 0; i < runs.length; i++) {
lines.push(formatRunSummary(runs[i]!, i, now));
}
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { runs: runs.map(toResultRun) },
};
},
};
}