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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ A worker deny-on-ask registers a harness-owned denied-call envelope (`src/permis

Two directors, selected by role:

- **ChatDirector** (interactive, `src/agent/director.ts`) — Extends `DefaultDirector` with task list tracking, workflow nudges, LSP auto-activation, and multi-turn chat semantics. It never terminates the session: operator declines are surfaced as replies and the reactor stays alive for the next message. Yielding while a live fleet is running is allowed (idle-with-fleet); the open-task nudge does not rewrite that wait/reply, and the workflow idle rail does not declare the parent stuck while helpers run. The TUI idle-with-fleet seed alone is not occupancy: an empty fleet still auto-continues slash workflows. Occupancy, mailbox, and operator inbounds reset the workflow idle counter. When a worker finishes or fails while the parent is idle (including idle-with-fleet), occupancy delivers mailbox mail as system inbound so Skywalker starts a new turn without polling `wait_agents`. Mailbox mail is a Summary + Blockers digest with a blob pointer to the full report, not the full report JSON. When the fleet goes dry with tasks still todo/doing, the TUI runtime re-enters the parent with collected worker reports rather than settling idle. Already-collected IDs in that continuation are id/status/description only.
- **ChatDirector** (interactive, `src/agent/director.ts`) — Extends `DefaultDirector` with task list tracking, workflow nudges, LSP auto-activation, and multi-turn chat semantics. It never terminates the session: operator declines are surfaced as replies and the reactor stays alive for the next message. Yielding while a live fleet is running is allowed (idle-with-fleet); the open-task nudge does not rewrite that wait/reply, and the workflow idle rail does not declare the parent stuck while helpers run. The TUI idle-with-fleet seed alone is not occupancy: an empty fleet still auto-continues slash workflows. Occupancy, mailbox, and operator inbounds reset the workflow idle counter. When a worker finishes or fails while the parent is idle (including idle-with-fleet), occupancy delivers mailbox mail as system inbound so Skywalker starts a new turn without polling `wait_agents`. Mailbox mail is a Summary + Blockers digest with a blob pointer to the full report, not the full report JSON. When the fleet goes dry with tasks still todo/doing, the TUI runtime re-enters the parent with a digest of collected worker reports rather than settling idle. Already-collected IDs in that continuation are id/status/description only.

Auto mode is toggled by CLI flags (`--auto` / `--no-auto`); there is currently no in-session key to toggle it (default on; constrained envelope — workspace writes and unconstrained shell auto-allow; installs, recursive rm, force/uncontained worktree changes, sensitive-path and opaque-wrapper shell still ask; contained non-force `git worktree add`/`remove`/`prune` and `list` auto-allow; shell file-mutation denied). It is not a separate edit/plan mode.

Expand Down
2 changes: 1 addition & 1 deletion docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ Listings are list-free, dumps are dump-locked: a bounded `ls`/`tree` prints name

- **Alt+Enter** queues a follow-up (kind `"queue"`) delivered only on **session-idle** — parent-idle **and** no live fleet lanes (`run` goes idle). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run.

Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent turn can settle while workers keep running. The runner emits a `fleet` event carrying the live-lane count (same liveness rule as the progress strip: interrupted leftovers are not live); the bridge holds the run busy on that count, so mid-hold Enter upgrades to a new primary turn (sent immediately) instead of queueing a steer, follow-ups keep waiting for true session-idle, and any steer left pending at the hold's engagement delivers immediately — the parent it was steering has already stopped. The workflow idle rail honors live helper occupancy, not the TUI idle-with-fleet seed: a live fleet is not a stuck parent, an empty fleet still auto-continues slash workflows, and occupancy / mailbox / operator inbounds reset the workflow idle counter. While the hold is up and the parent is not processing, occupancy flushes mailbox mail (`driveMailboxMail` + `buildMailboxMailMessage`) on store subscribe and idle-with-fleet settle — one child done while siblings run is enough. The mail payload is a Summary + Blockers digest with a blob pointer, not the full report JSON; fleet-dry continuation lists already-collected IDs as id/status/description only and does not re-deliver an ID mailbox mail is already sending. Skip that shot when a fleet-dry open-task continuation is latched. Both wakes wait for send to settle as accepted before taking reports; a pending Promise is not a continuation, and a failed or uncertain send idles the parent. Exec-primary `wait_agents` mounts with no yield predicate (`createWaitAgentsTool({ sessions, fleetRecords })`) and blocks to ready/timeout/abort; parent interrupt/abort cancels that parked waiter via the tool AbortSignal (`timed_out`, workers stay running) rather than a second waiter table. `interrupt_agent` on a child unblocks wait as interrupted through the mailbox overlay. `shouldYieldWait` remains a supported-but-unwired tool option (no production mount passes it).
Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent turn can settle while workers keep running. The runner emits a `fleet` event carrying the live-lane count (same liveness rule as the progress strip: interrupted leftovers are not live); the bridge holds the run busy on that count, so mid-hold Enter upgrades to a new primary turn (sent immediately) instead of queueing a steer, follow-ups keep waiting for true session-idle, and any steer left pending at the hold's engagement delivers immediately — the parent it was steering has already stopped. The workflow idle rail honors live helper occupancy, not the TUI idle-with-fleet seed: a live fleet is not a stuck parent, an empty fleet still auto-continues slash workflows, and occupancy / mailbox / operator inbounds reset the workflow idle counter. While the hold is up and the parent is not processing, occupancy flushes mailbox mail (`driveMailboxMail` + `buildMailboxMailMessage`) on store subscribe and idle-with-fleet settle — one child done while siblings run is enough. The mail payload is a Summary + Blockers digest with a blob pointer, not the full report JSON; fleet-dry continuation uses the same digest for newly collected terminals, lists already-collected IDs as id/status/description only, and does not re-deliver an ID mailbox mail is already sending. Skip that shot when a fleet-dry open-task continuation is latched. Both wakes wait for send to settle as accepted before taking reports; a pending Promise is not a continuation, and a failed or uncertain send idles the parent. Exec-primary `wait_agents` mounts with no yield predicate (`createWaitAgentsTool({ sessions, fleetRecords })`) and blocks to ready/timeout/abort; parent interrupt/abort cancels that parked waiter via the tool AbortSignal (`timed_out`, workers stay running) rather than a second waiter table. `interrupt_agent` on a child unblocks wait as interrupted through the mailbox overlay. `shouldYieldWait` remains a supported-but-unwired tool option (no production mount passes it).

`src/tui/stream-event-map.ts` maps reactor events onto the bridge's inbound events, and `src/tui/turn-state.ts` tracks the turn's status. `src/tui/turns-to-blocks.ts` hydrates a resumed session's stored turns into the same content blocks.

Expand Down
5 changes: 3 additions & 2 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -724,8 +724,9 @@ continuation starts before the fleet-0 event so the run stays busy and
follow-ups wait one more turn. Occupancy wakes consume mailbox reports only
after send settles as accepted; a failed or uncertain send idles the parent
so a later flush can retry. Mailbox mail is a Summary + Blockers digest with
a blob pointer to the full report. Fleet-dry continuation lists
already-collected IDs as id/status/description only. The mail and that fleet-dry continuation
a blob pointer to the full report. Fleet-dry continuation uses the same digest
for newly collected terminals and lists already-collected IDs as
id/status/description only. The mail and that fleet-dry continuation
are runtime-to-agent traffic — the fleet board owns worker status — so
neither paints a transcript row, and neither rehydrates as one.

Expand Down
17 changes: 11 additions & 6 deletions src/subagent/fleet-dry-drive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
FLEET_DRY_CONTINUATION_PREFIX,
FLEET_DRY_REPORT_CHARS,
fleetDrySpillKey,
OCCUPANCY_DIGEST_READ_HINT,
shouldDriveOpenTasks,
type FleetDryMailboxRecord,
} from "./fleet-dry-drive.js";
Expand Down Expand Up @@ -529,14 +530,15 @@ describe("driveOpenTasksAfterFleetDry", () => {
agent_id: string;
status: string;
description?: string;
summary?: string;
report?: string;
}[];
expect(parsed).toEqual([
{
agent_id: "fresh",
status: "done",
description: "new lane",
report: "fresh report",
summary: "fresh report",
},
{
agent_id: "prior",
Expand Down Expand Up @@ -620,13 +622,16 @@ describe("driveOpenTasksAfterFleetDry", () => {
send: () => ACCEPTED_DELIVERY,
});
expect(driven).toBe(true);
const parsed = reportsJSONFromPrompt(sent[0] ?? "");
expect(Array.isArray(parsed)).toBe(true);
const report = (parsed as { report?: string }[])[0]?.report ?? "";
expect(report).toContain(
const parsed = reportsJSONFromPrompt(sent[0] ?? "") as {
report_uri?: string;
summary?: string;
}[];
expect(parsed[0]?.report_uri).toBe(
`tool-output:///${fleetDrySpillKey("big", "report")}`,
);
expect(report).not.toContain("TAIL-MARKER");
expect(sent[0]).toContain(OCCUPANCY_DIGEST_READ_HINT);
expect(sent[0]).not.toContain("TAIL-MARKER");
expect(parsed[0]?.summary).not.toContain("TAIL-MARKER");
expect(
new TextDecoder().decode(
store.blobs.get(fleetDrySpillKey("big", "report"))?.bytes ??
Expand Down
87 changes: 65 additions & 22 deletions src/subagent/fleet-dry-drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,15 @@ import { parseSubAgentReport } from "./report.js";
/** Enough of a lane report for a parent continuation; traces stay on disk. */
export const FLEET_DRY_REPORT_CHARS = 8_192;

/** Summary/Blockers inline in mailbox digest; the blob holds the rest. */
/** Summary/Blockers inline in occupancy digest; the blob holds the rest. */
export const MAILBOX_DIGEST_SECTION_CHARS = 2_048;

/** Unstructured reports have no envelope; keep the inline teaser short. */
export const UNSTRUCTURED_DIGEST_CHARS = 280;

export const OCCUPANCY_DIGEST_READ_HINT =
"read_file each report_uri (offset/limit supported) for the full report.";

export const FLEET_DRY_CONTINUATION_PREFIX =
"The fleet has gone dry. Remaining open tasks:";

Expand Down Expand Up @@ -204,9 +210,26 @@ export function fleetDrySpillKey(
return `fleet-dry:${agentId}:${field}`;
}

function clipDigestSection(text: string): string {
if (text.length <= MAILBOX_DIGEST_SECTION_CHARS) return text;
return `${text.slice(0, MAILBOX_DIGEST_SECTION_CHARS - 1).trimEnd()}…`;
function clipDigestSection(
text: string,
max = MAILBOX_DIGEST_SECTION_CHARS,
): string {
if (text.length <= max) return text;
return `${text.slice(0, max - 1).trimEnd()}…`;
}

export function occupancyDigestReadHint(
reports: readonly object[],
): string | undefined {
for (const report of reports) {
if (
"report_uri" in report &&
typeof (report as { report_uri?: unknown }).report_uri === "string"
) {
return OCCUPANCY_DIGEST_READ_HINT;
}
}
return undefined;
}

export async function spillWorkerField(
Expand All @@ -215,7 +238,9 @@ export async function spillWorkerField(
field: "report" | "error",
writeBlob?: FleetDryBlobWriter,
): Promise<string | undefined> {
if (text === undefined || writeBlob === undefined) return undefined;
if (text === undefined || text.length === 0 || writeBlob === undefined) {
return undefined;
}
const key = fleetDrySpillKey(agentId, field);
try {
await writeBlob(key, new TextEncoder().encode(text), "text/plain");
Expand Down Expand Up @@ -245,6 +270,11 @@ export async function digestCollectedReport(
report.report !== undefined
? parseSubAgentReport(report.report)
: undefined;
const unstructured =
parsed !== undefined &&
parsed.findings.length === 0 &&
parsed.blockers.length === 0 &&
parsed.paths.length === 0;
const reportUri = await spillWorkerField(
report.report,
report.agent_id,
Expand All @@ -259,14 +289,21 @@ export async function digestCollectedReport(
);
const summary =
parsed !== undefined && parsed.summary.length > 0
? clipDigestSection(parsed.summary)
: undefined;
const blockers =
parsed !== undefined
? clipDigestSection(
parsed.blockers.length > 0 ? parsed.blockers : "None.",
parsed.summary,
unstructured
? UNSTRUCTURED_DIGEST_CHARS
: MAILBOX_DIGEST_SECTION_CHARS,
)
: undefined;
const blockers =
parsed === undefined || unstructured
? parsed !== undefined && parsed.blockers.length > 0
? clipDigestSection(parsed.blockers)
: undefined
: clipDigestSection(
parsed.blockers.length > 0 ? parsed.blockers : "None.",
);
return {
agent_id: report.agent_id,
status: report.status,
Expand Down Expand Up @@ -463,13 +500,15 @@ export async function collectUncollectedTerminals(
return reports;
}

export function buildFleetDryContinuationPrompt(
export function buildFleetDryContinuationPrompt<T extends { agent_id: string }>(
tasks: readonly Task[],
reports: readonly CollectedWorkerReport[],
reports: readonly T[],
): string {
const open = tasks.filter(
(task) => task.status === "todo" || task.status === "doing",
);
const unique = dedupeByAgentId(reports);
const hint = occupancyDigestReadHint(unique);
const taskLines = open
.map((task) => `- ${task.id}: ${task.title} (${task.status})`)
.join("\n");
Expand All @@ -478,7 +517,8 @@ export function buildFleetDryContinuationPrompt(
taskLines,
"",
"Collected worker reports (already collected — do not call wait_agents for these agent_ids):",
JSON.stringify(dedupeByAgentId(reports)),
JSON.stringify(unique),
...(hint !== undefined ? [hint] : []),
"",
"Continue the remaining work. Mark each task done or cancelled with manage_tasks",
"when finished, or spawn_agent the next specialist. Do not end this turn while",
Expand Down Expand Up @@ -527,34 +567,37 @@ async function driveOpenTasksAfterFleetDrySpill(
args.mailbox,
args.lanes,
false,
args.writeBlob,
undefined,
false,
)
).filter((report) => !delivering.has(report.agent_id));
const uncollectedIds = new Set(uncollected.map((report) => report.agent_id));
const stubs = collectAlreadyCollectedStubs(args.mailbox, args.lanes).filter(
(stub) =>
!uncollectedIds.has(stub.agent_id) && !delivering.has(stub.agent_id),
);
const reports = dedupeByAgentId([...uncollected, ...stubs]);
const takeIds = uncollected.map((report) => report.agent_id);
for (const id of takeIds) delivering.add(id);
const prompt = buildFleetDryContinuationPrompt(tasks, reports);
const takeReports = (): void => {
for (const id of takeIds) {
args.mailbox?.take(id);
}
releaseOccupancyDelivering(args.mailbox, takeIds);
};
const fail = (): boolean => {
releaseOccupancyDelivering(args.mailbox, takeIds);
args.onSendFailure?.();
return false;
};
let prompt: string;
try {
const digested = await digestCollectedReports(uncollected, args.writeBlob);
const reports = dedupeByAgentId([...digested, ...stubs]);
prompt = buildFleetDryContinuationPrompt(tasks, reports);
args.beginSystemContinuation(prompt);
} catch {
return fail();
}
const takeReports = (): void => {
for (const id of takeIds) {
args.mailbox?.take(id);
}
releaseOccupancyDelivering(args.mailbox, takeIds);
};
return settleOccupancySend({
send: () => args.send(prompt),
onSuccess: takeReports,
Expand Down
2 changes: 2 additions & 0 deletions src/subagent/mailbox-mail-drive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { createSubAgentSessionStore } from "./session-store.js";
import {
digestCollectedReports,
fleetDrySpillKey,
OCCUPANCY_DIGEST_READ_HINT,
type FleetDryMailboxRecord,
} from "./fleet-dry-drive.js";
import {
Expand Down Expand Up @@ -217,6 +218,7 @@ describe("driveMailboxMail", () => {
]);
expect(sent[0]).not.toContain("SECRET_FINDINGS_BODY");
expect(sent[0]).not.toContain('"report":');
expect(sent[0]).toContain(OCCUPANCY_DIGEST_READ_HINT);
expect(
new TextDecoder().decode(
store.blobs.get(fleetDrySpillKey("done", "report"))?.bytes ??
Expand Down
11 changes: 8 additions & 3 deletions src/subagent/mailbox-mail-drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
dedupeByAgentId,
digestCollectedReports,
occupancyDeliveringSet,
occupancyDigestReadHint,
releaseOccupancyDelivering,
settleOccupancySend,
type FleetDryBlobWriter,
Expand Down Expand Up @@ -82,9 +83,13 @@ export function occupancyShouldYieldWait(
export function buildMailboxMailPrompt<T extends { agent_id: string }>(
reports: readonly T[],
): string {
return [mailboxMailWakeLine(), JSON.stringify(dedupeByAgentId(reports))].join(
"\n",
);
const unique = dedupeByAgentId(reports);
const hint = occupancyDigestReadHint(unique);
return [
mailboxMailWakeLine(),
JSON.stringify(unique),
...(hint !== undefined ? [hint] : []),
].join("\n");
}

function hasDeliverableMailboxMail(
Expand Down
Loading