@falkenslab/agent-kit 0.10.2 → 0.12.0

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 (51) hide show
  1. package/README.md +91 -97
  2. package/dist/core/agentSpec.d.ts +31 -1
  3. package/dist/core/hooks/stepGate.js +3 -2
  4. package/dist/core/language.d.ts +39 -0
  5. package/dist/core/language.js +59 -0
  6. package/dist/core/messages/de.d.ts +2 -0
  7. package/dist/core/messages/de.js +103 -0
  8. package/dist/core/messages/en.d.ts +90 -0
  9. package/dist/core/messages/en.js +102 -0
  10. package/dist/core/messages/es.d.ts +2 -0
  11. package/dist/core/messages/es.js +103 -0
  12. package/dist/core/messages/fr.d.ts +2 -0
  13. package/dist/core/messages/fr.js +103 -0
  14. package/dist/core/messages/index.d.ts +23 -0
  15. package/dist/core/messages/index.js +67 -0
  16. package/dist/core/runs.d.ts +51 -0
  17. package/dist/core/runs.js +194 -0
  18. package/dist/core/session.d.ts +8 -0
  19. package/dist/core/session.js +42 -7
  20. package/dist/core/toolLabels.d.ts +2 -1
  21. package/dist/core/toolLabels.js +31 -51
  22. package/dist/core/tools/humanApproval.js +3 -2
  23. package/dist/index.d.ts +5 -0
  24. package/dist/index.js +6 -0
  25. package/dist/tui/chatTui.d.ts +23 -2
  26. package/dist/tui/chatTui.js +71 -7
  27. package/dist/tui/claudeAuth.d.ts +3 -1
  28. package/dist/tui/claudeAuth.js +12 -17
  29. package/dist/tui/consoleRenderer.js +2 -1
  30. package/dist/tui/ink/PromptInput.d.ts +0 -2
  31. package/dist/tui/ink/PromptInput.js +5 -15
  32. package/dist/tui/ink/SessionView.js +16 -15
  33. package/dist/tui/ink/inkInteraction.js +4 -7
  34. package/dist/tui/ink/progressView.d.ts +6 -0
  35. package/dist/tui/ink/progressView.js +2 -0
  36. package/dist/tui/ink/promptEditing.d.ts +2 -5
  37. package/dist/tui/ink/promptEditing.js +11 -3
  38. package/dist/tui/ink/runChatInk.d.ts +25 -2
  39. package/dist/tui/ink/runChatInk.js +156 -48
  40. package/dist/tui/ink/sessionModel.d.ts +10 -0
  41. package/dist/tui/ink/sessionModel.js +27 -2
  42. package/dist/tui/ink/toolGroup.d.ts +2 -1
  43. package/dist/tui/ink/toolGroup.js +5 -18
  44. package/dist/tui/ink/wizard.d.ts +6 -0
  45. package/dist/tui/ink/wizard.js +4 -1
  46. package/dist/tui/language.d.ts +5 -0
  47. package/dist/tui/language.js +11 -0
  48. package/dist/tui/runs.d.ts +27 -0
  49. package/dist/tui/runs.js +30 -0
  50. package/dist/tui/terminalInteraction.js +2 -2
  51. package/package.json +1 -1
@@ -1,6 +1,8 @@
1
1
  import type { Options, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { type TranscriptLogger } from "./hooks/transcriptLogger.js";
3
3
  import type { AgentSpec, BaseSessionConfig, Mode } from "./agentSpec.js";
4
+ import { type Language } from "./language.js";
5
+ import { type RunFolder } from "./runs.js";
4
6
  /**
5
7
  * Builds the `options` object passed to the Agent SDK's `query()` — everything about
6
8
  * *how* the agent runs a session (system prompt, tools, MCP servers, hooks), independent
@@ -15,14 +17,20 @@ import type { AgentSpec, BaseSessionConfig, Mode } from "./agentSpec.js";
15
17
  * The per-mode behavioral differences (whether the approval/manual-intervention tools
16
18
  * exist, whether the interactive step gate hook runs) all fall out of `config.mode`
17
19
  * alone.
20
+ *
21
+ * `options.run` keeps the SDK's transcript of the conversation in that run folder (see
22
+ * runs.ts's createRunStore()) and, when it has a session already, resumes it: the chat
23
+ * passes it when the agent gives it a runs folder (`InkChatOptions.runsDir`).
18
24
  */
19
25
  export declare function buildSessionOptions<TConfig extends BaseSessionConfig>(config: TConfig, runDir: string, spec: AgentSpec<TConfig>, options?: {
20
26
  autoCompactEnabled?: boolean;
27
+ run?: RunFolder;
21
28
  }): Promise<{
22
29
  options: Options;
23
30
  transcriptLogger: TranscriptLogger;
24
31
  transcriptPath: string;
25
32
  modeControl: ModeControl;
33
+ language: Language;
26
34
  }>;
27
35
  /**
28
36
  * The supervision mode of a running session. Only "guided" and "interactive" can switch into
@@ -10,6 +10,9 @@ import { createManualLoginServer } from "./tools/manualLogin.js";
10
10
  import { createSaveToSourcesServer } from "./tools/saveToSources.js";
11
11
  import { allowAnyMcpTool } from "./mcpPermissions.js";
12
12
  import { knowledgePluginRoot, knowledgePromptSection } from "./knowledge.js";
13
+ import { replyLanguageInstruction } from "./language.js";
14
+ import { chooseLanguage } from "./messages/index.js";
15
+ import { createRunStore } from "./runs.js";
13
16
  /**
14
17
  * Builds the `options` object passed to the Agent SDK's `query()` — everything about
15
18
  * *how* the agent runs a session (system prompt, tools, MCP servers, hooks), independent
@@ -24,9 +27,18 @@ import { knowledgePluginRoot, knowledgePromptSection } from "./knowledge.js";
24
27
  * The per-mode behavioral differences (whether the approval/manual-intervention tools
25
28
  * exist, whether the interactive step gate hook runs) all fall out of `config.mode`
26
29
  * alone.
30
+ *
31
+ * `options.run` keeps the SDK's transcript of the conversation in that run folder (see
32
+ * runs.ts's createRunStore()) and, when it has a session already, resumes it: the chat
33
+ * passes it when the agent gives it a runs folder (`InkChatOptions.runsDir`).
27
34
  */
28
35
  export async function buildSessionOptions(config, runDir, spec, options = {}) {
29
36
  const { mode } = config;
37
+ // The kit's language (`--language`, then config.language, then the system's), chosen here
38
+ // for the interface too, so the agent answers in the language its interface is in.
39
+ const language = chooseLanguage(config.language);
40
+ const replyLine = spec.replyInLanguage === false ? null : replyLanguageInstruction(language);
41
+ const withReplyLine = (prompt) => (replyLine ? `${prompt}\n\n${replyLine}` : prompt);
30
42
  const modeControl = createModeControl(mode);
31
43
  // The human approval server only exists outside autonomous mode: the point of
32
44
  // autonomous is that the agent has no channel to ask for human intervention at all.
@@ -69,6 +81,10 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
69
81
  // anything not already present here — which is why the hook, not just omitting "Bash"
70
82
  // from this list, is what actually confines it to subagent use).
71
83
  const subagents = spec.buildSubagents(config);
84
+ // Each subagent gets the reply line too: its prompt doesn't include the main one's.
85
+ const subagentDefinitions = subagents
86
+ ? Object.fromEntries(Object.entries(subagents.agents).map(([name, agent]) => [name, { ...agent, prompt: withReplyLine(agent.prompt) }]))
87
+ : undefined;
72
88
  const includeSubagentTools = subagents !== undefined;
73
89
  // The exact set of subagent_type values the Agent tool may spawn in this session — see
74
90
  // createSubagentTypeGate() below for why this can't just be "whatever's in `agents`
@@ -79,10 +95,20 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
79
95
  const transcriptPath = path.join(runDir, "transcript.jsonl");
80
96
  const transcriptLogger = createTranscriptLogger(transcriptPath, config.secrets ?? []);
81
97
  const sdkOptions = {
82
- systemPrompt: includeKnowledgeBase && config.knowledgeDir
98
+ systemPrompt: withReplyLine(includeKnowledgeBase && config.knowledgeDir
83
99
  ? `${spec.buildSystemPrompt(config)}\n\n${knowledgePromptSection(config.projectDir, config.knowledgeDir, config.sourcesDir)}`
84
- : spec.buildSystemPrompt(config),
85
- settings: { autoCompactEnabled: options.autoCompactEnabled ?? true },
100
+ : spec.buildSystemPrompt(config)),
101
+ // Not the SDK's default (every source): "user" hands the agent the runner's own Claude
102
+ // Code configuration, and its `language` setting outranked the agent's prompt.
103
+ settingSources: spec.settingSources ?? ["project"],
104
+ // autoMemoryEnabled: false keeps out the runner's own Claude Code memory for the project
105
+ // (~/.claude/projects/<repository>/memory/MEMORY.md), which the CLI loads whatever
106
+ // settingSources says (confirmed empirically): it's notes for them, not for this agent,
107
+ // whose own memory is the knowledge base. includeGitInstructions: false leaves out the
108
+ // CLI's commit workflow and git context (the runner's git user name among it), which no
109
+ // agent here needs and which pulled replies towards the runner's language (confirmed
110
+ // empirically, see ADR-018).
111
+ settings: { autoCompactEnabled: options.autoCompactEnabled ?? true, autoMemoryEnabled: false, includeGitInstructions: false },
86
112
  // No built-in tools except, if applicable, Read/Write/Edit/Glob/Grep scoped to the
87
113
  // project's own folders (fileScopeGate below), and WebFetch/WebSearch
88
114
  // with no domain restriction (not conditioned on includeFileTools: it's read-only,
@@ -116,13 +142,15 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
116
142
  additionalDirectories: searchableDirs,
117
143
  // "skills" acts as a name whitelist, not an addition: "all" enables both the
118
144
  // SDK's own official skills (pdf/docx), the project's own custom ones, and the
119
- // plugin-provided built-ins below.
120
- skills: "all",
145
+ // plugin-provided built-ins below. A spec's own list gets the knowledge base's
146
+ // skills added, so it only names its own.
147
+ skills: skillList(spec.skills, includeKnowledgeBase),
121
148
  plugins: pluginRoots.map((pluginPath) => ({ type: "local", path: pluginPath, skipMcpDiscovery: true })),
122
149
  }
123
150
  : {}),
124
151
  maxTurns: 400,
125
- ...(subagents ? { agents: subagents.agents } : {}),
152
+ ...(options.run ? { sessionStore: createRunStore(options.run.dir), ...(options.run.sessionId ? { resume: options.run.sessionId } : {}) } : {}),
153
+ ...(subagentDefinitions ? { agents: subagentDefinitions } : {}),
126
154
  mcpServers: {
127
155
  ...spec.buildMcpServers(config, runDir),
128
156
  ...(includeApprovalTool ? { approvals: createHumanApprovalServer(runDir, spec.humanApprovalTexts) } : {}),
@@ -149,7 +177,14 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
149
177
  PostToolUse: [{ hooks: [transcriptLogger.postToolUse] }],
150
178
  },
151
179
  };
152
- return { options: sdkOptions, transcriptLogger, transcriptPath, modeControl };
180
+ return { options: sdkOptions, transcriptLogger, transcriptPath, modeControl, language };
181
+ }
182
+ /** The knowledge plugin's skills, as the SDK names a plugin's skills ("plugin:skill"). */
183
+ const KNOWLEDGE_SKILLS = ["knowledge-pages", "knowledge-ingest", "knowledge-query", "knowledge-lint"].map((skill) => `knowledge:${skill}`);
184
+ function skillList(skills, includeKnowledgeBase) {
185
+ if (skills === undefined || skills === "all")
186
+ return "all";
187
+ return includeKnowledgeBase ? [...new Set([...skills, ...KNOWLEDGE_SKILLS])] : skills;
153
188
  }
154
189
  export function createModeControl(initial) {
155
190
  let current = initial;
@@ -5,7 +5,8 @@
5
5
  * plus its own human-approval/manual-intervention/save-to-sources MCP tools)
6
6
  * generically; a
7
7
  * concrete agent supplies a `describe` callback for its own domain-specific tools (e.g.
8
- * Playwright's browser_* cases) via `createFriendlyToolLabel()`.
8
+ * Playwright's browser_* cases) via `createFriendlyToolLabel()`. The kit's own labels are in
9
+ * the current language (see messages/).
9
10
  */
10
11
  export declare function truncate(text: string, max?: number): string;
11
12
  export declare function truncatePath(text: string, max?: number): string;
@@ -1,3 +1,4 @@
1
+ import { t } from "./messages/index.js";
1
2
  /**
2
3
  * Turns a raw tool name + input into a short, readable console description, instead of
3
4
  * the tool's technical name (e.g. "mcp__playwright__browser_click"). Covers the tools
@@ -5,7 +6,8 @@
5
6
  * plus its own human-approval/manual-intervention/save-to-sources MCP tools)
6
7
  * generically; a
7
8
  * concrete agent supplies a `describe` callback for its own domain-specific tools (e.g.
8
- * Playwright's browser_* cases) via `createFriendlyToolLabel()`.
9
+ * Playwright's browser_* cases) via `createFriendlyToolLabel()`. The kit's own labels are in
10
+ * the current language (see messages/).
9
11
  */
10
12
  export function truncate(text, max = 60) {
11
13
  return text.length > max ? `${text.slice(0, max)}…` : text;
@@ -18,65 +20,43 @@ export function truncatePath(text, max = 60) {
18
20
  }
19
21
  /** Returns undefined for anything it doesn't specifically recognize — the caller decides the fallback. */
20
22
  function describeCore(shortName, input) {
23
+ const labels = t().labels;
24
+ const text = (value, fallback) => (typeof value === "string" ? value : fallback);
21
25
  switch (shortName) {
22
- case "request_human_approval": {
23
- const summary = typeof input.summary === "string" ? input.summary : "";
24
- return `Asking for human approval: ${truncate(summary, 120)}`;
25
- }
26
+ case "request_human_approval":
27
+ return labels.askingApproval(truncate(text(input.summary, ""), 120));
26
28
  case "request_manual_login":
27
- return "Waiting for a human to intervene manually";
28
- case "save_to_sources": {
29
- const destination = typeof input.destination === "string" ? input.destination : "sources/";
30
- return `Saving a file to sources/${truncatePath(destination, 70)}`;
31
- }
32
- case "Read": {
33
- const file = typeof input.file_path === "string" ? input.file_path : "a file";
34
- return `Reading ${truncatePath(file, 80)}`;
35
- }
36
- case "Write": {
37
- const file = typeof input.file_path === "string" ? input.file_path : "a file";
38
- return `Writing to ${truncatePath(file, 80)}`;
39
- }
40
- case "Edit": {
41
- const file = typeof input.file_path === "string" ? input.file_path : "a file";
42
- return `Editing ${truncatePath(file, 80)}`;
43
- }
44
- case "Glob": {
45
- const pattern = typeof input.pattern === "string" ? input.pattern : "*";
46
- return `Searching for files matching "${truncate(pattern, 60)}"`;
47
- }
48
- case "Grep": {
49
- const pattern = typeof input.pattern === "string" ? input.pattern : "";
50
- return `Searching file contents for "${truncate(pattern, 60)}"`;
51
- }
52
- case "Bash": {
53
- const command = typeof input.command === "string" ? input.command : "";
54
- return `Running "${truncate(command, 80)}"`;
55
- }
29
+ return labels.waitingManual;
30
+ case "save_to_sources":
31
+ return labels.savingToSources(truncatePath(text(input.destination, ""), 70));
32
+ case "Read":
33
+ return labels.reading(truncatePath(text(input.file_path, labels.aFile), 80));
34
+ case "Write":
35
+ return labels.writing(truncatePath(text(input.file_path, labels.aFile), 80));
36
+ case "Edit":
37
+ return labels.editing(truncatePath(text(input.file_path, labels.aFile), 80));
38
+ case "Glob":
39
+ return labels.findingFiles(truncate(text(input.pattern, "*"), 60));
40
+ case "Grep":
41
+ return labels.searchingContents(truncate(text(input.pattern, ""), 60));
42
+ case "Bash":
43
+ return labels.running(truncate(text(input.command, ""), 80));
56
44
  // The SDK's built-in subagent-delegation tool. Confirmed empirically (real
57
45
  // transcript) its input is { description, prompt, subagent_type }, not
58
46
  // { subagent_type, task } as its own tool description might suggest.
59
47
  case "Agent": {
60
- const subagentType = typeof input.subagent_type === "string" ? input.subagent_type : "a subagent";
61
- const description = typeof input.description === "string" ? input.description : "";
62
- return description
63
- ? `Delegating to "${subagentType}": ${truncate(description, 80)}`
64
- : `Delegating to "${subagentType}"`;
65
- }
66
- case "WebFetch": {
67
- const url = typeof input.url === "string" ? input.url : "a page";
68
- return `Fetching ${truncate(url, 80)}`;
69
- }
70
- case "WebSearch": {
71
- const query = typeof input.query === "string" ? input.query : "";
72
- return `Searching the web for "${truncate(query, 80)}"`;
48
+ const subagentType = text(input.subagent_type, labels.aSubagent);
49
+ const description = text(input.description, "");
50
+ return description ? labels.delegatingTask(subagentType, truncate(description, 80)) : labels.delegating(subagentType);
73
51
  }
52
+ case "WebFetch":
53
+ return labels.fetching(truncate(text(input.url, labels.aPage), 80));
54
+ case "WebSearch":
55
+ return labels.searchingWeb(truncate(text(input.query, ""), 80));
74
56
  // The SDK's own tool for invoking a skill (see session.ts for why it's listed
75
57
  // explicitly in `tools`). Confirmed empirically: its input is `{ skill: "name" }`.
76
- case "Skill": {
77
- const skill = typeof input.skill === "string" ? input.skill : "a skill";
78
- return `Applying the "${skill}" skill`;
79
- }
58
+ case "Skill":
59
+ return labels.applyingSkill(text(input.skill, labels.aSkill));
80
60
  default:
81
61
  return undefined;
82
62
  }
@@ -1,6 +1,7 @@
1
1
  import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { z } from "zod";
3
3
  import { askForDecision } from "../hooks/humanInput.js";
4
+ import { t } from "../messages/index.js";
4
5
  const APPROVED_ANSWERS = new Set(["", "y", "yes"]);
5
6
  /** Domain-neutral defaults — a concrete agent should normally override these via `AgentSpec.humanApprovalTexts` with wording specific to what "publishing" means in its domain. */
6
7
  export const DEFAULT_HUMAN_APPROVAL_TEXTS = {
@@ -21,8 +22,8 @@ export function createHumanApprovalServer(runDir, texts = DEFAULT_HUMAN_APPROVAL
21
22
  summary: z.string().describe("Brief summary of what you're about to submit or publish"),
22
23
  }, async (args) => {
23
24
  const answer = await askForDecision(runDir, {
24
- title: "Human confirmation required before publishing",
25
- lines: [`Summary: ${args.summary}`],
25
+ title: t().approvalTitle,
26
+ lines: [t().summaryLine(args.summary)],
26
27
  });
27
28
  const approved = APPROVED_ANSWERS.has(answer);
28
29
  return {
package/dist/index.d.ts CHANGED
@@ -9,7 +9,11 @@ export type { Mode, BaseSessionConfig, AgentSpec } from "./core/agentSpec.js";
9
9
  export { buildSessionOptions, createInputQueue, createDeferred, createModeControl, type ModeControl } from "./core/session.js";
10
10
  export { runQuery, type AgentEvent, type AgentRun, type SessionUsage, type ContextUsage } from "./core/runner.js";
11
11
  export { knowledgePluginRoot, knowledgePromptSection } from "./core/knowledge.js";
12
+ export { resolveLanguage, detectLanguage, SUPPORTED_LANGUAGES, type Language, type LanguageSources, type ResolvedLanguage, } from "./core/language.js";
13
+ export { setLanguage, getLanguage, messagesFor, type Messages } from "./core/messages/index.js";
14
+ export { createRunStore, createRunFolder, listRuns, readConversation, type RunFolder, type RunSummary, type ConversationMessage, } from "./core/runs.js";
12
15
  export type { Options, McpServerConfig, AgentDefinition } from "@anthropic-ai/claude-agent-sdk";
16
+ export { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
13
17
  export { createTranscriptLogger, summarizeToolResponse, type TranscriptLogger } from "./core/hooks/transcriptLogger.js";
14
18
  export { createStepGate } from "./core/hooks/stepGate.js";
15
19
  export { createSubagentBashGate } from "./core/hooks/subagentBashGate.js";
@@ -23,6 +27,7 @@ export { createHumanApprovalServer, DEFAULT_HUMAN_APPROVAL_TEXTS, type HumanAppr
23
27
  export { createManualLoginServer, type ManualInterventionTexts } from "./core/tools/manualLogin.js";
24
28
  export { createSaveToSourcesServer } from "./core/tools/saveToSources.js";
25
29
  export { runChatTui, type ChatTuiOptions } from "./tui/chatTui.js";
30
+ export type { SessionOpener } from "./tui/runs.js";
26
31
  export { createConsoleRenderer, type ConsoleRenderer, type ConsoleRendererOptions } from "./tui/consoleRenderer.js";
27
32
  export { runChatInk, type InkChatOptions, type HeaderInfo } from "./tui/ink/runChatInk.js";
28
33
  export type { ToolPhrase } from "./tui/ink/toolGroup.js";
package/dist/index.js CHANGED
@@ -10,6 +10,12 @@ export { createFriendlyToolLabel, truncate, truncatePath, } from "./core/toolLab
10
10
  export { buildSessionOptions, createInputQueue, createDeferred, createModeControl } from "./core/session.js";
11
11
  export { runQuery } from "./core/runner.js";
12
12
  export { knowledgePluginRoot, knowledgePromptSection } from "./core/knowledge.js";
13
+ export { resolveLanguage, detectLanguage, SUPPORTED_LANGUAGES, } from "./core/language.js";
14
+ export { setLanguage, getLanguage, messagesFor } from "./core/messages/index.js";
15
+ export { createRunStore, createRunFolder, listRuns, readConversation, } from "./core/runs.js";
16
+ // And the two functions an agent needs for tools of its own (an in-process MCP server,
17
+ // returned from AgentSpec.buildMcpServers()), so it doesn't need the SDK for those either.
18
+ export { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
13
19
  export { createTranscriptLogger, summarizeToolResponse } from "./core/hooks/transcriptLogger.js";
14
20
  export { createStepGate } from "./core/hooks/stepGate.js";
15
21
  export { createSubagentBashGate } from "./core/hooks/subagentBashGate.js";
@@ -1,5 +1,6 @@
1
1
  import type { Options } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { type AgentEvent } from "../core/runner.js";
3
+ import { type SessionOpener } from "./runs.js";
3
4
  export interface ChatTuiOptions {
4
5
  /**
5
6
  * Turns a tool call into a one-line console label. Defaults to `createFriendlyToolLabel()`
@@ -37,7 +38,7 @@ export interface ChatTuiOptions {
37
38
  * so a session leaves behind an exact, always-present transcript of what the terminal
38
39
  * showed even when the agent never calls a single tool (unlike `transcriptPath` from
39
40
  * `buildSessionOptions()`, which only gets written to on the first tool call). Omit to
40
- * skip session logging entirely.
41
+ * skip session logging entirely. With `runsDir`, the run's own `session.log` is used instead.
41
42
  */
42
43
  sessionLogPath?: string;
43
44
  /**
@@ -53,6 +54,22 @@ export interface ChatTuiOptions {
53
54
  historyPath?: string;
54
55
  /** Max entries kept in `historyPath` (and in the in-session ↑/↓ list). Defaults to 100. */
55
56
  historyLimit?: number;
57
+ /**
58
+ * The language of the kit's texts ("en", "es", "fr", "de"). `--language=<code>` on the
59
+ * command line wins; without either, the one `buildSessionOptions()` chose from
60
+ * `config.language`, or else the system's.
61
+ */
62
+ language?: string;
63
+ /**
64
+ * The agent's runs folder, with a session opener in place of the options (e.g.
65
+ * `(run) => buildSessionOptions(config, run.dir, spec, { run })`): each run gets its own
66
+ * folder (`<runsDir>/<timestamp>/`) keeping its conversation, its session log
67
+ * (`session.log`, instead of `sessionLogPath`) and its transcript, and can be resumed:
68
+ * `--continue` on the command line starts with the latest run, and `/resume` lists them
69
+ * (date and the human's last message) to switch to one. The runs folder holds the whole
70
+ * conversation, tool results included and not redacted: keep it out of version control.
71
+ */
72
+ runsDir?: string;
56
73
  }
57
74
  interface HistoryEntry {
58
75
  text: string;
@@ -97,9 +114,13 @@ export declare function drainTurn(events: AsyncIterator<AgentEvent>, onEvent: (e
97
114
  * const { options } = await buildSessionOptions(config, runDir, spec);
98
115
  * await runChatTui(options, { welcomeMessage: "Ready. Type /exit to quit." });
99
116
  *
117
+ * or, to keep each run's conversation in its folder and resume it (see `runsDir`):
118
+ *
119
+ * await runChatTui((run) => buildSessionOptions(config, run.dir, spec, { run }), { runsDir });
120
+ *
100
121
  * Resolves once the chat ends (an exit command, Ctrl+C/Ctrl+D at an idle prompt) — this
101
122
  * function owns the whole session lifecycle, closing the underlying query and readline
102
123
  * interface itself before returning.
103
124
  */
104
- export declare function runChatTui(options: Options, tuiOptions?: ChatTuiOptions): Promise<void>;
125
+ export declare function runChatTui(options: Options | SessionOpener, tuiOptions?: ChatTuiOptions): Promise<void>;
105
126
  export {};
@@ -5,9 +5,13 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { createInputQueue } from "../core/session.js";
7
7
  import { runQuery } from "../core/runner.js";
8
+ import { listRuns, readConversation } from "../core/runs.js";
8
9
  import { isSharedQuestionActive, setSharedReadline } from "./terminalInteraction.js";
9
10
  import * as ui from "./ui.js";
11
+ import { t } from "../core/messages/index.js";
12
+ import { applyLanguage } from "./language.js";
10
13
  import { createConsoleRenderer } from "./consoleRenderer.js";
14
+ import { firstRun, openSession, runDate, runFolderOf, runLabel } from "./runs.js";
11
15
  // Only strips picocolors' own SGR sequences (`\x1b[<codes>m`) — the only kind this file
12
16
  // ever writes to the console — not a general-purpose ANSI stripper for arbitrary escape
13
17
  // sequences (cursor movement, OSC, ...) this kit never emits.
@@ -92,16 +96,27 @@ export async function drainTurn(events, onEvent) {
92
96
  * const { options } = await buildSessionOptions(config, runDir, spec);
93
97
  * await runChatTui(options, { welcomeMessage: "Ready. Type /exit to quit." });
94
98
  *
99
+ * or, to keep each run's conversation in its folder and resume it (see `runsDir`):
100
+ *
101
+ * await runChatTui((run) => buildSessionOptions(config, run.dir, spec, { run }), { runsDir });
102
+ *
95
103
  * Resolves once the chat ends (an exit command, Ctrl+C/Ctrl+D at an idle prompt) — this
96
104
  * function owns the whole session lifecycle, closing the underlying query and readline
97
105
  * interface itself before returning.
98
106
  */
99
107
  export async function runChatTui(options, tuiOptions = {}) {
108
+ applyLanguage(tuiOptions.language);
100
109
  const exitCommands = new Set((tuiOptions.exitCommands ?? DEFAULT_EXIT_COMMANDS).map((c) => c.toLowerCase()));
101
110
  const promptLabel = tuiOptions.promptLabel ?? DEFAULT_PROMPT_LABEL;
102
- const queue = createInputQueue();
103
- const run = runQuery(queue.iterable, options);
104
- const events = run.events[Symbol.asyncIterator]();
111
+ const opener = typeof options === "function" ? options : null;
112
+ const runsDir = opener ? tuiOptions.runsDir : undefined;
113
+ if (opener && !runsDir)
114
+ throw new Error("runChatTui(): a session opener needs `runsDir`.");
115
+ // The session in use: with runs, the run's folder, rebuilt on /resume.
116
+ let current = opener && runsDir ? await openSession(opener, await firstRun(runsDir)) : { options: options, run: null };
117
+ let queue = createInputQueue();
118
+ let run = runQuery(queue.iterable, current.options);
119
+ let events = run.events[Symbol.asyncIterator]();
105
120
  const historyLimit = tuiOptions.historyLimit ?? DEFAULT_HISTORY_LIMIT;
106
121
  let historyEntries = tuiOptions.historyPath ? capHistory(await loadHistory(tuiOptions.historyPath), historyLimit) : [];
107
122
  const rl = readline.createInterface({
@@ -113,7 +128,14 @@ export async function runChatTui(options, tuiOptions = {}) {
113
128
  historySize: historyLimit,
114
129
  });
115
130
  setSharedReadline(rl);
116
- const sessionLog = tuiOptions.sessionLogPath ? createWriteStream(tuiOptions.sessionLogPath, { flags: "a" }) : null;
131
+ // With runs, the session log is the run's own; it moves with /resume.
132
+ let sessionLog = null;
133
+ function openLog() {
134
+ sessionLog?.end();
135
+ const file = current.run ? path.join(current.run.dir, "session.log") : tuiOptions.sessionLogPath;
136
+ sessionLog = file ? createWriteStream(file, { flags: "a" }) : null;
137
+ }
138
+ openLog();
117
139
  // Mirrors to the log file only — for text already visible on the terminal some other
118
140
  // way (readline's own prompt + the human's local echo), so it isn't printed twice.
119
141
  function mirror(text) {
@@ -182,7 +204,7 @@ export async function runChatTui(options, tuiOptions = {}) {
182
204
  return;
183
205
  turnInterrupted = true;
184
206
  void run.interrupt();
185
- writeLine(ui.warn("(interrupted)"));
207
+ writeLine(ui.warn(t().interrupted));
186
208
  }
187
209
  rl.on("SIGINT", () => {
188
210
  if (turnInFlight) {
@@ -203,10 +225,48 @@ export async function runChatTui(options, tuiOptions = {}) {
203
225
  interruptTurn();
204
226
  };
205
227
  stdin.on("keypress", onKeypress);
228
+ /** Prints the run's earlier conversation (when it has one) and says it was resumed. */
229
+ async function showConversation(folder) {
230
+ if (!folder.sessionId)
231
+ return;
232
+ for (const message of await readConversation(folder.dir)) {
233
+ // Printed, not logged: the run's session log already has it.
234
+ stdout.write(message.role === "user" ? `${promptLabel}${message.text}\n` : `\n${tuiOptions.agentLabel ? `${tuiOptions.agentLabel} ` : ""}${message.text}\n`);
235
+ }
236
+ const [summary] = (await listRuns(path.dirname(folder.dir))).filter((r) => path.resolve(r.dir) === path.resolve(folder.dir));
237
+ writeLine(ui.dim(`\n${t().resumed(runDate(summary?.updatedAt ?? new Date()))}`));
238
+ }
239
+ /** /resume: lists the runs, asks for one and switches the session to it. */
240
+ async function resume() {
241
+ if (!opener || !runsDir)
242
+ return;
243
+ const runs = await listRuns(runsDir);
244
+ if (runs.length === 0) {
245
+ writeLine(ui.dim(t().noEarlierRuns));
246
+ return;
247
+ }
248
+ writeLine(ui.heading(t().resumeTitle));
249
+ runs.forEach((summary, i) => writeLine(` ${i + 1}. ${runLabel(summary, current.run ?? undefined)}`));
250
+ const answer = (await rl.question(t().resumeQuestion)).trim();
251
+ const summary = runs[Number(answer) - 1];
252
+ if (!answer || !summary || (current.run && path.resolve(summary.dir) === path.resolve(current.run.dir)))
253
+ return;
254
+ queue.end();
255
+ run.close();
256
+ current = await openSession(opener, await runFolderOf(summary));
257
+ openLog();
258
+ queue = createInputQueue();
259
+ run = runQuery(queue.iterable, current.options);
260
+ events = run.events[Symbol.asyncIterator]();
261
+ knownCommandTokens = null;
262
+ await showConversation(current.run);
263
+ }
206
264
  if (tuiOptions.welcomeMessage)
207
265
  writeLine(tuiOptions.welcomeMessage);
208
266
  try {
209
- if (tuiOptions.initialPrompt) {
267
+ if (current.run)
268
+ await showConversation(current.run);
269
+ if (tuiOptions.initialPrompt && !current.run?.sessionId) {
210
270
  mirror(`${promptLabel}${tuiOptions.initialPrompt}\n`);
211
271
  queue.push(tuiOptions.initialPrompt);
212
272
  turnInFlight = true;
@@ -233,6 +293,10 @@ export async function runChatTui(options, tuiOptions = {}) {
233
293
  }
234
294
  if (exitCommands.has(line.toLowerCase()))
235
295
  break;
296
+ if (runsDir && line.toLowerCase() === "/resume") {
297
+ await resume();
298
+ continue;
299
+ }
236
300
  // A line that *looks* like a slash command but matches nothing registered (a typo,
237
301
  // an unnamespaced plugin command, ...) would otherwise reach the model as literal
238
302
  // text with no special handling — confirmed empirically (captain-whiskers'
@@ -242,7 +306,7 @@ export async function runChatTui(options, tuiOptions = {}) {
242
306
  // silence on unrecognized input might mean.
243
307
  const commandToken = slashCommandToken(line);
244
308
  if (commandToken && !(await isKnownSlashCommand(commandToken))) {
245
- writeLine(ui.warn(`Unknown command: /${commandToken}`));
309
+ writeLine(ui.warn(t().unknownCommand(commandToken)));
246
310
  continue;
247
311
  }
248
312
  queue.push(line);
@@ -16,4 +16,6 @@ import { type ClaudeAuthConfig } from "../core/claudeAuth.js";
16
16
  * Only makes sense with a real terminal in front of a human (a `runWizard()` confirm, colored console
17
17
  * output), which is why it lives here rather than in the non-interactive core.
18
18
  */
19
- export declare function ensureClaudeAuth(config?: ClaudeAuthConfig): Promise<string | undefined>;
19
+ export declare function ensureClaudeAuth(config?: ClaudeAuthConfig, options?: {
20
+ language?: string;
21
+ }): Promise<string | undefined>;
@@ -3,6 +3,8 @@ import { resolveClaudeAuth } from "../core/claudeAuth.js";
3
3
  import { isExitPromptError } from "./promptErrors.js";
4
4
  import * as ui from "./ui.js";
5
5
  import { runWizard } from "./ink/wizard.js";
6
+ import { applyLanguage } from "./language.js";
7
+ import { t } from "../core/messages/index.js";
6
8
  // The real format of "claude setup-token" tokens ("sk-ant-oat01-...", seen in the
7
9
  // installed @anthropic-ai/claude-code binary) — used to extract the token from its output.
8
10
  const TOKEN_PATTERN = /sk-ant-oat\d{2}-[A-Za-z0-9_-]+/;
@@ -61,23 +63,17 @@ function runSetupToken() {
61
63
  * Only makes sense with a real terminal in front of a human (a `runWizard()` confirm, colored console
62
64
  * output), which is why it lives here rather than in the non-interactive core.
63
65
  */
64
- export async function ensureClaudeAuth(config = {}) {
66
+ export async function ensureClaudeAuth(config = {}, options = {}) {
65
67
  if (resolveClaudeAuth(config))
66
68
  return undefined;
67
- console.log(ui.warn("\nNo Claude authentication token was found"));
68
- console.log(ui.dim("(neither the CLAUDE_CODE_OAUTH_TOKEN environment variable, nor one passed in)."));
69
+ applyLanguage(options.language);
70
+ const texts = t().auth;
71
+ console.log(ui.warn(`\n${texts.noToken}`));
72
+ console.log(ui.dim(texts.noTokenDetail));
69
73
  try {
70
- const { generate } = await runWizard([
71
- {
72
- type: "confirm",
73
- name: "generate",
74
- message: 'Generate one now with "claude setup-token" (requires a Claude Pro/Max subscription)?',
75
- default: true,
76
- },
77
- ]);
74
+ const { generate } = await runWizard([{ type: "confirm", name: "generate", message: texts.generateQuestion, default: true }]);
78
75
  if (!generate) {
79
- console.log(ui.error("\nThe agent can't start without a token. Set CLAUDE_CODE_OAUTH_TOKEN by hand " +
80
- "(or run this again and accept generating it) and try again."));
76
+ console.log(ui.error(`\n${texts.cantStart}`));
81
77
  process.exit(0);
82
78
  }
83
79
  }
@@ -86,11 +82,10 @@ export async function ensureClaudeAuth(config = {}) {
86
82
  process.exit(0);
87
83
  throw error;
88
84
  }
89
- console.log(ui.heading("\n=== Generating a Claude authentication token ==="));
90
- console.log(ui.dim("A browser will open to log in; follow the instructions the command itself prints " +
91
- "below.\n"));
85
+ console.log(ui.heading(`\n${texts.generatingHeading}`));
86
+ console.log(ui.dim(`${texts.browserWillOpen}\n`));
92
87
  const token = await runSetupToken();
93
88
  process.env.CLAUDE_CODE_OAUTH_TOKEN = token;
94
- console.log(ui.success("\nToken generated for this run. Save it yourself (e.g. CLAUDE_CODE_OAUTH_TOKEN in your shell profile) to skip this next time.\n"));
89
+ console.log(ui.success(`\n${texts.generated}\n`));
95
90
  return token;
96
91
  }
@@ -1,6 +1,7 @@
1
1
  import { stdout } from "node:process";
2
2
  import { createFriendlyToolLabel } from "../core/toolLabels.js";
3
3
  import * as ui from "./ui.js";
4
+ import { t } from "../core/messages/index.js";
4
5
  /**
5
6
  * The console rendering of `runQuery()`'s event stream, shared by `runChatTui()` and by any
6
7
  * one-shot run that prints the same events. Streamed text rarely ends in a newline, so
@@ -46,7 +47,7 @@ export function createConsoleRenderer(options = {}) {
46
47
  case "prompt-suggestion":
47
48
  return;
48
49
  case "mcp-error":
49
- writeLine(ui.warn(`Some MCP servers failed to connect: ${event.failedServers.join(", ")}`));
50
+ writeLine(ui.warn(t().mcpFailed(event.failedServers.join(", "))));
50
51
  return;
51
52
  case "info":
52
53
  writeLine((event.level === "warning" ? ui.warn : ui.dim)(`(${event.text})`));
@@ -27,8 +27,6 @@ export interface PromptInputProps {
27
27
  /** Project files for `@` mentions (paths relative to the session's folder). */
28
28
  files?: readonly string[];
29
29
  }
30
- /** The shortcuts panel, opened with "?" on an empty prompt. */
31
- export declare const SHORTCUTS: string[];
32
30
  /**
33
31
  * The chat's prompt: history (↑/↓), "/command" completion and suggestions (Tab), pasted
34
32
  * blocks folded into a token, multi-line input (`\` + Enter or Ctrl+J), word and line