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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/core/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
deleteWikiNote,
listWikiNotes,
readWikiNote,
writeWikiForRunState,
writeWikiSupportingNote
} from "./wiki-store.js";

Expand Down Expand Up @@ -273,9 +274,19 @@ export async function deleteRunAction({ id, stateDir = DEFAULT_STATE_DIR, confir
if (confirmationError) {
return confirmationError;
}
const notes = await listWikiNotes({ stateDir });
const runNoteIds = notes
.filter((note) => note.kind === "run" && note.runId === id)
.map((note) => note.id);
const result = await deleteRunState(id, { stateDir });
const wikiResults = [];
for (const noteId of runNoteIds) {
wikiResults.push(await deleteWikiNote(noteId, { stateDir }));
}
return {
ok: true,
result: await deleteRunState(id, { stateDir })
result,
wikiResults
};
}

Expand Down Expand Up @@ -309,7 +320,8 @@ export async function markVerificationAction({
nextAction: "review verification evidence and decide whether the run is complete"
};
const paths = await writeRunState(next, { stateDir });
return { ok: true, state: next, paths };
const wikiPaths = await writeWikiForRunState(next, { stateDir, paths });
return { ok: true, state: next, paths, wikiPaths };
}

/**
Expand Down Expand Up @@ -342,7 +354,8 @@ export async function markCompleteAction({
nextAction: "complete"
});
const paths = await writeRunState(completed, { stateDir });
return { ok: true, state: completed, paths };
const wikiPaths = await writeWikiForRunState(completed, { stateDir, paths });
return { ok: true, state: completed, paths, wikiPaths };
}

/**
Expand Down
12 changes: 6 additions & 6 deletions src/core/demo.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
export const demoWorkflows = [
{
title: "Darkwear luxury exhibition site",
description: "Start a fresh local project, let Loop create a git boundary, and watch the agent run.",
title: "Product quality loop",
description: "Improve a real dashboard through UX review, bug fixes, visual QA, docs, and follow-up issue capture.",
commands: [
"mkdir darkwear-exhibit",
"cd darkwear-exhibit",
"mkdir dashboard-quality-loop",
"cd dashboard-quality-loop",
"loop doctor",
"loop \"Build a darkwear luxury exhibition site MVP\"",
"loop \"Improve the current dashboard until a real user can understand it. Repeat through UX review, bug fixes, visual QA, documentation updates, and follow-up issue creation.\"",
"loop status",
"loop logs --follow",
"loop wiki"
Expand Down Expand Up @@ -53,7 +53,7 @@ export function renderDemoGuide() {
lines.push("Most users start with:");
lines.push("");
lines.push("```sh");
lines.push("loop \"Build the thing you want\"");
lines.push("loop \"Improve this product workflow until a real user can understand what is happening, what to read, and what to do next.\"");
lines.push("```");
lines.push("");

Expand Down
59 changes: 49 additions & 10 deletions src/core/tui.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
readGraphAction,
readRunLogTailAction
} from "./actions.js";
import { dashboardUrl } from "./wiki-dashboard.js";
import { dashboardUrl, serveWikiDashboard } from "./wiki-dashboard.js";
import { openTarget } from "./open-target.js";
import {
codexCommandFromOpenEffect,
Expand Down Expand Up @@ -137,7 +137,7 @@ export function renderTuiHome(snapshot) {
"Commands",
" 1-9 select run logs show tail wiki list notes",
" note add note verify add evidence complete mark complete",
" follow prepare codex open terminal dashboard open wiki",
" follow prepare codex open terminal dashboard start/open wiki",
" agent switch refresh q quit",
""
].join("\n");
Expand All @@ -151,6 +151,17 @@ function writeStatus(output, message) {
output.write(`\n${message}\n`);
}

/**
* @param {{ ok: boolean, error?: { message?: string, kind?: string } }} result
* @param {string} successMessage
*/
function actionStatus(result, successMessage) {
if (result.ok) {
return successMessage;
}
return `Action failed: ${result.error?.message ?? result.error?.kind ?? "unknown error"}`;
}

/**
* @param {{
* stateDir?: string,
Expand All @@ -160,7 +171,8 @@ function writeStatus(output, message) {
* clearScreen?: boolean,
* env?: NodeJS.ProcessEnv,
* openTargetImpl?: typeof openTarget,
* launchTerminalCommandImpl?: typeof launchTerminalCommand
* launchTerminalCommandImpl?: typeof launchTerminalCommand,
* serveWikiDashboardImpl?: typeof serveWikiDashboard
* }} [options]
*/
export async function runLoopTui({
Expand All @@ -171,14 +183,17 @@ export async function runLoopTui({
clearScreen = true,
env = process.env,
openTargetImpl = openTarget,
launchTerminalCommandImpl = launchTerminalCommand
launchTerminalCommandImpl = launchTerminalCommand,
serveWikiDashboardImpl = serveWikiDashboard
} = {}) {
if (!input.isTTY || !output.isTTY) {
throw new Error("Loop Agent Console requires an interactive terminal.");
}
let selectedRunId = /** @type {string | null} */ (null);
let agent = /** @type {"codex" | "claudecode"} */ ("codex");
let showLogo = true;
/** @type {import("node:http").Server | null} */
let dashboardServer = null;
const rl = createInterface({ input, output });
try {
while (true) {
Expand Down Expand Up @@ -217,9 +232,23 @@ export async function runLoopTui({
continue;
}
if (answer === "dashboard") {
const url = dashboardUrl();
openTargetImpl(url);
writeStatus(output, `Dashboard: ${url}`);
try {
const url = dashboardUrl();
if (!dashboardServer) {
const served = await serveWikiDashboardImpl({ stateDir });
if (served.server) {
dashboardServer = served.server;
}
openTargetImpl(served.url);
writeStatus(output, `Dashboard: ${served.url}`);
} else {
openTargetImpl(url);
writeStatus(output, `Dashboard: ${url}`);
}
} catch (error) {
const fallbackUrl = dashboardUrl();
writeStatus(output, `Dashboard failed to start: ${error instanceof Error ? error.message : String(error)}\nURL: ${fallbackUrl}`);
}
continue;
}
if (!selectedRunId) {
Expand All @@ -242,7 +271,7 @@ export async function runLoopTui({
const title = (await rl.question("Title: ")).trim();
const body = (await rl.question("Body: ")).trim();
if (title && body) {
await addWikiNoteAction({
const result = await addWikiNoteAction({
stateDir,
runId: selectedRunId,
targetId: selectedRunId,
Expand All @@ -251,29 +280,36 @@ export async function runLoopTui({
body,
confirmation: createActionConfirmation({ action: "add-note", targetId: selectedRunId, stateDir })
});
writeStatus(output, actionStatus(result, "Note added."));
} else {
writeStatus(output, "Title and body are required.");
}
continue;
}
if (answer === "verify") {
const summary = (await rl.question("Evidence summary: ")).trim();
if (summary) {
await markVerificationAction({
const result = await markVerificationAction({
stateDir,
id: selectedRunId,
summary,
confirmation: createActionConfirmation({ action: "verify-run", targetId: selectedRunId, stateDir })
});
writeStatus(output, actionStatus(result, "Verification evidence recorded."));
} else {
writeStatus(output, "Evidence summary is required.");
}
continue;
}
if (answer === "complete") {
const confirm = (await rl.question(`Mark ${selectedRunId} complete? y/N `)).trim().toLowerCase();
if (confirm === "y" || confirm === "yes") {
await markCompleteAction({
const result = await markCompleteAction({
stateDir,
id: selectedRunId,
confirmation: createActionConfirmation({ action: "mark-complete", targetId: selectedRunId, stateDir })
});
writeStatus(output, actionStatus(result, "Run marked complete."));
}
continue;
}
Expand Down Expand Up @@ -328,5 +364,8 @@ export async function runLoopTui({
}
} finally {
rl.close();
if (dashboardServer) {
await new Promise((resolve) => dashboardServer?.close(resolve));
}
}
}
22 changes: 16 additions & 6 deletions src/core/wiki-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ function wikiText(locale) {
backToNotes: "노트로 돌아가기",
liveRunLog: "실시간 실행 로그",
liveTail: "라이브 테일",
logSnapshot: "로그 스냅샷",
commandToWatch: "터미널에서 보기",
commandToResume: "Codex 이어보기",
commandUnavailable: "이 실행에서 이어보기 명령을 만들 수 없습니다.",
Expand Down Expand Up @@ -534,6 +535,7 @@ function wikiText(locale) {
backToNotes: "Back to notes",
liveRunLog: "Live Run Log",
liveTail: "Live tail",
logSnapshot: "Log snapshot",
commandToWatch: "Watch in terminal",
commandToResume: "Resume Codex",
commandUnavailable: "No resume command can be built for this run.",
Expand Down Expand Up @@ -2171,8 +2173,13 @@ export function renderRunLogHtml({ id, log, state = null, stateDir }) {
: hasHangul(log) ? "ko" : "en";
const text = wikiText(locale);
const content = log.trim() ? escapeHtml(log) : escapeHtml(text.outputEmpty);
const session = rawSessionFromState(state);
const sessionLabelText = sessionLabel(normalizeSession(session), locale);
const session = normalizeSession(rawSessionFromState(state));
const sessionLabelText = sessionLabel(session, locale);
const isLive = session?.status === "running";
const liveClass = isLive ? "live-pill" : "live-pill static";
const liveDot = isLive ? "<span class=\"live-dot\"></span>" : "";
const liveLabel = isLive ? text.liveTail : text.logSnapshot;
const pollingText = isLive ? text.pollingHint : text.logSnapshot;
const status = isRecord(state) && typeof state.status === "string" ? state.status : text.notRecorded;
const phase = isRecord(state) && typeof state.phase === "string" ? state.phase : text.notRecorded;
const statusText = status === text.notRecorded ? status : displayStatus(status, locale);
Expand All @@ -2183,8 +2190,10 @@ export function renderRunLogHtml({ id, log, state = null, stateDir }) {
const boot = jsonScript({
id,
emptyText: text.outputEmpty,
pollingHint: text.pollingHint
pollingHint: pollingText,
isLive
});
const livePollingScript = isLive ? "setInterval(pollLog, 1000);" : "";
return `<!doctype html>
<html lang="${text.lang}">
<head>
Expand All @@ -2210,6 +2219,7 @@ export function renderRunLogHtml({ id, log, state = null, stateDir }) {
.traffic span:nth-child(2) { background: var(--amber); }
.traffic span:nth-child(3) { background: var(--green); }
.live-pill { display: inline-flex; align-items: center; gap: 7px; color: var(--green); font-size: 12px; font-weight: 800; }
.live-pill.static { color: var(--muted); }
.live-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--green); box-shadow: 0 0 16px rgba(119,217,154,.75); animation: pulse 1.2s ease-in-out infinite; }
pre { margin: 0; min-height: 68vh; max-height: 72vh; padding: 16px; overflow: auto; color: #f8fafc; white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
#cursor { display: inline-block; width: 8px; height: 16px; margin-left: 2px; background: var(--blue); vertical-align: -2px; animation: blink 1s steps(2, start) infinite; }
Expand Down Expand Up @@ -2244,7 +2254,7 @@ export function renderRunLogHtml({ id, log, state = null, stateDir }) {
<div class="terminal">
<div class="terminal-bar">
<div class="traffic" aria-hidden="true"><span></span><span></span><span></span></div>
<span class="live-pill"><span class="live-dot"></span>${escapeHtml(text.liveTail)}</span>
<span class="${liveClass}">${liveDot}${escapeHtml(liveLabel)}</span>
</div>
<pre id="log-scroll"><code id="log-output">${content}</code><span id="cursor" aria-hidden="true"></span></pre>
</div>
Expand All @@ -2261,7 +2271,7 @@ export function renderRunLogHtml({ id, log, state = null, stateDir }) {
<code>${escapeHtml(logCommand)}</code>
<button class="button" type="button" data-copy="${escapeHtml(logCommand)}">${escapeHtml(text.copy)}</button>
</div>
<p class="small" id="live-meta">${escapeHtml(text.pollingHint)}</p>
<p class="small" id="live-meta">${escapeHtml(pollingText)}</p>
</section>
<section class="command-card">
<h2>${escapeHtml(text.commandToResume)}</h2>
Expand Down Expand Up @@ -2316,7 +2326,7 @@ export function renderRunLogHtml({ id, log, state = null, stateDir }) {
});
}
renderLog(output.textContent || "");
setInterval(pollLog, 1000);
${livePollingScript}
</script>
</body>
</html>`;
Expand Down
43 changes: 42 additions & 1 deletion test/actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
deleteRunAction,
deleteWikiNoteAction,
listRunsAction,
listWikiNotes,
markCompleteAction,
markVerificationAction,
prepareCodexOpenAction,
Expand All @@ -19,9 +20,11 @@ import {
readRunLog,
readRunLogTailAction,
readRunState,
readWikiNote,
runLogPath,
writeRunState,
writeWikiForRunState
writeWikiForRunState,
writeWikiSupportingNote
} from "../src/index.js";

test("action module stays inside the domain boundary", async () => {
Expand Down Expand Up @@ -104,32 +107,70 @@ test("verification and completion actions update run state through the store", a
now: new Date("2026-06-13T08:00:00.000Z")
});
await writeRunState(state, { stateDir });
await writeWikiForRunState(state, { stateDir });

const verified = await markVerificationAction({
stateDir,
id: state.id,
confirmation: createActionConfirmation({ action: "verify-run", targetId: state.id, stateDir }),
summary: "manual QA passed"
});
const notesAfterVerify = await listWikiNotes({ stateDir });
const noteAfterVerify = await readWikiNote(notesAfterVerify[0].id, { stateDir });
const blockedComplete = await markCompleteAction({ stateDir, id: state.id });
const completed = await markCompleteAction({
stateDir,
id: state.id,
confirmation: createActionConfirmation({ action: "mark-complete", targetId: state.id, stateDir })
});
const notesAfterComplete = await listWikiNotes({ stateDir });
const noteAfterComplete = await readWikiNote(notesAfterComplete[0].id, { stateDir });

assert.equal(verified.ok, true);
assert.ok("state" in verified);
const verifiedState = verified.state;
assert.ok(verifiedState);
assert.equal(verifiedState.phase, "verify");
assert.equal(notesAfterVerify[0].phase, "verify");
assert.match(noteAfterVerify.markdown, /manual QA passed/);
assert.equal(blockedComplete.ok, false);
assert.equal(completed.ok, true);
assert.ok("state" in completed);
const completedState = completed.state;
assert.ok(completedState);
assert.equal(completedState.status, "complete");
assert.equal(completedState.verificationEvidence.length, 2);
assert.equal(notesAfterComplete[0].status, "complete");
assert.match(noteAfterComplete.markdown, /complete/);
});

test("delete run action removes the run wiki card while preserving attached notes", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "loop-actions-delete-run-wiki-"));
const state = createRunState({
objective: "Delete run wiki objective",
now: new Date("2026-06-13T08:00:00.000Z")
});
await writeRunState(state, { stateDir });
const runNote = await writeWikiForRunState(state, { stateDir });
const attached = await writeWikiSupportingNote({
stateDir,
runId: state.id,
kind: "plan",
title: "Preserved plan",
body: "Keep this attached note after deleting the run state."
});

const deleted = await deleteRunAction({
stateDir,
id: state.id,
confirmation: createActionConfirmation({ action: "delete-run", targetId: state.id, stateDir })
});
const notes = await listWikiNotes({ stateDir });

assert.equal(deleted.ok, true);
assert.equal((await readRunState(state.id, { stateDir })).ok, false);
assert.ok(!notes.some((note) => note.id === runNote.id));
assert.ok(notes.some((note) => note.id === attached.id));
});

test("follow-up action creates lineage without launching an agent", async () => {
Expand Down
Loading