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.
@@ -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
@@ -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({ resume: opts.resume });
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
- if (opts.resume) {
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
- return [...builtins, taskTool, askUserTool, spawnTeammateTool, sendMessageTool, requestShutdownTool, requestPlanTool, reviewPlanTool, createTaskTool, listTasksTool, ...(currentSession() ? [listPeersTool] : [])];
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.push({ role: "assistant", content: out.content + "\n[interrupted by user]" }); // text only — partial tool calls must NOT go in (they would need paired results)
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.push({
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.push({ role: "tool", tool_call_id: call.id, content: "[follow-up] Not executed: new user input arrived. Reconsider this action using the latest instructions." });
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.push({ role: "tool", tool_call_id: r.id, content: r.content });
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.push({ role: "tool", tool_call_id: r.id, content: r.content });
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
@@ -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.5",
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"