agent-from-zero 0.35.5 → 0.35.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -1
- package/dist/agent.js +92 -4
- package/dist/cli.js +11 -3
- package/dist/completion.js +1 -0
- package/dist/config.js +7 -1
- package/dist/goal.js +365 -0
- package/dist/hooks.js +58 -1
- package/dist/ink/app.js +90 -6
- package/dist/ink/commands.js +1 -0
- package/dist/ink/launch.js +1 -1
- package/dist/ink/setup.js +29 -3
- package/dist/loop.js +59 -10
- package/dist/permissions.js +4 -0
- package/dist/print.js +68 -0
- package/dist/session.js +12 -0
- package/package.json +2 -2
package/dist/ink/commands.js
CHANGED
|
@@ -35,6 +35,7 @@ export const SESSION_HELP = `commands:
|
|
|
35
35
|
/bg list background tasks this session (run_bash_background) and their status
|
|
36
36
|
/team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
|
|
37
37
|
/tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
|
|
38
|
+
/goal <objective> [--check <cmd>] keep working, turn after turn, until the goal is verified done (/goal alone shows it; pause | resume | clear)
|
|
38
39
|
/cron list scheduled cron jobs (schedule_cron) and their expressions
|
|
39
40
|
/peers pick another mini-agent session on this machine: talk to it from this box (Esc to come back), or make it flash its tab so you know which window it is
|
|
40
41
|
/rename <name> rename this session — the name other sessions use to reach it
|
package/dist/ink/launch.js
CHANGED
|
@@ -9,7 +9,7 @@ import { App } from "./app.js"; // the Ink REPL
|
|
|
9
9
|
// `npm run ink` entry (main.tsx) and agent.ts (the default `mini-agent` entry,
|
|
10
10
|
// once it hands interactive sessions to Ink) can start it.
|
|
11
11
|
export async function launchInk(opts = {}) {
|
|
12
|
-
const session = await buildInkSession(
|
|
12
|
+
const session = await buildInkSession(opts);
|
|
13
13
|
const { waitUntilExit } = render(_jsx(App, { session: session, runTurn: makeRunTurn(session.client, session.messages) }));
|
|
14
14
|
await waitUntilExit(); // keep the process alive until the user quits
|
|
15
15
|
// Then leave for real. The cron poll, the settings watcher and friends keep
|
package/dist/ink/setup.js
CHANGED
|
@@ -7,9 +7,10 @@ import { contextPercent } from "../context.js";
|
|
|
7
7
|
import { initCostMeter, DEFAULT_PRICING } from "../cost.js";
|
|
8
8
|
import { gitBranch } from "../tui.js";
|
|
9
9
|
import { listPeers, registerSession, unregisterSession } from "../peers.js"; // peer sessions: other mini-agent processes on this machine
|
|
10
|
-
import { newSessionId, latestSession } from "../session.js";
|
|
10
|
+
import { newSessionId, latestSession, loadSession, sessionPath } from "../session.js";
|
|
11
|
+
import { getGoal, restoreGoal } from "../goal.js"; // /goal (Day 41): a resumed session keeps working toward its goal
|
|
11
12
|
import { initTelemetry, emit } from "../telemetry.js";
|
|
12
|
-
import { runHooks } from "../hooks.js";
|
|
13
|
+
import { runHooks, setHookContext } from "../hooks.js";
|
|
13
14
|
import { connectMcpServers, watchMcpConfig } from "../mcp.js";
|
|
14
15
|
import { Judge } from "../judge.js";
|
|
15
16
|
import { AutoMode } from "../auto.js";
|
|
@@ -39,19 +40,34 @@ export async function buildInkSession(opts = {}) {
|
|
|
39
40
|
let sessionId = newSessionId();
|
|
40
41
|
let initialTitle; // only a resumed session has one already
|
|
41
42
|
const startedAt = Date.now();
|
|
42
|
-
|
|
43
|
+
// --session-id: an id chosen by whoever started us (an orchestrator picks it
|
|
44
|
+
// up front so it knows the session before the first turn). Resume it if it
|
|
45
|
+
// exists, otherwise it names the new session.
|
|
46
|
+
if (opts.sessionId) {
|
|
47
|
+
const prev = loadSession(opts.sessionId);
|
|
48
|
+
sessionId = opts.sessionId;
|
|
49
|
+
if (prev) {
|
|
50
|
+
messages.push(...prev.messages);
|
|
51
|
+
initialTitle = prev.title;
|
|
52
|
+
notices.push(`(resumed session ${prev.id} — ${prev.messages.length} messages; files must be re-read before editing)`);
|
|
53
|
+
restoreGoal(prev.goal);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else if (opts.resume) {
|
|
43
57
|
const prev = latestSession();
|
|
44
58
|
if (prev) {
|
|
45
59
|
messages.push(...prev.messages);
|
|
46
60
|
sessionId = prev.id; // keep appending to the same file
|
|
47
61
|
initialTitle = prev.title;
|
|
48
62
|
notices.push(`(resumed session ${prev.id} — ${prev.messages.length} messages; files must be re-read before editing)`);
|
|
63
|
+
restoreGoal(prev.goal);
|
|
49
64
|
}
|
|
50
65
|
else {
|
|
51
66
|
notices.push("(no previous session here — starting fresh)");
|
|
52
67
|
}
|
|
53
68
|
}
|
|
54
69
|
initTelemetry(sessionId);
|
|
70
|
+
setHookContext({ session_id: sessionId, transcript_path: sessionPath(sessionId) });
|
|
55
71
|
emit("agent_session_start", { mode: "repl" });
|
|
56
72
|
// MCP servers connect in the BACKGROUND, like Claude Code: the prompt is
|
|
57
73
|
// ready at once, each server's tools join the next model call the moment it
|
|
@@ -71,6 +87,16 @@ export async function buildInkSession(opts = {}) {
|
|
|
71
87
|
const judge = CONFIG.judge.enabled && !CONFIG.bypassPermissions ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
|
|
72
88
|
const autoMode = new AutoMode(client, { projectInstructions });
|
|
73
89
|
notices.push(...autoMode.startupNotices());
|
|
90
|
+
// A restored ACTIVE goal starts running as soon as the prompt is up — no
|
|
91
|
+
// keystroke — so say so loudly. Its /goal line was a genuine human request,
|
|
92
|
+
// so auto mode re-learns it (a paused goal is re-learned on /goal resume).
|
|
93
|
+
const goal = getGoal();
|
|
94
|
+
if (goal?.status === "active") {
|
|
95
|
+
autoMode.recordRequest(`/goal ${goal.objective}`);
|
|
96
|
+
notices.push(`(◎ resumed an ACTIVE goal — the agent continues it now; Esc pauses: ${goal.objective.slice(0, 80)})`);
|
|
97
|
+
}
|
|
98
|
+
else if (goal)
|
|
99
|
+
notices.push(`(◎ goal restored [${goal.status}] — /goal to see it, /goal resume to continue)`);
|
|
74
100
|
if (judge)
|
|
75
101
|
notices.push(`(permission judge on — ${autoMode.backend})`);
|
|
76
102
|
// The remember tool flows through the same permission gate as any tool.
|
package/dist/loop.js
CHANGED
|
@@ -11,7 +11,7 @@ import { currentSession, describePeers, peerMessageContent, readPeerInbox, sendP
|
|
|
11
11
|
import { LEAD, MAX_TEAMMATES, sendMessage, sendProtocol, readInbox, inboxCount, registerTeammate, finishTeammate, teammateExists, teammateCount, createRequest, resolveResponse, setTeammateState, anyTeammateBusy, runningTeammates, markShutdown, shutdownRequestId, resetTeam, listTeammateViews } from "./team.js"; // agent teams (Day 38) + team protocols (Day 39): mailboxes, registry, request/response contracts
|
|
12
12
|
import { createTask, claimTask, completeTask, claimNextAvailable, boardSummary, resetBoard } from "./board.js"; // the shared task board (Day 40): autonomous work claiming
|
|
13
13
|
import { emit } from "./telemetry.js"; // local-only event log (no-op unless the CLI armed it)
|
|
14
|
-
import { runHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / PostToolUse / Stop)
|
|
14
|
+
import { runHooks, runPermissionHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / PostToolUse / Stop / PermissionRequest)
|
|
15
15
|
import { reviewHistory } from "./auto-context.js";
|
|
16
16
|
import { AgentProgress } from "./agent-progress.js";
|
|
17
17
|
import { recordUsage } from "./cost.js"; // meter token usage from the stream
|
|
@@ -22,6 +22,7 @@ import { todoNag, getTodos, renderTodos } from "./todos.js"; // the agent's plan
|
|
|
22
22
|
import { currentSkills, skillBodyMessages, skillListingReminder, syncSkillTool } from "./skills.js"; // skill listing reminders + launched skill bodies
|
|
23
23
|
import { pendingNotifications } from "./background.js"; // background tasks (Day 37): surface finished jobs as a turn
|
|
24
24
|
import { consumeCronQueue, cronItemsPending, cronTriggerContent } from "./cron.js"; // cron scheduler (Day s14): scheduled, recurring work
|
|
25
|
+
import { getGoal, updateGoalTool, runUpdateGoal, countGoalToolCall } from "./goal.js"; // /goal (Day 41): the model ends a durable goal with update_goal
|
|
25
26
|
export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
|
|
26
27
|
export const MAX_RATE_LIMIT_RETRIES = 3; // 429s get their own, much smaller budget
|
|
27
28
|
export const MAX_CONSECUTIVE_FAILURES = 3; // the circuit breaker
|
|
@@ -54,6 +55,16 @@ function rememberTools(opts, tools) {
|
|
|
54
55
|
lastTopLevelTools = tools;
|
|
55
56
|
return tools;
|
|
56
57
|
}
|
|
58
|
+
// Append a model turn or tool result to history AND tell the observer (stream-json).
|
|
59
|
+
// Only these two kinds are streamed: injected user notes are the agent's own
|
|
60
|
+
// plumbing, and compaction rewrites the array wholesale (context.ts) — tapping
|
|
61
|
+
// messages.push itself would replay the entire history mid-stream. Sub-agents
|
|
62
|
+
// inherit opts by spread, so they are filtered here: their turns are not ours.
|
|
63
|
+
function record(messages, opts, message) {
|
|
64
|
+
messages.push(message);
|
|
65
|
+
if (!opts.subAgent && !opts.teammate)
|
|
66
|
+
opts.onMessage?.(message);
|
|
67
|
+
}
|
|
57
68
|
// Resolve the output sink for a loop: the caller's sink, or stdout (the old
|
|
58
69
|
// behaviour) when none was passed. Centralised so every site reads the same way.
|
|
59
70
|
function sink(opts) {
|
|
@@ -305,8 +316,13 @@ function toolsFor(opts) {
|
|
|
305
316
|
return builtins.filter((t) => !(t.type === "function" && t.function.name === "todo_write"));
|
|
306
317
|
}
|
|
307
318
|
// The Lead can do everything: orchestration, the protocols, and authoring +
|
|
308
|
-
// watching the board (it lays out tasks; teammates pull from it).
|
|
309
|
-
|
|
319
|
+
// watching the board (it lays out tasks; teammates pull from it). update_goal
|
|
320
|
+
// (Day 41) appears only once the user has set a /goal — no goal, nothing to end.
|
|
321
|
+
return [...builtins, taskTool, askUserTool, spawnTeammateTool, sendMessageTool, requestShutdownTool, requestPlanTool, reviewPlanTool, createTaskTool, listTasksTool, ...(currentSession() ? [listPeersTool] : []), ...(getGoal() ? [updateGoalTool] : [])];
|
|
322
|
+
}
|
|
323
|
+
// The names the top-level agent would be offered right now — stream-json's init line.
|
|
324
|
+
export function topLevelToolNames() {
|
|
325
|
+
return toolsFor({}).flatMap((t) => (t.type === "function" ? [t.function.name] : []));
|
|
310
326
|
}
|
|
311
327
|
const pendingSubAgents = new Map();
|
|
312
328
|
let subAgentSeq = 0;
|
|
@@ -581,6 +597,8 @@ async function runOneCall(call, opts) {
|
|
|
581
597
|
const spinner = !opts.quiet && !opts.subAgent ? sink(opts).spinner(status()) : null;
|
|
582
598
|
const timer = spinner ? setInterval(() => spinner.set(status()), 1000) : undefined;
|
|
583
599
|
emit("agent_tool_call", { tool: call.name });
|
|
600
|
+
if (!opts.subAgent)
|
|
601
|
+
countGoalToolCall(); // a goal turn with zero tool calls pauses the goal (Day 41)
|
|
584
602
|
try {
|
|
585
603
|
const refusal = await authorizeCall(call, opts);
|
|
586
604
|
const content = refusal ?? await runWithHooks(call, opts);
|
|
@@ -682,6 +700,29 @@ async function authorizeCall(call, opts) {
|
|
|
682
700
|
}
|
|
683
701
|
if (opts.signal.aborted || opts.isInterrupted())
|
|
684
702
|
return "[permission] Interrupted before execution.";
|
|
703
|
+
// Before the human is asked, a PermissionRequest hook may decide (this is
|
|
704
|
+
// how an orchestrator approves on the human's behalf). A deny is always
|
|
705
|
+
// honoured. An allow is not when a rule says a human must decide: then the
|
|
706
|
+
// question still goes to the human. Sub-agents skip hooks, as everywhere.
|
|
707
|
+
if (!autoAllowed && !opts.subAgent) {
|
|
708
|
+
const decision = await runPermissionHooks({ tool: call.name, args: call.args, reason: v.reason, summary: v.summary });
|
|
709
|
+
if (opts.signal.aborted || opts.isInterrupted())
|
|
710
|
+
return "[permission] Interrupted before execution.";
|
|
711
|
+
if (decision?.behavior === "deny") {
|
|
712
|
+
emit("agent_tool_declined", { tool: call.name });
|
|
713
|
+
if (!opts.quiet)
|
|
714
|
+
sink(opts).note(mark.declined);
|
|
715
|
+
return `[permission] Action not approved: ${decision.message}. Ask the user how to proceed, or choose a safer alternative.`;
|
|
716
|
+
}
|
|
717
|
+
if (decision?.behavior === "allow" && !v.requiresHuman) {
|
|
718
|
+
autoAllowed = true;
|
|
719
|
+
if (!opts.quiet)
|
|
720
|
+
recordToolDetail(call.id, "Approved by a PermissionRequest hook");
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
await runHooks("Notification", { notification_type: "permission_prompt", message: `approval needed: ${call.name}`, tool: call.name, args: call.args });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
685
726
|
// An unattended callback may contain the old blanket auto-approve policy.
|
|
686
727
|
// Auto mode never uses that policy as a fallback when Jev did not approve.
|
|
687
728
|
// A diff is needed when the human is deciding; automatic edits stay in
|
|
@@ -791,6 +832,14 @@ async function execute(call, opts) {
|
|
|
791
832
|
return runClaimTask(call, opts);
|
|
792
833
|
if (call.name === "complete_task")
|
|
793
834
|
return runCompleteTask(call, opts);
|
|
835
|
+
// /goal (Day 41): loop-level because the verifier is a model call (needs the
|
|
836
|
+
// client) and reads the conversation. The goal is the user's — top-level only.
|
|
837
|
+
if (call.name === "update_goal") {
|
|
838
|
+
if (opts.subAgent || !opts.transcript)
|
|
839
|
+
return "[error] Only the top-level agent can end the goal.";
|
|
840
|
+
return runUpdateGoal(call.args, { client: opts.client, model: subAgentModelFor(opts), signal: opts.signal, transcript: opts.transcript, note: (line) => { if (!opts.quiet)
|
|
841
|
+
sink(opts).note(chalk.cyan(` ${line}`)); } });
|
|
842
|
+
}
|
|
794
843
|
if (call.name !== "task")
|
|
795
844
|
return dispatch(call.name, call.args, opts.signal); // ordinary tools go through the registry (signal lets Ctrl+C kill run_bash)
|
|
796
845
|
if (opts.subAgent)
|
|
@@ -1316,7 +1365,7 @@ export async function runLoop(messages, // conversation history (mutated in plac
|
|
|
1316
1365
|
opts) {
|
|
1317
1366
|
// Children inherit this snapshot. Later human turns cannot silently grant
|
|
1318
1367
|
// broader authorization to already-running background work.
|
|
1319
|
-
opts = { ...opts, reviewBudget: opts.reviewBudget ?? new AutoReviewBudget(), autoRequests: opts.autoRequests ?? opts.autoMode?.snapshot(), followUps: opts.subAgent ? undefined : opts.followUps };
|
|
1368
|
+
opts = { ...opts, reviewBudget: opts.reviewBudget ?? new AutoReviewBudget(), autoRequests: opts.autoRequests ?? opts.autoMode?.snapshot(), followUps: opts.subAgent ? undefined : opts.followUps, transcript: opts.subAgent ? undefined : messages };
|
|
1320
1369
|
const recordHumanInput = (text) => {
|
|
1321
1370
|
opts.autoMode?.recordRequest(text);
|
|
1322
1371
|
opts.autoRequests = [...(opts.autoRequests ?? []), text];
|
|
@@ -1438,13 +1487,13 @@ opts) {
|
|
|
1438
1487
|
// partial text in history (clearly marked) so the next turn makes sense.
|
|
1439
1488
|
if (opts.isInterrupted()) {
|
|
1440
1489
|
if (out.content)
|
|
1441
|
-
messages
|
|
1490
|
+
record(messages, opts, { role: "assistant", content: out.content + "\n[interrupted by user]" }); // text only — partial tool calls must NOT go in (they would need paired results)
|
|
1442
1491
|
return { reason: TerminateReason.UserInterrupt };
|
|
1443
1492
|
}
|
|
1444
1493
|
attempts.consecutive = 0; // success resets the breaker — but never the total budget
|
|
1445
1494
|
// Rebuild the assistant message from the assembled stream and add it to
|
|
1446
1495
|
// history — tool results must stay paired with their calls.
|
|
1447
|
-
messages
|
|
1496
|
+
record(messages, opts, {
|
|
1448
1497
|
role: "assistant", // the model's turn
|
|
1449
1498
|
content: out.content || null, // null when the turn was tool calls only
|
|
1450
1499
|
...(out.reasoning !== undefined ? { reasoning_content: out.reasoning } : {}),
|
|
@@ -1463,7 +1512,7 @@ opts) {
|
|
|
1463
1512
|
// runs the tests, blocks while they fail, and the agent keeps fixing.
|
|
1464
1513
|
// Sub-agents are exempt — Stop hooks are the human's project policy.
|
|
1465
1514
|
if (!opts.subAgent) {
|
|
1466
|
-
const stop = await runHooks("Stop", { finalText: out.content }); // give hooks the last word
|
|
1515
|
+
const stop = await runHooks("Stop", { finalText: out.content, last_assistant_message: out.content, stop_hook_active: false }); // give hooks the last word
|
|
1467
1516
|
if (stop.block) {
|
|
1468
1517
|
emit("agent_hook_block", { event: "Stop" }); // the agent was sent back to work
|
|
1469
1518
|
if (!opts.quiet)
|
|
@@ -1508,7 +1557,7 @@ opts) {
|
|
|
1508
1557
|
// Resolve every outstanding tool ID BEFORE adding a user message.
|
|
1509
1558
|
// Unstarted actions came from the old intent and must be replanned.
|
|
1510
1559
|
for (const call of out.toolCalls.slice(i))
|
|
1511
|
-
messages
|
|
1560
|
+
record(messages, opts, { role: "tool", tool_call_id: call.id, content: "[follow-up] Not executed: new user input arrived. Reconsider this action using the latest instructions." });
|
|
1512
1561
|
receiveFollowUps();
|
|
1513
1562
|
break;
|
|
1514
1563
|
}
|
|
@@ -1519,12 +1568,12 @@ opts) {
|
|
|
1519
1568
|
// Run the whole read-only batch at once, preserving result order.
|
|
1520
1569
|
const results = await Promise.all(batch.map((c) => runOneCall(c, { ...opts, autoHistory: reviewHistory(messages, opts.autoHistory) })));
|
|
1521
1570
|
for (const r of results)
|
|
1522
|
-
messages
|
|
1571
|
+
record(messages, opts, { role: "tool", tool_call_id: r.id, content: r.content });
|
|
1523
1572
|
continue; // back to the top — the next call is non-read-only
|
|
1524
1573
|
}
|
|
1525
1574
|
// A single non-read-only call: gate, maybe ask, execute — all serial.
|
|
1526
1575
|
const r = await runOneCall(out.toolCalls[i++], { ...opts, autoHistory: reviewHistory(messages, opts.autoHistory) });
|
|
1527
|
-
messages
|
|
1576
|
+
record(messages, opts, { role: "tool", tool_call_id: r.id, content: r.content });
|
|
1528
1577
|
}
|
|
1529
1578
|
// A launched skill's body arrives as its own user message, after ALL of
|
|
1530
1579
|
// this round's tool results (the tool result itself only says "Launching
|
package/dist/permissions.js
CHANGED
|
@@ -617,6 +617,10 @@ function basePermission(toolName, argsJson, auto) {
|
|
|
617
617
|
// Task board (Day 40) — bookkeeping on the internal board, not the user's
|
|
618
618
|
// project. The actual work a claimed task drives still passes this gate.
|
|
619
619
|
return { decision: "allow", reason: "task board, no project side effects", summary: toolName };
|
|
620
|
+
case "update_goal":
|
|
621
|
+
// /goal (Day 41) — ends the user's goal. Bookkeeping; the only command it
|
|
622
|
+
// runs is the user's own /goal --check, which the model cannot change.
|
|
623
|
+
return { decision: "allow", reason: "goal bookkeeping, runs only the user's own --check", summary: toolName };
|
|
620
624
|
case "ask_user":
|
|
621
625
|
// Asking the user a question has no side effects — it's the safest thing
|
|
622
626
|
// the model can do. Never gate it behind an approval prompt.
|
package/dist/print.js
CHANGED
|
@@ -1,11 +1,79 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
export function formatPrintResult(format, data) {
|
|
2
3
|
if (format === "text")
|
|
3
4
|
return data.result.finalText ? `${data.result.finalText}\n` : "";
|
|
5
|
+
if (format === "stream-json")
|
|
6
|
+
return streamJsonResult(data);
|
|
4
7
|
return JSON.stringify({ type: "result", is_error: data.result.reason !== "done", reason: data.result.reason,
|
|
5
8
|
result: data.result.finalText ?? "", session_id: data.sessionId, model: data.model, effort: data.effort,
|
|
6
9
|
usage: { input_tokens: data.usage.inputUncached + data.usage.inputCached, cached_input_tokens: data.usage.inputCached,
|
|
7
10
|
output_tokens: data.usage.output, model_calls: data.usage.calls }, estimated_cost_usd: data.usage.cost }) + "\n";
|
|
8
11
|
}
|
|
12
|
+
// ---- stream-json: Claude Code's NDJSON event protocol -----------------------------
|
|
13
|
+
// `claude -p --output-format stream-json` prints one JSON object per line:
|
|
14
|
+
// system/init → assistant / user (tool_result) … → result
|
|
15
|
+
// Tools that parse Claude's stream (SDK wrappers, CI dashboards, log viewers)
|
|
16
|
+
// can read ours unchanged. Our history is OpenAI-shaped, so each message is
|
|
17
|
+
// translated at the boundary: tool_calls become tool_use blocks, role:"tool"
|
|
18
|
+
// becomes a user message carrying a tool_result, reasoning_content becomes a
|
|
19
|
+
// thinking block. Tool NAMES stay ours (run_bash, not Bash) — they are what the
|
|
20
|
+
// model actually called. Sub-agent turns are not streamed, so every event has
|
|
21
|
+
// parent_tool_use_id: null.
|
|
22
|
+
// Every line carries a fresh uuid and the session id, like Claude's.
|
|
23
|
+
const event = (sessionId, body) => JSON.stringify({ ...body, session_id: sessionId, uuid: randomUUID() }) + "\n";
|
|
24
|
+
// The first line: what this run is equipped with, before any model call.
|
|
25
|
+
export function streamJsonInit(data) {
|
|
26
|
+
return event(data.sessionId, { type: "system", subtype: "init", cwd: process.cwd(), tools: data.tools,
|
|
27
|
+
mcp_servers: data.mcpServers, model: data.model, permissionMode: data.permissionMode });
|
|
28
|
+
}
|
|
29
|
+
// Our tool results mark failure with a bracketed prefix, not a flag.
|
|
30
|
+
const TOOL_ERROR = /^\[(?:error|permission|hook|follow-up)\]/;
|
|
31
|
+
// One history message → zero or more events. Claude emits one assistant event
|
|
32
|
+
// per content block (sharing the message id), so consumers can render each
|
|
33
|
+
// block as it lands; we do the same.
|
|
34
|
+
export function streamJsonMessage(sessionId, model, message) {
|
|
35
|
+
if (message.role === "tool") {
|
|
36
|
+
const content = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
|
|
37
|
+
return event(sessionId, { type: "user", parent_tool_use_id: null, message: { role: "user",
|
|
38
|
+
content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content, is_error: TOOL_ERROR.test(content) }] } });
|
|
39
|
+
}
|
|
40
|
+
if (message.role !== "assistant")
|
|
41
|
+
return "";
|
|
42
|
+
const blocks = [];
|
|
43
|
+
const reasoning = message.reasoning_content;
|
|
44
|
+
if (reasoning)
|
|
45
|
+
blocks.push({ type: "thinking", thinking: reasoning });
|
|
46
|
+
if (typeof message.content === "string" && message.content)
|
|
47
|
+
blocks.push({ type: "text", text: message.content });
|
|
48
|
+
for (const call of message.tool_calls ?? []) {
|
|
49
|
+
if (call.type !== "function")
|
|
50
|
+
continue;
|
|
51
|
+
// The model can emit malformed arguments; the stream must never throw over it.
|
|
52
|
+
let input;
|
|
53
|
+
try {
|
|
54
|
+
input = JSON.parse(call.function.arguments || "{}");
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
input = call.function.arguments;
|
|
58
|
+
}
|
|
59
|
+
blocks.push({ type: "tool_use", id: call.id, name: call.function.name, input });
|
|
60
|
+
}
|
|
61
|
+
const id = `msg_${randomUUID()}`;
|
|
62
|
+
const stopReason = message.tool_calls?.length ? "tool_use" : "end_turn";
|
|
63
|
+
return blocks.map((block) => event(sessionId, { type: "assistant", parent_tool_use_id: null, message: {
|
|
64
|
+
id, type: "message", role: "assistant", model, content: [block], stop_reason: stopReason, stop_sequence: null
|
|
65
|
+
} })).join("");
|
|
66
|
+
}
|
|
67
|
+
// The last line. Usage follows Anthropic's split (uncached input vs cache
|
|
68
|
+
// reads), which differs from our `json` format's input_tokens (the total).
|
|
69
|
+
function streamJsonResult(data) {
|
|
70
|
+
const ok = data.result.reason === "done";
|
|
71
|
+
return event(data.sessionId, { type: "result", subtype: ok ? "success" : "error_during_execution", is_error: !ok,
|
|
72
|
+
duration_ms: data.durationMs ?? 0, num_turns: data.numTurns ?? 0, result: data.result.finalText ?? "",
|
|
73
|
+
total_cost_usd: data.usage.cost, usage: { input_tokens: data.usage.inputUncached, cache_creation_input_tokens: 0,
|
|
74
|
+
cache_read_input_tokens: data.usage.inputCached, output_tokens: data.usage.output },
|
|
75
|
+
permission_denials: [], reason: data.result.reason });
|
|
76
|
+
}
|
|
9
77
|
// stdout is a protocol in print mode, even when an MCP startup or hook helper
|
|
10
78
|
// logs directly. Reserve the original writer for the result and route all
|
|
11
79
|
// incidental output to stderr. Only the CLI entry point installs this guard.
|
package/dist/session.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs"; // session files live on disk
|
|
2
2
|
import path from "node:path"; // path joining
|
|
3
|
+
import { getGoal } from "./goal.js"; // the /goal rides along in the session file (Day 41)
|
|
3
4
|
// Sessions are project-local: .mini-agent/sessions/<id>.json under the cwd.
|
|
4
5
|
// Project-local (not global) because a conversation is about a codebase —
|
|
5
6
|
// resuming a refactor of project A from inside project B makes no sense.
|
|
@@ -7,6 +8,15 @@ import path from "node:path"; // path joining
|
|
|
7
8
|
// working directory — and so tests can chdir into a scratch dir.
|
|
8
9
|
const sessionsDir = () => path.resolve(".mini-agent", "sessions");
|
|
9
10
|
const KEEP_SESSIONS = 20; // how many session files survive pruning
|
|
11
|
+
// Where a session's file lives — absolute, so it can be handed to hooks.
|
|
12
|
+
export function sessionPath(id) {
|
|
13
|
+
return path.join(sessionsDir(), `${id}.json`);
|
|
14
|
+
}
|
|
15
|
+
// A session id becomes a file name, so one chosen from outside (--session-id)
|
|
16
|
+
// must not be able to name a path.
|
|
17
|
+
export function isValidSessionId(id) {
|
|
18
|
+
return /^[A-Za-z0-9._-]+$/.test(id) && id !== "." && id !== "..";
|
|
19
|
+
}
|
|
10
20
|
// A fresh, filename-safe session id derived from the wall clock.
|
|
11
21
|
export function newSessionId() {
|
|
12
22
|
return new Date().toISOString().replace(/[:.]/g, "-"); // 2026-06-12T10-30-00-000Z
|
|
@@ -27,6 +37,7 @@ export function saveSession(id, model, messages, title) {
|
|
|
27
37
|
model, // informational — resume does not force the same model
|
|
28
38
|
title: title ?? existing?.title, // a generated title sticks across saves
|
|
29
39
|
messages: messages.filter((m) => m.role !== "system"), // the constitution is rebuilt fresh on resume (AGENT.md may have changed)
|
|
40
|
+
goal: getGoal() ?? undefined, // the live goal, snapshotted with the conversation it belongs to
|
|
30
41
|
};
|
|
31
42
|
const tmp = `${file}.tmp`; // write next to the destination (same filesystem → rename is atomic)
|
|
32
43
|
fs.writeFileSync(tmp, JSON.stringify(data)); // the new snapshot
|
|
@@ -93,6 +104,7 @@ export function sessionTitle(messages) {
|
|
|
93
104
|
const firstUser = messages.find((m) => m.role === "user");
|
|
94
105
|
let text = typeof firstUser?.content === "string" ? firstUser.content : "";
|
|
95
106
|
text = text.split("\n\n[Referenced files")[0]; // drop the @file attachment the REPL appended (Day 26)
|
|
107
|
+
text = text.match(/^\[goal\][^\n]*\nObjective: ([^\n]*)/)?.[1] ?? text; // a session started with /goal is named after its objective (Day 41)
|
|
96
108
|
const line = text.replace(/\s+/g, " ").trim(); // flatten to one line
|
|
97
109
|
return line || "(no prompt)";
|
|
98
110
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-from-zero",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"mini-agent": "dist/agent.js"
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"auto:report": "tsx eval/auto-report.ts",
|
|
18
18
|
"build": "tsc -p tsconfig.build.json",
|
|
19
19
|
"prepare": "npm run build",
|
|
20
|
-
"test": "export MINI_AGENT_REVIEW_DEBUG=0 && tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts && tsx tests/mcp.test.ts && tsx tests/judge.test.ts && tsx tests/auto.test.ts && tsx tests/auto-context.test.ts && tsx tests/auto-providers.test.ts && tsx tests/auto-cascade.test.ts && tsx tests/auto-rules.test.ts && tsx tests/auto-rule-cascade.test.ts && tsx tests/auto-debug.test.ts && tsx tests/auto-report.test.ts && tsx tests/auto-clarification.test.ts && tsx tests/memory.test.ts && tsx tests/cost.test.ts && tsx tests/cli.test.ts && tsx tests/print.test.ts && tsx tests/status.test.ts && tsx tests/validate.test.ts && tsx tests/plan.test.ts && tsx tests/diff.test.ts && tsx tests/undo.test.ts && tsx tests/tiering.test.ts && tsx tests/mcp-http.test.ts && tsx tests/mcp-oauth.test.ts && tsx tests/changes.test.ts && tsx tests/mentions.test.ts && tsx tests/session.test.ts && tsx tests/title.test.ts && tsx tests/ui.test.ts && tsx tests/activity.test.ts && tsx tests/follow-up.test.ts && tsx tests/follow-up-ui.test.tsx && tsx tests/agents.test.ts && tsx tests/images.test.ts && tsx tests/approval.test.ts && tsx tests/form.test.ts && tsx tests/model.test.ts && tsx tests/reasoning.test.ts && tsx tests/effort.test.ts && tsx tests/completion.test.ts && tsx tests/pasted-text.test.ts && tsx tests/skills.test.ts && tsx tests/markdown.test.ts && tsx tests/editor.test.ts && tsx tests/todos.test.ts && tsx tests/background.test.ts && tsx tests/team.test.ts && tsx tests/peers.test.ts && tsx tests/suggestion.test.ts && tsx tests/board.test.ts && tsx tests/drop.test.ts && tsx tests/editline.test.ts && tsx tests/cron.test.ts"
|
|
20
|
+
"test": "export MINI_AGENT_REVIEW_DEBUG=0 && tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts && tsx tests/mcp.test.ts && tsx tests/judge.test.ts && tsx tests/auto.test.ts && tsx tests/auto-context.test.ts && tsx tests/auto-providers.test.ts && tsx tests/auto-cascade.test.ts && tsx tests/auto-rules.test.ts && tsx tests/auto-rule-cascade.test.ts && tsx tests/auto-debug.test.ts && tsx tests/auto-report.test.ts && tsx tests/auto-clarification.test.ts && tsx tests/memory.test.ts && tsx tests/cost.test.ts && tsx tests/cli.test.ts && tsx tests/print.test.ts && tsx tests/status.test.ts && tsx tests/validate.test.ts && tsx tests/plan.test.ts && tsx tests/diff.test.ts && tsx tests/undo.test.ts && tsx tests/tiering.test.ts && tsx tests/mcp-http.test.ts && tsx tests/mcp-oauth.test.ts && tsx tests/changes.test.ts && tsx tests/mentions.test.ts && tsx tests/session.test.ts && tsx tests/title.test.ts && tsx tests/ui.test.ts && tsx tests/activity.test.ts && tsx tests/follow-up.test.ts && tsx tests/follow-up-ui.test.tsx && tsx tests/agents.test.ts && tsx tests/images.test.ts && tsx tests/approval.test.ts && tsx tests/form.test.ts && tsx tests/model.test.ts && tsx tests/reasoning.test.ts && tsx tests/effort.test.ts && tsx tests/completion.test.ts && tsx tests/pasted-text.test.ts && tsx tests/skills.test.ts && tsx tests/markdown.test.ts && tsx tests/editor.test.ts && tsx tests/todos.test.ts && tsx tests/background.test.ts && tsx tests/team.test.ts && tsx tests/peers.test.ts && tsx tests/suggestion.test.ts && tsx tests/board.test.ts && tsx tests/drop.test.ts && tsx tests/editline.test.ts && tsx tests/cron.test.ts && tsx tests/goal.test.ts"
|
|
21
21
|
},
|
|
22
22
|
"engines": {
|
|
23
23
|
"node": ">=20.6"
|