@falkenslab/agent-kit 0.15.0 → 0.16.1

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.
Files changed (68) hide show
  1. package/README.md +3 -1
  2. package/assets/knowledge-plugin/.claude-plugin/plugin.json +1 -1
  3. package/assets/knowledge-plugin/commands/ingest.md +1 -1
  4. package/assets/knowledge-plugin/commands/lint.md +1 -1
  5. package/assets/knowledge-plugin/skills/knowledge-ingest/SKILL.md +13 -19
  6. package/assets/knowledge-plugin/skills/knowledge-lint/SKILL.md +18 -14
  7. package/assets/knowledge-plugin/skills/knowledge-query/SKILL.md +5 -5
  8. package/assets/knowledge-plugin-files/.claude-plugin/plugin.json +4 -0
  9. package/assets/knowledge-plugin-files/commands/ingest.md +7 -0
  10. package/assets/knowledge-plugin-files/commands/lint.md +5 -0
  11. package/assets/knowledge-plugin-files/commands/query.md +5 -0
  12. package/assets/knowledge-plugin-files/skills/knowledge-ingest/SKILL.md +46 -0
  13. package/assets/knowledge-plugin-files/skills/knowledge-lint/SKILL.md +26 -0
  14. package/assets/knowledge-plugin-files/skills/knowledge-query/SKILL.md +14 -0
  15. package/dist/core/agentSpec.d.ts +27 -2
  16. package/dist/core/extractText.d.ts +13 -0
  17. package/dist/core/extractText.js +147 -0
  18. package/dist/core/fileKnowledgeStore.d.ts +10 -0
  19. package/dist/core/fileKnowledgeStore.js +395 -0
  20. package/dist/core/hooks/fileScopeGate.d.ts +8 -0
  21. package/dist/core/hooks/fileScopeGate.js +20 -0
  22. package/dist/core/hooks/humanInput.d.ts +18 -0
  23. package/dist/core/hooks/humanInput.js +30 -2
  24. package/dist/core/hooks/planGate.js +14 -5
  25. package/dist/core/hooks/stepGate.js +6 -0
  26. package/dist/core/interaction.d.ts +20 -0
  27. package/dist/core/knowledge.d.ts +14 -4
  28. package/dist/core/knowledge.js +36 -6
  29. package/dist/core/knowledgeStore.d.ts +139 -0
  30. package/dist/core/knowledgeStore.js +84 -0
  31. package/dist/core/messages/de.js +65 -1
  32. package/dist/core/messages/en.d.ts +39 -0
  33. package/dist/core/messages/en.js +65 -1
  34. package/dist/core/messages/es.js +65 -1
  35. package/dist/core/messages/fr.js +65 -1
  36. package/dist/core/modeControl.d.ts +36 -0
  37. package/dist/core/modeControl.js +61 -0
  38. package/dist/core/session.d.ts +6 -31
  39. package/dist/core/session.js +81 -71
  40. package/dist/core/sources.d.ts +122 -0
  41. package/dist/core/sources.js +312 -0
  42. package/dist/core/todos.d.ts +23 -0
  43. package/dist/core/todos.js +33 -0
  44. package/dist/core/toolLabels.d.ts +1 -1
  45. package/dist/core/toolLabels.js +42 -2
  46. package/dist/core/tools/humanApproval.d.ts +9 -5
  47. package/dist/core/tools/humanApproval.js +39 -7
  48. package/dist/core/tools/knowledgeTools.d.ts +16 -0
  49. package/dist/core/tools/knowledgeTools.js +168 -0
  50. package/dist/core/tools/saveToSources.d.ts +27 -8
  51. package/dist/core/tools/saveToSources.js +217 -55
  52. package/dist/core/tools/time.d.ts +29 -0
  53. package/dist/core/tools/time.js +129 -0
  54. package/dist/core/version.d.ts +5 -0
  55. package/dist/core/version.js +12 -0
  56. package/dist/index.d.ts +7 -3
  57. package/dist/index.js +5 -1
  58. package/dist/tui/chatTui.js +14 -1
  59. package/dist/tui/consoleRenderer.js +16 -2
  60. package/dist/tui/ink/SessionView.js +67 -17
  61. package/dist/tui/ink/inkInteraction.d.ts +4 -2
  62. package/dist/tui/ink/inkInteraction.js +20 -2
  63. package/dist/tui/ink/runChatInk.js +5 -0
  64. package/dist/tui/ink/sessionModel.d.ts +3 -0
  65. package/dist/tui/ink/sessionModel.js +14 -1
  66. package/dist/tui/terminalInteraction.js +2 -0
  67. package/package.json +31 -1
  68. /package/assets/{knowledge-plugin → knowledge-plugin-files}/skills/knowledge-pages/SKILL.md +0 -0
@@ -0,0 +1,129 @@
1
+ import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
2
+ import { z } from "zod";
3
+ /**
4
+ * The date and time, and date arithmetic, for every agent: the kit puts no date in the
5
+ * prompt and only subagents have Bash, so without these an agent can't know what day it
6
+ * is, and models get "days until" and "three weeks from" wrong often enough to matter
7
+ * (deadlines, a course's calendar). Only reads: the plan gate lets the server through.
8
+ */
9
+ const DAY_MS = 86_400_000;
10
+ const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
11
+ /** "now" in `timeZone`, as calendar parts. */
12
+ export function nowIn(timeZone, now = new Date()) {
13
+ const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", {
14
+ timeZone,
15
+ year: "numeric",
16
+ month: "2-digit",
17
+ day: "2-digit",
18
+ hour: "2-digit",
19
+ minute: "2-digit",
20
+ second: "2-digit",
21
+ hourCycle: "h23",
22
+ timeZoneName: "longOffset",
23
+ })
24
+ .formatToParts(now)
25
+ .map((part) => [part.type, part.value]));
26
+ const date = `${parts.year}-${parts.month}-${parts.day}`;
27
+ return {
28
+ date,
29
+ time: `${parts.hour}:${parts.minute}:${parts.second}`,
30
+ weekday: WEEKDAYS[toDay(date).getUTCDay()],
31
+ timeZone,
32
+ utcOffset: parts.timeZoneName.replace("GMT", "") || "+00:00",
33
+ utc: now.toISOString(),
34
+ };
35
+ }
36
+ /** A YYYY-MM-DD calendar date as midnight UTC, so arithmetic never meets daylight saving. */
37
+ function toDay(date) {
38
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
39
+ if (!match)
40
+ throw new Error(`"${date}" isn't a date in YYYY-MM-DD form.`);
41
+ const day = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
42
+ if (day.toISOString().slice(0, 10) !== date)
43
+ throw new Error(`"${date}" isn't a real date.`);
44
+ return day;
45
+ }
46
+ const fromDay = (day) => day.toISOString().slice(0, 10);
47
+ const isWorkingDay = (day) => day.getUTCDay() !== 0 && day.getUTCDay() !== 6;
48
+ /** Days, weeks and working days (Monday to Friday) from `from` to `to`; negative when `to` is earlier. */
49
+ export function dateDifference(from, to) {
50
+ const start = toDay(from);
51
+ const end = toDay(to);
52
+ const days = Math.round((end.getTime() - start.getTime()) / DAY_MS);
53
+ // Working days counted from the day after the earlier date up to the later one included, as
54
+ // "in N working days"; the same count, negative, when `to` is earlier.
55
+ const [first, last] = days < 0 ? [end, start] : [start, end];
56
+ let workingDays = 0;
57
+ for (let day = first.getTime() + DAY_MS; day <= last.getTime(); day += DAY_MS)
58
+ if (isWorkingDay(new Date(day)))
59
+ workingDays++;
60
+ return { from, to, days, weeks: Math.trunc(days / 7), remainderDays: days % 7, workingDays: days < 0 ? -workingDays : workingDays, toWeekday: WEEKDAYS[end.getUTCDay()] };
61
+ }
62
+ /** `date` plus `amount` (negative to go back) `unit`s; months keep the day, or the month's last one. */
63
+ export function dateAdd(date, amount, unit) {
64
+ if (!Number.isInteger(amount))
65
+ throw new Error("The amount must be a whole number.");
66
+ const start = toDay(date);
67
+ let result;
68
+ if (unit === "days" || unit === "weeks") {
69
+ result = new Date(start.getTime() + amount * (unit === "weeks" ? 7 : 1) * DAY_MS);
70
+ }
71
+ else if (unit === "months") {
72
+ const target = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + amount, 1));
73
+ const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
74
+ result = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth(), Math.min(start.getUTCDate(), lastDay)));
75
+ }
76
+ else {
77
+ result = start;
78
+ const step = Math.sign(amount);
79
+ for (let left = Math.abs(amount); left > 0;) {
80
+ result = new Date(result.getTime() + step * DAY_MS);
81
+ if (isWorkingDay(result))
82
+ left--;
83
+ }
84
+ }
85
+ return { date: fromDay(result), weekday: WEEKDAYS[result.getUTCDay()] };
86
+ }
87
+ const text = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }] });
88
+ const failure = (error) => ({ content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], isError: true });
89
+ /** The time zone the tools answer in: `timeZone` if valid, otherwise the system's. */
90
+ export function resolveTimeZone(timeZone) {
91
+ const system = Intl.DateTimeFormat().resolvedOptions().timeZone;
92
+ if (!timeZone)
93
+ return system;
94
+ try {
95
+ new Intl.DateTimeFormat("en", { timeZone });
96
+ return timeZone;
97
+ }
98
+ catch {
99
+ throw new Error(`Unknown time zone "${timeZone}" (an IANA name, e.g. "Europe/Madrid").`);
100
+ }
101
+ }
102
+ /** `current_time` and `date_math`, in `timeZone` (an IANA name; the system's if not given). */
103
+ export function createTimeServer(timeZone) {
104
+ const zone = resolveTimeZone(timeZone);
105
+ const currentTime = tool("current_time", "The current date and time: the local date (YYYY-MM-DD), time, weekday, time zone and UTC offset, and the instant in UTC. Use it whenever you need today's date or the time; don't guess them.", {}, async () => text(nowIn(zone)), { annotations: { readOnlyHint: true } });
106
+ const dateMath = tool("date_math", 'Exact date arithmetic on calendar dates (YYYY-MM-DD, local). "difference": days, weeks and working days (Monday to Friday) from `from` to `to`. "add": `from` plus `amount` (negative to go back) days, weeks, months or working days. `from` defaults to today. Use it instead of counting dates yourself.', {
107
+ operation: z.enum(["difference", "add"]),
108
+ from: z.string().optional().describe("Start date, YYYY-MM-DD; today if omitted"),
109
+ to: z.string().optional().describe('End date, YYYY-MM-DD (for "difference")'),
110
+ amount: z.number().int().optional().describe('How many units to add, negative to subtract (for "add")'),
111
+ unit: z.enum(["days", "weeks", "months", "working_days"]).optional().describe('The unit of `amount` (for "add"); days if omitted'),
112
+ }, async (args) => {
113
+ try {
114
+ const from = args.from ?? nowIn(zone).date;
115
+ if (args.operation === "difference") {
116
+ if (!args.to)
117
+ throw new Error('"difference" needs `to`.');
118
+ return text(dateDifference(from, args.to));
119
+ }
120
+ if (args.amount === undefined)
121
+ throw new Error('"add" needs `amount`.');
122
+ return text({ from, ...dateAdd(from, args.amount, args.unit ?? "days") });
123
+ }
124
+ catch (error) {
125
+ return failure(error);
126
+ }
127
+ }, { annotations: { readOnlyHint: true } });
128
+ return createSdkMcpServer({ name: "time", version: "1.0.0", tools: [currentTime, dateMath] });
129
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The version of agent-kit in use, read from its own `package.json` (next to `dist/`, or
3
+ * `src/` under tsx), e.g. to show it in an agent's header or log.
4
+ */
5
+ export declare function agentKitVersion(): string;
@@ -0,0 +1,12 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ let cached;
5
+ /**
6
+ * The version of agent-kit in use, read from its own `package.json` (next to `dist/`, or
7
+ * `src/` under tsx), e.g. to show it in an agent's header or log.
8
+ */
9
+ export function agentKitVersion() {
10
+ cached ??= JSON.parse(readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"), "utf8")).version;
11
+ return cached;
12
+ }
package/dist/index.d.ts CHANGED
@@ -10,6 +10,10 @@ export type { Mode, BaseSessionConfig, AgentSpec, PlanModeSpec } from "./core/ag
10
10
  export { buildSessionOptions, createInputQueue, createDeferred, createModeControl, togglePlanMode, type ModeControl } from "./core/session.js";
11
11
  export { runQuery, type AgentEvent, type AgentRun, type SessionUsage, type ContextUsage } from "./core/runner.js";
12
12
  export { knowledgePluginRoot, knowledgePromptSection } from "./core/knowledge.js";
13
+ export { agentKitVersion } from "./core/version.js";
14
+ export { BUILT_IN_PAGE_TYPES, isSlug, parseId, linkedIds, type KnowledgeStore, type PageType, type KnowledgePage, type PageInfo, type SearchHit, type CheckReport, type FieldChanges, type NewPage, } from "./core/knowledgeStore.js";
15
+ export { createFileKnowledgeStore, type FileKnowledgeStoreOptions } from "./core/fileKnowledgeStore.js";
16
+ export { createKnowledgeServer, type KnowledgeToolsOptions } from "./core/tools/knowledgeTools.js";
13
17
  export { resolveLanguage, detectLanguage, SUPPORTED_LANGUAGES, type Language, type LanguageSources, type ResolvedLanguage, } from "./core/language.js";
14
18
  export { setLanguage, getLanguage, messagesFor, type Messages } from "./core/messages/index.js";
15
19
  export { createRunStore, createRunFolder, listRuns, readConversation, type RunFolder, type RunSummary, type ConversationMessage, } from "./core/runs.js";
@@ -22,12 +26,12 @@ export { createSubagentTypeGate } from "./core/hooks/subagentTypeGate.js";
22
26
  export { createSubagentForegroundGate } from "./core/hooks/subagentForegroundGate.js";
23
27
  export { createFileScopeGate, checkFileScope, type FileScope } from "./core/hooks/fileScopeGate.js";
24
28
  export { createPlanGate, checkPlanScope, type PlanScope } from "./core/hooks/planGate.js";
25
- export { askForDecision, askForManualIntervention } from "./core/hooks/humanInput.js";
26
- export { setInteractionPort, getInteractionPort, type InteractionPort, type ApprovalPrompt } from "./core/interaction.js";
29
+ export { askForDecision, askForManualIntervention, askForChoice, askForText, type ChoiceAnswer } from "./core/hooks/humanInput.js";
30
+ export { setInteractionPort, getInteractionPort, type InteractionPort, type ApprovalPrompt, type ChoiceSettings } from "./core/interaction.js";
27
31
  export { terminalInteractionPort, setSharedReadline, getSharedReadline } from "./tui/terminalInteraction.js";
28
32
  export { createHumanApprovalServer, DEFAULT_HUMAN_APPROVAL_TEXTS, type HumanApprovalTexts } from "./core/tools/humanApproval.js";
29
33
  export { createManualLoginServer, type ManualInterventionTexts } from "./core/tools/manualLogin.js";
30
- export { createSaveToSourcesServer } from "./core/tools/saveToSources.js";
34
+ export { createSaveToSourcesServer, type SourceToolsOptions } from "./core/tools/saveToSources.js";
31
35
  export { runChatTui, type ChatTuiOptions } from "./tui/chatTui.js";
32
36
  export type { SessionOpener } from "./tui/runs.js";
33
37
  export { createConsoleRenderer, type ConsoleRenderer, type ConsoleRendererOptions } from "./tui/consoleRenderer.js";
package/dist/index.js CHANGED
@@ -11,6 +11,10 @@ export { createFriendlyToolLabel, truncate, truncatePath, } from "./core/toolLab
11
11
  export { buildSessionOptions, createInputQueue, createDeferred, createModeControl, togglePlanMode } from "./core/session.js";
12
12
  export { runQuery } from "./core/runner.js";
13
13
  export { knowledgePluginRoot, knowledgePromptSection } from "./core/knowledge.js";
14
+ export { agentKitVersion } from "./core/version.js";
15
+ export { BUILT_IN_PAGE_TYPES, isSlug, parseId, linkedIds, } from "./core/knowledgeStore.js";
16
+ export { createFileKnowledgeStore } from "./core/fileKnowledgeStore.js";
17
+ export { createKnowledgeServer } from "./core/tools/knowledgeTools.js";
14
18
  export { resolveLanguage, detectLanguage, SUPPORTED_LANGUAGES, } from "./core/language.js";
15
19
  export { setLanguage, getLanguage, messagesFor } from "./core/messages/index.js";
16
20
  export { createRunStore, createRunFolder, listRuns, readConversation, } from "./core/runs.js";
@@ -24,7 +28,7 @@ export { createSubagentTypeGate } from "./core/hooks/subagentTypeGate.js";
24
28
  export { createSubagentForegroundGate } from "./core/hooks/subagentForegroundGate.js";
25
29
  export { createFileScopeGate, checkFileScope } from "./core/hooks/fileScopeGate.js";
26
30
  export { createPlanGate, checkPlanScope } from "./core/hooks/planGate.js";
27
- export { askForDecision, askForManualIntervention } from "./core/hooks/humanInput.js";
31
+ export { askForDecision, askForManualIntervention, askForChoice, askForText } from "./core/hooks/humanInput.js";
28
32
  export { setInteractionPort, getInteractionPort } from "./core/interaction.js";
29
33
  export { terminalInteractionPort, setSharedReadline, getSharedReadline } from "./tui/terminalInteraction.js";
30
34
  // The checkpoints answer on the terminal by default, as they always have; a non-terminal
@@ -188,6 +188,14 @@ export async function runChatTui(options, tuiOptions = {}) {
188
188
  },
189
189
  });
190
190
  const { writeLine } = renderer;
191
+ // The mode can change from inside the session (the agent leaving plan mode with
192
+ // present_plan): say so, as /plan does.
193
+ let unsubscribeMode;
194
+ const watchMode = () => {
195
+ unsubscribeMode?.();
196
+ unsubscribeMode = modeControl()?.subscribe?.((mode) => writeLine(ui.dim(`⏵⏵ ${t().mode(mode)}`)));
197
+ };
198
+ watchMode();
191
199
  // Cached lazily (not fetched until the first "/..." line, and only once — the SDK docs
192
200
  // don't promise this list changes mid-session, and re-fetching per line would add a
193
201
  // round-trip to the CLI subprocess for every turn): every registered command name/alias,
@@ -261,6 +269,7 @@ export async function runChatTui(options, tuiOptions = {}) {
261
269
  queue = createInputQueue({ modeControl: modeControl() });
262
270
  run = runQuery(queue.iterable, current.options);
263
271
  events = run.events[Symbol.asyncIterator]();
272
+ watchMode();
264
273
  knownCommandTokens = null;
265
274
  await showConversation(current.run);
266
275
  }
@@ -304,7 +313,11 @@ export async function runChatTui(options, tuiOptions = {}) {
304
313
  if (line.toLowerCase() === "/plan") {
305
314
  const control = modeControl();
306
315
  const next = control ? togglePlanMode(control) : null;
307
- writeLine(ui.dim(next ? `⏵⏵ ${t().mode(next)}` : t().modeLocked(control?.mode)));
316
+ // The new mode's line comes from watchMode(); a control without subscribe() says it here.
317
+ if (!next)
318
+ writeLine(ui.dim(t().modeLocked(control?.mode)));
319
+ else if (!control?.subscribe)
320
+ writeLine(ui.dim(`⏵⏵ ${t().mode(next)}`));
308
321
  continue;
309
322
  }
310
323
  // A line that *looks* like a slash command but matches nothing registered (a typo,
@@ -1,5 +1,6 @@
1
1
  import { stdout } from "node:process";
2
2
  import { createFriendlyToolLabel } from "../core/toolLabels.js";
3
+ import { parseTodos, todoChanges, TODO_TOOL } from "../core/todos.js";
3
4
  import * as ui from "./ui.js";
4
5
  import { t } from "../core/messages/index.js";
5
6
  /**
@@ -14,6 +15,8 @@ export function createConsoleRenderer(options = {}) {
14
15
  const output = options.output ?? ((text) => void stdout.write(text));
15
16
  let atLineStart = true;
16
17
  let labelPrinted = false;
18
+ // The main agent's task list, to print only what changed in each TodoWrite call.
19
+ let todos = [];
17
20
  function write(text) {
18
21
  if (text.length === 0)
19
22
  return;
@@ -39,9 +42,20 @@ export function createConsoleRenderer(options = {}) {
39
42
  }
40
43
  write(ui.agent(event.text));
41
44
  return;
42
- case "action":
43
- writeLine(ui.action(`[action] ${formatAction(event.toolName, event.input)}`));
45
+ case "action": {
46
+ const next = event.toolName === TODO_TOOL ? parseTodos(event.input) : null;
47
+ if (!next) {
48
+ writeLine(ui.action(`[action] ${formatAction(event.toolName, event.input)}`));
49
+ return;
50
+ }
51
+ const { started, completed } = todoChanges(todos, next);
52
+ todos = next;
53
+ for (const todo of completed)
54
+ writeLine(ui.action(`[task] ☑ ${todo.content}`));
55
+ for (const todo of started)
56
+ writeLine(ui.action(`[task] ◼ ${todo.activeForm}`));
44
57
  return;
58
+ }
45
59
  case "subagent-action":
46
60
  case "tool-result":
47
61
  case "prompt-suggestion":
@@ -2,7 +2,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
2
2
  import wrapAnsi from "wrap-ansi";
3
3
  import { useEffect, useReducer, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { Box, measureElement, Static, Text, useInput, useStdout } from "ink";
5
- import { Select } from "@inkjs/ui";
5
+ import { MultiSelect, Select, TextInput } from "@inkjs/ui";
6
6
  import { liveWidth } from "./sessionModel.js";
7
7
  import { createRowCache, highlightRow, isMouseReport, mouseEvents, rowsBelow, scrollBy, selectedText, visibleRows, userBarRows, } from "./fullscreen.js";
8
8
  import { framePosition } from "./terminalCursor.js";
@@ -11,6 +11,7 @@ import { fitWidth, stripAnsi } from "./lineBuffer.js";
11
11
  import * as ui from "../ui.js";
12
12
  import { t } from "../../core/messages/index.js";
13
13
  import { inkColor } from "../theme.js";
14
+ import { renderMarkdown } from "./markdown.js";
14
15
  // Numbered as in the Claude Code CLI's permission prompt; the digits and y/n/q answer too.
15
16
  const decisionOptions = () => [
16
17
  { label: `1. ${t().yes}`, value: "y" },
@@ -44,16 +45,42 @@ export function previewLines(lines, terminalRows, reservedRows = PANEL_RESERVED_
44
45
  }
45
46
  function CheckpointPanel({ checkpoint, renderApproval, reservedRows, width, }) {
46
47
  const decision = checkpoint.kind === "decision";
47
- useInput((input) => {
48
- const key = input.toLowerCase();
49
- if (decision && DECISION_KEYS[key])
50
- checkpoint.answer(DECISION_KEYS[key]);
51
- else if (!decision && key === "1")
48
+ const text = checkpoint.kind === "text";
49
+ const choice = checkpoint.kind === "choice";
50
+ useInput((input, key) => {
51
+ if (text || choice) {
52
+ if (key.escape)
53
+ checkpoint.answer("");
54
+ return;
55
+ }
56
+ const lower = input.toLowerCase();
57
+ if (decision && DECISION_KEYS[lower])
58
+ checkpoint.answer(DECISION_KEYS[lower]);
59
+ else if (!decision && lower === "1")
52
60
  checkpoint.answer("");
53
61
  });
54
62
  const { prompt } = checkpoint;
55
63
  const { stdout } = useStdout();
56
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: inkColor("accent"), paddingX: 1, children: [renderApproval ? (renderApproval(prompt)) : (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: prompt.title }), previewLines(prompt.lines, stdout.rows, reservedRows, width - PANEL_FRAME_COLUMNS).map((line, index) => (_jsx(Text, { children: line }, index))), !decision && prompt.question ? _jsx(Text, { dimColor: true, children: prompt.question }) : null] })), decision ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: t().proceed }) })) : null, _jsx(Box, { marginTop: decision ? 0 : 1, children: _jsx(Select, { options: decision ? decisionOptions() : manualOptions(), onChange: (value) => checkpoint.answer(decision ? value : "") }, checkpoint.id) })] }));
64
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: inkColor("accent"), paddingX: 1, children: [renderApproval ? (renderApproval(prompt)) : (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: prompt.title }), previewLines(prompt.markdown ? renderMarkdown(prompt.lines.join("\n"), width - PANEL_FRAME_COLUMNS) : prompt.lines, stdout.rows, reservedRows, width - PANEL_FRAME_COLUMNS).map((line, index) => (_jsx(Text, { children: line }, index))), !decision && prompt.question ? _jsx(Text, { dimColor: true, children: prompt.question }) : null] })), decision ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: t().proceed }) })) : null, choice && checkpoint.choice ? (_jsx(ChoiceInput, { choice: checkpoint.choice, onAnswer: (value) => checkpoint.answer(value) }, checkpoint.id)) : text ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { children: "> " }), _jsx(TextInput, { placeholder: t().textAnswerHint, onSubmit: (value) => checkpoint.answer(value.trim()) }, checkpoint.id)] }), _jsx(Text, { dimColor: true, children: t().textAnswerKeys })] })) : (_jsx(Box, { marginTop: decision ? 0 : 1, children: _jsx(Select, { options: decision ? decisionOptions() : manualOptions(), onChange: (value) => checkpoint.answer(decision ? value : "") }, checkpoint.id) }))] }));
65
+ }
66
+ /**
67
+ * A choice's options (one with Select, several with MultiSelect), plus "Other" to type an
68
+ * answer of one's own; answers as `InteractionPort.askChoice()` says: the numbers, then the
69
+ * typed answer on the next line.
70
+ */
71
+ function ChoiceInput({ choice, onAnswer }) {
72
+ const [typing, setTyping] = useState(null);
73
+ const options = [...choice.options.map((label, i) => ({ label: `${i + 1}. ${label}`, value: String(i + 1) })), { label: t().otherOption, value: "other" }];
74
+ if (typing !== null) {
75
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { children: "> " }), _jsx(TextInput, { placeholder: t().textAnswerHint, onSubmit: (value) => onAnswer([typing, value.trim()].filter(Boolean).join("\n")) })] }), _jsx(Text, { dimColor: true, children: t().textAnswerKeys })] }));
76
+ }
77
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [choice.multiple ? (_jsx(MultiSelect, { options: options, onSubmit: (values) => {
78
+ const picked = values.filter((value) => value !== "other").join(",");
79
+ if (values.includes("other"))
80
+ setTyping(picked);
81
+ else
82
+ onAnswer(picked);
83
+ } })) : (_jsx(Select, { options: options, onChange: (value) => (value === "other" ? setTyping("") : onAnswer(value)) })), _jsx(Text, { dimColor: true, children: t().choiceKeys(choice.multiple) })] }));
57
84
  }
58
85
  function formatTokens(count) {
59
86
  return count >= 1000 ? `${(count / 1000).toFixed(1)}k` : String(count);
@@ -89,7 +116,21 @@ function LiveArea({ session, checkpoint, renderApproval, mode, width, statusExtr
89
116
  return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, marginTop: session.liveGap ? 1 : 0, children: [stripAnsi(session.live) ? _jsx(Text, { children: session.live }) : null, checkpoint ? (_jsx(CheckpointPanel, { checkpoint: checkpoint, renderApproval: renderApproval, reservedRows: reservedRows, width: width })) : session.busy ? (
90
117
  // Always one blank line above the spinner: from this margin, or from the live area's
91
118
  // own when a separator is pending and nothing is written above the spinner yet.
92
- _jsxs(Box, { flexDirection: "column", marginTop: stripAnsi(session.live) || !session.liveGap ? 1 : 0, children: [_jsx(Working, { label: session.activity ?? t().thinking, startedAt: session.turnStartedAt, width: width }), session.subagentActivity ? _jsx(Text, { dimColor: true, children: fitWidth(` ↳ ${session.subagentActivity}`, width) }) : null] })) : null, checkpoint ? null : children, _jsx(Text, { children: fitWidth(status, width) })] }));
119
+ _jsxs(Box, { flexDirection: "column", marginTop: stripAnsi(session.live) || !session.liveGap ? 1 : 0, children: [_jsx(Working, { label: session.activity ?? t().thinking, startedAt: session.turnStartedAt, width: width }), session.subagentActivity ? _jsx(Text, { dimColor: true, children: fitWidth(` ↳ ${session.subagentActivity}`, width) }) : null] })) : null, checkpoint || !session.todos ? null : _jsx(TodoList, { todos: session.todos, width: width }), checkpoint ? null : children, _jsx(Text, { children: fitWidth(status, width) })] }));
120
+ }
121
+ // At most this many tasks show; the rest are counted (the one in progress always shows).
122
+ const MAX_TODOS = 8;
123
+ /** The agent's task list (TodoWrite), as in the Claude Code CLI: `☐` pending, `◼` in progress, `☑` done. */
124
+ function TodoList({ todos, width }) {
125
+ const current = todos.findIndex((todo) => todo.status === "in_progress");
126
+ const first = Math.max(0, Math.min(current - 2, todos.length - MAX_TODOS));
127
+ const shown = todos.slice(first, first + MAX_TODOS);
128
+ const hidden = todos.length - shown.length;
129
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [shown.map((todo, i) => {
130
+ const line = ` ${i === 0 ? "⎿ " : " "}${todo.status === "completed" ? "☑" : todo.status === "in_progress" ? "◼" : "☐"} ${todo.content}`;
131
+ const fitted = fitWidth(line, width);
132
+ return (_jsx(Text, { children: todo.status === "completed" ? ui.dim(ui.strike(fitted)) : todo.status === "in_progress" ? ui.bold(fitted) : fitted }, first + i));
133
+ }), hidden > 0 ? _jsx(Text, { children: ui.dim(fitWidth(` ${t().moreTodos(hidden)}`, width)) }) : null] }));
93
134
  }
94
135
  /** A history line for the inline view: the human's line as a bar, a tool group unfolded with Ctrl+O. */
95
136
  function inlineItem(item, expanded, width) {
@@ -133,6 +174,16 @@ function FullscreenSession({ session, checkpoint, renderApproval, mode, header,
133
174
  if (height !== historyHeight)
134
175
  setHistoryHeight(height);
135
176
  });
177
+ // The history's height now: `historyHeight` is set by the effect above after the layout,
178
+ // so a key or a click on the first frames would still find the terminal's height.
179
+ function heightNow() {
180
+ if (!historyRef.current)
181
+ return historyHeight;
182
+ const { height } = measureElement(historyRef.current);
183
+ if (height !== historyHeight)
184
+ setHistoryHeight(height);
185
+ return height;
186
+ }
136
187
  const [anchor, setAnchor] = useState(null);
137
188
  // Mirrored in a ref: mouse reports can arrive faster than React re-renders.
138
189
  const [selection, setSelectionState] = useState(null);
@@ -161,9 +212,7 @@ function FullscreenSession({ session, checkpoint, renderApproval, mode, header,
161
212
  if (!historyRef.current || end === start)
162
213
  return null;
163
214
  const box = framePosition(historyRef.current);
164
- // Measured now, not `historyHeight`: that state is set by an effect after the layout, so
165
- // a click on the first frames would find the rows where they were before it.
166
- const height = measureElement(historyRef.current).height;
215
+ const height = heightNow();
167
216
  const firstY = box.y + height - (end - start); // the rows are bottom-aligned
168
217
  let offset = y - firstY;
169
218
  // Rows that don't fit are clipped above the box, not clickable.
@@ -179,7 +228,8 @@ function FullscreenSession({ session, checkpoint, renderApproval, mode, header,
179
228
  for (const event of mouseEvents(text)) {
180
229
  if (event.kind === "wheel-up" || event.kind === "wheel-down") {
181
230
  const steps = event.kind === "wheel-up" ? -WHEEL_ROWS : WHEEL_ROWS;
182
- setAnchor((a) => scrollBy(a, steps, total, historyHeight));
231
+ const height = heightNow();
232
+ setAnchor((a) => scrollBy(a, steps, total, height));
183
233
  }
184
234
  else if (event.kind === "press") {
185
235
  const cell = cellAt(event.x, event.y, false);
@@ -210,15 +260,15 @@ function FullscreenSession({ session, checkpoint, renderApproval, mode, header,
210
260
  }
211
261
  }
212
262
  useInput((text, key) => {
213
- const page = Math.max(1, historyHeight - 1);
214
263
  if (isMouseReport(text))
215
264
  handleMouse(text);
216
265
  else if (isFocusReport(text))
217
266
  return;
218
- else if (key.pageUp)
219
- setAnchor((a) => scrollBy(a, -page, total, historyHeight));
220
- else if (key.pageDown)
221
- setAnchor((a) => scrollBy(a, page, total, historyHeight));
267
+ else if (key.pageUp || key.pageDown) {
268
+ const height = heightNow();
269
+ const page = Math.max(1, height - 1);
270
+ setAnchor((a) => scrollBy(a, key.pageUp ? -page : page, total, height));
271
+ }
222
272
  else if (key.ctrl && key.end)
223
273
  setAnchor(null);
224
274
  else if (text && !key.ctrl && !key.meta && !key.escape)
@@ -1,9 +1,11 @@
1
- import type { ApprovalPrompt, InteractionPort } from "../../core/interaction.js";
2
- export type CheckpointKind = "decision" | "manual-intervention";
1
+ import type { ApprovalPrompt, ChoiceSettings, InteractionPort } from "../../core/interaction.js";
2
+ export type CheckpointKind = "decision" | "manual-intervention" | "text" | "choice";
3
3
  export interface Checkpoint {
4
4
  id: number;
5
5
  kind: CheckpointKind;
6
6
  prompt: ApprovalPrompt;
7
+ /** For a choice: the options and whether several can be picked. */
8
+ choice?: ChoiceSettings;
7
9
  /** Settles the checkpoint with the raw answer ("y", "n", "q" for a decision). */
8
10
  answer(value: string): void;
9
11
  }
@@ -1,4 +1,6 @@
1
+ import { parseChoice } from "../../core/hooks/humanInput.js";
1
2
  import * as ui from "../ui.js";
3
+ import { renderMarkdown } from "./markdown.js";
2
4
  import { t } from "../../core/messages/index.js";
3
5
  const outcome = (answer) => ({ y: ui.success(t().approved), n: ui.warn(t().rejected), q: ui.error(t().stopped) })[answer];
4
6
  /**
@@ -25,16 +27,30 @@ export function createInkInteraction(note) {
25
27
  function record(prompt, outcome) {
26
28
  note([ui.heading(`=== ${prompt.title} ===`), ...prompt.lines, outcome].join("\n"));
27
29
  }
28
- function ask(kind, prompt, signal) {
30
+ /** How a settled checkpoint is written to the history. */
31
+ function settledAs(kind, value, choice) {
32
+ if (kind === "decision")
33
+ return outcome(value) ?? value;
34
+ if (kind === "text")
35
+ return value ? ui.success(`✔ ${value}`) : ui.dim(t().noAnswer);
36
+ if (kind === "choice" && choice) {
37
+ const answer = parseChoice(value, choice.options, choice.multiple);
38
+ const words = [...answer.chosen, ...(answer.other ? [answer.other] : [])];
39
+ return words.length ? ui.success(`✔ ${words.join("; ")}`) : ui.dim(t().noAnswer);
40
+ }
41
+ return ui.success(t().done);
42
+ }
43
+ function ask(kind, prompt, signal, choice) {
29
44
  return new Promise((resolve) => {
30
45
  const checkpoint = {
31
46
  id: nextId++,
32
47
  kind,
33
48
  prompt,
49
+ ...(choice ? { choice } : {}),
34
50
  answer(value) {
35
51
  if (!remove(checkpoint))
36
52
  return;
37
- record(prompt, kind === "decision" ? (outcome(value) ?? value) : ui.success(t().done));
53
+ record(prompt.markdown ? { ...prompt, lines: renderMarkdown(prompt.lines.join("\n"), 80) } : prompt, settledAs(kind, value, choice));
38
54
  resolve(value);
39
55
  },
40
56
  };
@@ -50,6 +66,8 @@ export function createInkInteraction(note) {
50
66
  port: {
51
67
  askDecision: (prompt, signal) => ask("decision", prompt, signal),
52
68
  askManualIntervention: (prompt, signal) => ask("manual-intervention", prompt, signal),
69
+ askText: (prompt, signal) => ask("text", prompt, signal),
70
+ askChoice: (prompt, choice, signal) => ask("choice", prompt, signal, choice),
53
71
  notify: (message) => note(message),
54
72
  },
55
73
  subscribe(listener) {
@@ -244,12 +244,17 @@ export async function runChatInk(options, tuiOptions = {}) {
244
244
  let run;
245
245
  let knownCommands;
246
246
  let generation = 0;
247
+ let unsubscribeMode;
247
248
  function connect() {
248
249
  const own = ++generation;
249
250
  queue = createInputQueue({ modeControl: modeControl() });
250
251
  run = runQuery(queue.iterable, { ...current.options, promptSuggestions: tuiOptions.promptSuggestions ?? true });
251
252
  const events = run.events[Symbol.asyncIterator]();
252
253
  input.setMode(modeControl()?.mode ?? null);
254
+ // The mode can also change from inside the session (the agent leaving plan mode with
255
+ // present_plan): the status bar follows it.
256
+ unsubscribeMode?.();
257
+ unsubscribeMode = modeControl()?.subscribe?.((mode) => input.setMode(mode));
253
258
  // Every registered command name and alias, built-ins included, for completion and for
254
259
  // catching an unknown "/command" before it reaches the model as plain text (see chatTui.ts).
255
260
  knownCommands = run.supportedCommands().then((commands) => new Set(commands.flatMap((c) => [c.name, ...(c.aliases ?? [])])), () => null);
@@ -1,6 +1,7 @@
1
1
  import type { AgentEvent, SessionUsage } from "../../core/runner.js";
2
2
  import { type ConsoleRenderer } from "../consoleRenderer.js";
3
3
  import { type ResultFormatter, type ToolDetail, type ToolPhrase } from "./toolGroup.js";
4
+ import { type Todo } from "../../core/todos.js";
4
5
  export interface HistoryItem {
5
6
  id: number;
6
7
  text: string;
@@ -36,6 +37,8 @@ export interface SessionSnapshot {
36
37
  usage: SessionUsage | null;
37
38
  /** How full the context window is (0-100), after the latest turn; null until known. */
38
39
  contextPercent: number | null;
40
+ /** The main agent's task list (TodoWrite), while any task is still to do; null otherwise. */
41
+ todos: Todo[] | null;
39
42
  }
40
43
  export interface SessionModelOptions {
41
44
  formatAction?: (toolName: string, toolInput: unknown) => string;
@@ -5,6 +5,7 @@ import { finishedLength, hasTable, renderMarkdown } from "./markdown.js";
5
5
  import { toolGroupExpanded, toolGroupSummary } from "./toolGroup.js";
6
6
  import * as ui from "../ui.js";
7
7
  import { t } from "../../core/messages/index.js";
8
+ import { hasOpenTodos, parseTodos, TODO_TOOL } from "../../core/todos.js";
8
9
  /**
9
10
  * Columns the live area may use: short of the edge, where the Windows console wraps early
10
11
  * (see lineBuffer.ts). Read on every use, so a resized terminal is picked up.
@@ -45,6 +46,7 @@ export function createSessionModel(options = {}) {
45
46
  turns: 0,
46
47
  usage: null,
47
48
  contextPercent: null,
49
+ todos: null,
48
50
  };
49
51
  function update(changes) {
50
52
  snapshot = { ...snapshot, ...changes };
@@ -190,6 +192,15 @@ export function createSessionModel(options = {}) {
190
192
  return;
191
193
  }
192
194
  case "action": {
195
+ // The task list isn't a tool call to show: it's drawn above the prompt, and the task
196
+ // in progress becomes the spinner's label.
197
+ const todos = event.toolName === TODO_TOOL ? parseTodos(event.input) : null;
198
+ if (todos) {
199
+ const current = todos.find((todo) => todo.status === "in_progress");
200
+ snapshot = { ...snapshot, todos: hasOpenTodos(todos) ? todos : null, activity: current?.activeForm ?? snapshot.activity };
201
+ refreshLive();
202
+ return;
203
+ }
193
204
  flushSegment(true);
194
205
  group ??= [];
195
206
  group.push({ id: event.toolUseId, label: formatAction(event.toolName, event.input), toolName: event.toolName, result: null });
@@ -198,6 +209,8 @@ export function createSessionModel(options = {}) {
198
209
  return;
199
210
  }
200
211
  case "tool-result": {
212
+ if (event.toolName === TODO_TOOL)
213
+ return;
201
214
  const call = group?.find((c) => c.id === event.toolUseId);
202
215
  if (call)
203
216
  call.result = { isError: event.isError, text: event.text };
@@ -282,7 +295,7 @@ export function createSessionModel(options = {}) {
282
295
  lastKind = null;
283
296
  gapPending = false;
284
297
  reply = "";
285
- snapshot = { ...snapshot, items: [], live: "", liveGap: false, turns: 0, usage: null, contextPercent: null };
298
+ snapshot = { ...snapshot, items: [], live: "", liveGap: false, turns: 0, usage: null, contextPercent: null, todos: null };
286
299
  refreshLive();
287
300
  },
288
301
  replay(messages, userLine) {
@@ -80,6 +80,8 @@ async function askOnTerminal(prompt, signal) {
80
80
  export const terminalInteractionPort = {
81
81
  askDecision: askOnTerminal,
82
82
  askManualIntervention: askOnTerminal,
83
+ askText: askOnTerminal,
84
+ askChoice: (prompt, choice, signal) => askOnTerminal({ ...prompt, lines: [...prompt.lines, ...choice.options.map((option, i) => `${i + 1}. ${option}`)], question: t().choiceQuestion(choice.multiple) }, signal),
83
85
  notify(message) {
84
86
  if (stdin.isTTY)
85
87
  console.log(message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@falkenslab/agent-kit",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "Library for building an agent on top of the Claude Agent SDK",
5
5
  "author": "Francisco Vargas Ruiz",
6
6
  "license": "MIT",
@@ -62,11 +62,17 @@
62
62
  },
63
63
  "devDependencies": {
64
64
  "@eslint/js": "^10.0.1",
65
+ "@mozilla/readability": "^0.6.0",
65
66
  "@types/node": "^24.0.0",
67
+ "@types/turndown": "^5.0.6",
66
68
  "eslint": "^10.9.1",
69
+ "fflate": "^0.8.3",
67
70
  "globals": "^17.11.0",
68
71
  "ink-testing-library": "^4.0.0",
72
+ "linkedom": "^0.18.13",
73
+ "mammoth": "^1.13.0",
69
74
  "tsx": "^4.19.0",
75
+ "turndown": "^7.2.4",
70
76
  "typescript": "^5.7.0",
71
77
  "typescript-eslint": "^8.68.0"
72
78
  },
@@ -75,5 +81,29 @@
75
81
  },
76
82
  "publishConfig": {
77
83
  "access": "public"
84
+ },
85
+ "peerDependencies": {
86
+ "@mozilla/readability": ">=0.5.0",
87
+ "linkedom": ">=0.16.0",
88
+ "turndown": ">=7.0.0",
89
+ "mammoth": ">=1.6.0",
90
+ "fflate": ">=0.8.0"
91
+ },
92
+ "peerDependenciesMeta": {
93
+ "@mozilla/readability": {
94
+ "optional": true
95
+ },
96
+ "linkedom": {
97
+ "optional": true
98
+ },
99
+ "turndown": {
100
+ "optional": true
101
+ },
102
+ "mammoth": {
103
+ "optional": true
104
+ },
105
+ "fflate": {
106
+ "optional": true
107
+ }
78
108
  }
79
109
  }