@falkenslab/agent-kit 0.18.0 → 0.20.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 (102) hide show
  1. package/assets/extension-launcher.mjs +24 -0
  2. package/dist/chat/chatController.d.ts +267 -0
  3. package/dist/chat/chatController.js +536 -0
  4. package/dist/chat/extensions.d.ts +18 -0
  5. package/dist/chat/extensions.js +44 -0
  6. package/dist/chat/history.d.ts +17 -0
  7. package/dist/chat/history.js +39 -0
  8. package/dist/{tui → chat}/runs.d.ts +11 -1
  9. package/dist/{tui → chat}/runs.js +1 -1
  10. package/dist/core/agentSpec.d.ts +28 -50
  11. package/dist/core/extensions.d.ts +128 -0
  12. package/dist/core/extensions.js +87 -0
  13. package/dist/core/externalExtensions.d.ts +143 -0
  14. package/dist/core/externalExtensions.js +368 -0
  15. package/dist/core/hooks/planGate.d.ts +2 -0
  16. package/dist/core/hooks/planGate.js +1 -10
  17. package/dist/core/hooks/stepGate.d.ts +1 -1
  18. package/dist/core/hooks/stepGate.js +3 -3
  19. package/dist/core/liveExtensions.d.ts +51 -0
  20. package/dist/core/liveExtensions.js +70 -0
  21. package/dist/core/marketplaces.d.ts +118 -0
  22. package/dist/core/marketplaces.js +242 -0
  23. package/dist/core/messages/de.js +12 -32
  24. package/dist/core/messages/en.d.ts +15 -16
  25. package/dist/core/messages/en.js +12 -32
  26. package/dist/core/messages/es.js +12 -32
  27. package/dist/core/messages/fr.js +12 -32
  28. package/dist/core/messages/index.d.ts +6 -0
  29. package/dist/core/messages/index.js +12 -1
  30. package/dist/core/packaged.d.ts +10 -0
  31. package/dist/core/packaged.js +38 -0
  32. package/dist/core/pluginAgents.d.ts +20 -0
  33. package/dist/core/pluginAgents.js +76 -0
  34. package/dist/core/pluginDownloads.d.ts +14 -0
  35. package/dist/core/pluginDownloads.js +141 -0
  36. package/dist/core/runner.js +21 -9
  37. package/dist/core/session.d.ts +38 -6
  38. package/dist/core/session.js +200 -99
  39. package/dist/core/sessionFacts.d.ts +85 -0
  40. package/dist/core/sessionFacts.js +49 -0
  41. package/dist/core/toolLabels.d.ts +24 -6
  42. package/dist/core/toolLabels.js +24 -39
  43. package/dist/extensions/awareness/index.d.ts +22 -0
  44. package/dist/extensions/awareness/index.js +42 -0
  45. package/dist/extensions/awareness/labels.d.ts +3 -0
  46. package/dist/extensions/awareness/labels.js +12 -0
  47. package/dist/extensions/awareness/tools.d.ts +5 -0
  48. package/dist/extensions/awareness/tools.js +54 -0
  49. package/dist/extensions/knowledge/index.d.ts +22 -0
  50. package/dist/extensions/knowledge/index.js +38 -0
  51. package/dist/{core → extensions/knowledge}/knowledgeStore.d.ts +2 -1
  52. package/dist/extensions/knowledge/labels.d.ts +3 -0
  53. package/dist/extensions/knowledge/labels.js +119 -0
  54. package/dist/{core/knowledge.d.ts → extensions/knowledge/prompt.d.ts} +4 -4
  55. package/dist/{core/knowledge.js → extensions/knowledge/prompt.js} +10 -7
  56. package/dist/{core/tools/knowledgeTools.d.ts → extensions/knowledge/tools.d.ts} +1 -1
  57. package/dist/{core/tools/knowledgeTools.js → extensions/knowledge/tools.js} +2 -2
  58. package/dist/extensions/memory/index.d.ts +21 -0
  59. package/dist/extensions/memory/index.js +59 -0
  60. package/dist/extensions/memory/labels.d.ts +3 -0
  61. package/dist/extensions/memory/labels.js +58 -0
  62. package/dist/extensions/memory/memoryStore.d.ts +53 -0
  63. package/dist/extensions/memory/memoryStore.js +88 -0
  64. package/dist/extensions/memory/tools.d.ts +9 -0
  65. package/dist/extensions/memory/tools.js +82 -0
  66. package/dist/extensions/sources/index.d.ts +21 -0
  67. package/dist/extensions/sources/index.js +42 -0
  68. package/dist/extensions/sources/labels.d.ts +3 -0
  69. package/dist/extensions/sources/labels.js +82 -0
  70. package/dist/{core/tools/saveToSources.d.ts → extensions/sources/tools.d.ts} +1 -1
  71. package/dist/{core/tools/saveToSources.js → extensions/sources/tools.js} +5 -5
  72. package/dist/index.d.ts +19 -9
  73. package/dist/index.js +16 -7
  74. package/dist/tui/chatTui.d.ts +11 -20
  75. package/dist/tui/chatTui.js +75 -230
  76. package/dist/tui/extensionCommand.d.ts +30 -0
  77. package/dist/tui/extensionCommand.js +257 -0
  78. package/dist/tui/ink/runChatInk.d.ts +4 -3
  79. package/dist/tui/ink/runChatInk.js +101 -217
  80. package/extensions/awareness/.claude-plugin/plugin.json +10 -0
  81. package/{assets/agent-help → extensions/awareness/skills/help}/SKILL.md +4 -3
  82. package/{assets/knowledge-plugin → extensions/knowledge}/.claude-plugin/plugin.json +7 -1
  83. package/extensions/memory/.claude-plugin/plugin.json +10 -0
  84. package/extensions/memory/commands/forget.md +8 -0
  85. package/extensions/memory/commands/list.md +5 -0
  86. package/extensions/sources/.claude-plugin/plugin.json +10 -0
  87. package/package.json +3 -2
  88. package/dist/core/agentHelp.d.ts +0 -30
  89. package/dist/core/agentHelp.js +0 -55
  90. /package/dist/{core → extensions/knowledge}/fileKnowledgeStore.d.ts +0 -0
  91. /package/dist/{core → extensions/knowledge}/fileKnowledgeStore.js +0 -0
  92. /package/dist/{core → extensions/knowledge}/knowledgeStore.js +0 -0
  93. /package/dist/{core → extensions/sources}/extractText.d.ts +0 -0
  94. /package/dist/{core → extensions/sources}/extractText.js +0 -0
  95. /package/dist/{core → extensions/sources}/sources.d.ts +0 -0
  96. /package/dist/{core → extensions/sources}/sources.js +0 -0
  97. /package/{assets/knowledge-plugin → extensions/knowledge}/commands/ingest.md +0 -0
  98. /package/{assets/knowledge-plugin → extensions/knowledge}/commands/lint.md +0 -0
  99. /package/{assets/knowledge-plugin → extensions/knowledge}/commands/query.md +0 -0
  100. /package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-ingest/SKILL.md +0 -0
  101. /package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-lint/SKILL.md +0 -0
  102. /package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-query/SKILL.md +0 -0
package/dist/index.js CHANGED
@@ -7,16 +7,24 @@ export { allowAnyMcpTool } from "./core/mcpPermissions.js";
7
7
  export { createPromptLoader } from "./core/promptTemplate.js";
8
8
  export { resolveClaudeAuth } from "./core/claudeAuth.js";
9
9
  export { ensureClaudeAuth } from "./tui/claudeAuth.js";
10
- export { createFriendlyToolLabel, truncate, truncatePath, } from "./core/toolLabels.js";
10
+ export { createFriendlyToolLabel, truncate, truncatePath, withToolLabels, withToolPhrases, } from "./core/toolLabels.js";
11
11
  export { buildSessionOptions, createInputQueue, createDeferred, createModeControl, togglePlanMode } from "./core/session.js";
12
12
  export { runQuery } from "./core/runner.js";
13
- export { knowledgePluginRoot, knowledgePromptSection } from "./core/knowledge.js";
13
+ export { knowledgePluginRoot, knowledgePromptSection } from "./extensions/knowledge/prompt.js";
14
+ export { awareness } from "./extensions/awareness/index.js";
15
+ export { sources } from "./extensions/sources/index.js";
16
+ export { knowledge } from "./extensions/knowledge/index.js";
17
+ export { memory } from "./extensions/memory/index.js";
18
+ export { readExtensionManifest, } from "./core/extensions.js";
19
+ export { addExtension, removeExtension, setExtensionEnabled, listInstalled, readExternalManifest, } from "./core/externalExtensions.js";
20
+ export { addMarketplace, findPlugin, inspectMarketplace, installFromMarketplace, listMarketplaces, readMarketplaceManifest, removeMarketplace, resolvePluginSource, updateMarketplace, } from "./core/marketplaces.js";
21
+ export { runExtensionCommand } from "./tui/extensionCommand.js";
14
22
  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";
23
+ export { BUILT_IN_PAGE_TYPES, isSlug, parseId, linkedIds, } from "./extensions/knowledge/knowledgeStore.js";
24
+ export { createFileKnowledgeStore } from "./extensions/knowledge/fileKnowledgeStore.js";
25
+ export { createKnowledgeServer } from "./extensions/knowledge/tools.js";
18
26
  export { resolveLanguage, detectLanguage, SUPPORTED_LANGUAGES, } from "./core/language.js";
19
- export { setLanguage, getLanguage, messagesFor } from "./core/messages/index.js";
27
+ export { setLanguage, switchLanguage, getLanguage, messagesFor } from "./core/messages/index.js";
20
28
  export { createRunStore, createRunFolder, listRuns, readConversation, } from "./core/runs.js";
21
29
  // And the two functions an agent needs for tools of its own (an in-process MCP server,
22
30
  // returned from AgentSpec.buildMcpServers()), so it doesn't need the SDK for those either.
@@ -36,8 +44,9 @@ export { terminalInteractionPort, setSharedReadline, getSharedReadline } from ".
36
44
  setInteractionPort(terminalInteractionPort);
37
45
  export { createHumanApprovalServer, DEFAULT_HUMAN_APPROVAL_TEXTS } from "./core/tools/humanApproval.js";
38
46
  export { createManualLoginServer } from "./core/tools/manualLogin.js";
39
- export { createSaveToSourcesServer, sourcesPromptSection } from "./core/tools/saveToSources.js";
47
+ export { createSaveToSourcesServer, sourcesPromptSection } from "./extensions/sources/tools.js";
40
48
  export { runChatTui } from "./tui/chatTui.js";
49
+ export { createChatController, } from "./chat/chatController.js";
41
50
  export { createConsoleRenderer } from "./tui/consoleRenderer.js";
42
51
  export { runChatInk } from "./tui/ink/runChatInk.js";
43
52
  export { createProgressView } from "./tui/ink/progressView.js";
@@ -1,8 +1,10 @@
1
1
  import type { Options } from "@anthropic-ai/claude-agent-sdk";
2
- import { type ModeControl } from "../core/session.js";
3
- import { type AgentEvent } from "../core/runner.js";
2
+ import type { ModeControl } from "../core/session.js";
3
+ import type { AgentEvent } from "../core/runner.js";
4
4
  import { type Theme } from "./theme.js";
5
- import { type SessionOpener } from "./runs.js";
5
+ import type { ToolLabels } from "../core/toolLabels.js";
6
+ import { type SessionOpener } from "../chat/runs.js";
7
+ export { capHistory, loadHistory, saveHistory, slashCommandToken } from "../chat/history.js";
6
8
  /** Options of the chats (`runChatTui()`, and `runChatInk()` through `InkChatOptions`). */
7
9
  export interface ChatTuiOptions {
8
10
  /**
@@ -80,6 +82,12 @@ export interface ChatTuiOptions {
80
82
  * returns is used instead.
81
83
  */
82
84
  modeControl?: ModeControl;
85
+ /**
86
+ * How the chat shows the extensions' tools (`buildSessionOptions()` returns them as
87
+ * `toolLabels`), before `formatAction`. With a session opener, the ones it returns are used
88
+ * instead.
89
+ */
90
+ toolLabels?: ToolLabels;
83
91
  /**
84
92
  * Colors for the kit's roles (see `Theme`), on top of the kit's defaults: only the roles
85
93
  * given change, e.g. `{ toolResult: "yellow", selection: "#00ff00" }`. One theme per
@@ -87,22 +95,6 @@ export interface ChatTuiOptions {
87
95
  */
88
96
  theme?: Partial<Theme>;
89
97
  }
90
- interface HistoryEntry {
91
- text: string;
92
- timestamp: string;
93
- }
94
- /** Keeps only the most recent `limit` entries (oldest-first order in, oldest-first out). */
95
- export declare function capHistory(entries: readonly HistoryEntry[], limit: number): HistoryEntry[];
96
- /** Oldest-first. Missing file reads as empty — nothing to load yet is the normal case. */
97
- export declare function loadHistory(filePath: string): Promise<HistoryEntry[]>;
98
- /** Overwrites the whole file (not appended) so a prior prune (oldest entries dropped) sticks. */
99
- export declare function saveHistory(filePath: string, entries: readonly HistoryEntry[]): Promise<void>;
100
- /**
101
- * The command name a typed line would invoke (e.g. "chiste" for both "/chiste" and
102
- * "/chiste con salsa"), or `null` for a line that isn't a slash command at all (doesn't
103
- * start with "/") or is just a bare "/" with nothing after it.
104
- */
105
- export declare function slashCommandToken(line: string): string | null;
106
98
  /**
107
99
  * Reads `events` one turn at a time — up to and including a `turn-end` — via manual
108
100
  * `.next()` calls, calling `onEvent` for each. Deliberately never uses
@@ -139,4 +131,3 @@ export declare function drainTurn(events: AsyncIterator<AgentEvent>, onEvent: (e
139
131
  * interface itself before returning.
140
132
  */
141
133
  export declare function runChatTui(options: Options | SessionOpener, tuiOptions?: ChatTuiOptions): Promise<void>;
142
- export {};
@@ -1,66 +1,17 @@
1
1
  import readline from "node:readline/promises";
2
2
  import { stdin, stdout } from "node:process";
3
- import { createWriteStream } from "node:fs";
4
- import { mkdir, readFile, writeFile } from "node:fs/promises";
5
- import path from "node:path";
6
- import { createInputQueue, togglePlanMode } from "../core/session.js";
7
- import { runQuery } from "../core/runner.js";
8
- import { listRuns, readConversation } from "../core/runs.js";
9
3
  import { isSharedQuestionActive, setSharedReadline } from "./terminalInteraction.js";
10
4
  import * as ui from "./ui.js";
11
5
  import { t } from "../core/messages/index.js";
12
6
  import { applyLanguage } from "./language.js";
13
7
  import { applyTheme } from "./theme.js";
14
8
  import { createConsoleRenderer } from "./consoleRenderer.js";
15
- import { firstRun, openSession, runDate, runFolderOf, runLabel } from "./runs.js";
16
- // Only strips picocolors' own SGR sequences (`\x1b[<codes>m`) — the only kind this file
17
- // ever writes to the console — not a general-purpose ANSI stripper for arbitrary escape
18
- // sequences (cursor movement, OSC, ...) this kit never emits.
19
- // eslint-disable-next-line no-control-regex -- \x1b is the ESC byte SGR sequences start with, not an accident
20
- const ANSI_SGR = /\x1b\[[0-9;]*m/g;
21
- function stripAnsi(text) {
22
- return text.replace(ANSI_SGR, "");
23
- }
9
+ import { runDate } from "../chat/runs.js";
10
+ import { createChatController } from "../chat/chatController.js";
11
+ export { capHistory, loadHistory, saveHistory, slashCommandToken } from "../chat/history.js";
24
12
  const DEFAULT_PROMPT_LABEL = "\n> ";
25
13
  const DEFAULT_EXIT_COMMANDS = ["/exit", "/quit"];
26
14
  const DEFAULT_HISTORY_LIMIT = 100;
27
- /** Keeps only the most recent `limit` entries (oldest-first order in, oldest-first out). */
28
- export function capHistory(entries, limit) {
29
- return entries.length > limit ? entries.slice(-limit) : entries.slice();
30
- }
31
- /** Oldest-first. Missing file reads as empty — nothing to load yet is the normal case. */
32
- export async function loadHistory(filePath) {
33
- let raw;
34
- try {
35
- raw = await readFile(filePath, "utf8");
36
- }
37
- catch (error) {
38
- if (error.code === "ENOENT")
39
- return [];
40
- throw error;
41
- }
42
- return raw
43
- .split("\n")
44
- .filter((line) => line.trim().length > 0)
45
- .map((line) => JSON.parse(line));
46
- }
47
- /** Overwrites the whole file (not appended) so a prior prune (oldest entries dropped) sticks. */
48
- export async function saveHistory(filePath, entries) {
49
- await mkdir(path.dirname(filePath), { recursive: true });
50
- const body = entries.map((entry) => JSON.stringify(entry)).join("\n");
51
- await writeFile(filePath, entries.length > 0 ? `${body}\n` : "", "utf8");
52
- }
53
- /**
54
- * The command name a typed line would invoke (e.g. "chiste" for both "/chiste" and
55
- * "/chiste con salsa"), or `null` for a line that isn't a slash command at all (doesn't
56
- * start with "/") or is just a bare "/" with nothing after it.
57
- */
58
- export function slashCommandToken(line) {
59
- if (!line.startsWith("/"))
60
- return null;
61
- const token = line.slice(1).split(/\s/, 1)[0];
62
- return token ? token : null;
63
- }
64
15
  /**
65
16
  * Reads `events` one turn at a time — up to and including a `turn-end` — via manual
66
17
  * `.next()` calls, calling `onEvent` for each. Deliberately never uses
@@ -108,65 +59,48 @@ export async function drainTurn(events, onEvent) {
108
59
  export async function runChatTui(options, tuiOptions = {}) {
109
60
  applyLanguage(tuiOptions.language);
110
61
  applyTheme(tuiOptions.theme);
111
- const exitCommands = new Set((tuiOptions.exitCommands ?? DEFAULT_EXIT_COMMANDS).map((c) => c.toLowerCase()));
112
62
  const promptLabel = tuiOptions.promptLabel ?? DEFAULT_PROMPT_LABEL;
113
- const opener = typeof options === "function" ? options : null;
114
- const runsDir = opener ? tuiOptions.runsDir : undefined;
115
- if (opener && !runsDir)
116
- throw new Error("runChatTui(): a session opener needs `runsDir`.");
117
- // The session in use: with runs, the run's folder, rebuilt on /resume.
118
- let current = opener && runsDir ? await openSession(opener, await firstRun(runsDir)) : { options: options, run: null };
119
- const modeControl = () => current.modeControl ?? tuiOptions.modeControl;
120
- let queue = createInputQueue({ modeControl: modeControl() });
121
- let run = runQuery(queue.iterable, current.options);
122
- let events = run.events[Symbol.asyncIterator]();
123
63
  const historyLimit = tuiOptions.historyLimit ?? DEFAULT_HISTORY_LIMIT;
124
- let historyEntries = tuiOptions.historyPath ? capHistory(await loadHistory(tuiOptions.historyPath), historyLimit) : [];
64
+ // The readline interface and its writer, set below once the history it starts with is loaded.
65
+ const late = {};
66
+ const controller = await createChatController(options, {
67
+ exitCommands: tuiOptions.exitCommands ?? DEFAULT_EXIT_COMMANDS,
68
+ initialPrompt: tuiOptions.initialPrompt,
69
+ sessionLogPath: tuiOptions.sessionLogPath,
70
+ promptLabel,
71
+ agentLabel: tuiOptions.agentLabel,
72
+ historyPath: tuiOptions.historyPath,
73
+ historyLimit,
74
+ runsDir: tuiOptions.runsDir,
75
+ modeControl: tuiOptions.modeControl,
76
+ toolLabels: tuiOptions.toolLabels,
77
+ formatAction: tuiOptions.formatAction,
78
+ // Nothing here shows them.
79
+ promptSuggestions: false,
80
+ // /resume: a numbered list, and a number typed.
81
+ async pick(title, choices) {
82
+ late.writeLine(ui.heading(title));
83
+ choices.forEach((choice) => late.writeLine(` ${choice.label}`));
84
+ const answer = (await late.rl.question(t().resumeQuestion)).trim();
85
+ return choices[Number(answer) - 1]?.value ?? null;
86
+ },
87
+ });
125
88
  const rl = readline.createInterface({
126
89
  input: stdin,
127
90
  output: stdout,
128
- // readline wants most-recent-first (↑ shows historyEntries' last/newest entry first);
129
- // this file is stored oldest-first (append-friendly, reads top-to-bottom like a log).
130
- history: historyEntries.map((entry) => entry.text).reverse(),
91
+ // readline wants most-recent-first (↑ shows the newest entry first); the history is oldest-first.
92
+ history: [...controller.getState().history].reverse(),
131
93
  historySize: historyLimit,
132
94
  });
133
95
  setSharedReadline(rl);
134
- // With runs, the session log is the run's own; it moves with /resume.
135
- let sessionLog = null;
136
- function openLog() {
137
- sessionLog?.end();
138
- const file = current.run ? path.join(current.run.dir, "session.log") : tuiOptions.sessionLogPath;
139
- sessionLog = file ? createWriteStream(file, { flags: "a" }) : null;
140
- }
141
- openLog();
142
- // Mirrors to the log file only — for text already visible on the terminal some other
143
- // way (readline's own prompt + the human's local echo), so it isn't printed twice.
144
- function mirror(text) {
145
- sessionLog?.write(stripAnsi(text));
146
- }
147
- // Prints to the console *and* mirrors to the log file — the normal case for anything
148
- // this loop itself puts on screen (streamed reply text, action/error lines, ...).
149
- // Tracks whether the terminal cursor currently sits at column 0 of a fresh row — true
150
- // right after any write ending in "\n" (writeLine's normal case), false right after a
151
- // streamed text delta that doesn't (the common case: deltas rarely end mid-sentence on a
152
- // newline). Confirmed empirically (against a working chat loop that
153
- // ends its streamed text block with an explicit newline): even with `prevRows` reset to 0 above, if the
154
- // cursor is left *mid-row* when the next prompt redraws, `[kRefreshLine]`'s `cursorTo(0)`
155
- // moves it to column 0 of that *same* row — the row still holding the tail of what we
156
- // just wrote — and the following `clearScreenDown` wipes the row from there on, eating
157
- // whatever was on it (for a short reply, potentially the whole thing, since "that row" is
158
- // its only row). Ending every turn's output on a fresh blank row (see below, after
159
- // `drainTurn()`) sidesteps this entirely: `clearScreenDown` then only ever clears blank
160
- // space, on any row.
161
96
  // Every write goes through the shared console renderer (./consoleRenderer.ts), which
162
97
  // tracks whether the cursor sits at the start of a line and never emits a blank line
163
- // between consecutive action lines.
98
+ // between consecutive action lines. The session log is the controller's.
164
99
  const renderer = createConsoleRenderer({
165
- formatAction: tuiOptions.formatAction,
100
+ formatAction: controller.formatAction,
166
101
  agentLabel: tuiOptions.agentLabel,
167
102
  output: (text) => void stdout.write(text),
168
- onWrite: (text) => {
169
- mirror(text);
103
+ onWrite: () => {
170
104
  // node:readline's `Interface` tracks, on itself, how many terminal rows its own last
171
105
  // rendered prompt+line took (`prevRows`), so that the *next* time it redraws (the next
172
106
  // `rl.question()` for the following turn, but — confirmed empirically — also any
@@ -188,43 +122,45 @@ export async function runChatTui(options, tuiOptions = {}) {
188
122
  },
189
123
  });
190
124
  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();
199
- // Cached lazily (not fetched until the first "/..." line, and only once — the SDK docs
200
- // don't promise this list changes mid-session, and re-fetching per line would add a
201
- // round-trip to the CLI subprocess for every turn): every registered command name/alias,
202
- // built-ins (`/compact`, ...) included, so `/exit`-style local ones never false-flag.
203
- let knownCommandTokens = null;
204
- async function isKnownSlashCommand(token) {
205
- if (!knownCommandTokens) {
206
- const commands = await run.supportedCommands();
207
- knownCommandTokens = new Set(commands.flatMap((c) => [c.name, ...(c.aliases ?? [])]));
125
+ Object.assign(late, { rl, writeLine });
126
+ const toned = (text, tone) => (tone === "plain" ? text : tone === "dim" ? ui.dim(text) : tone === "warn" ? ui.warn(text) : ui.error(text));
127
+ controller.onEvent((event) => {
128
+ switch (event.type) {
129
+ case "agent":
130
+ if (event.event.type !== "prompt-suggestion")
131
+ renderer.render(event.event);
132
+ return;
133
+ case "notice":
134
+ writeLine(toned(event.text, event.tone));
135
+ return;
136
+ case "turn-start":
137
+ renderer.startTurn();
138
+ return;
139
+ case "turn-end":
140
+ // Every turn ends on a fresh row, so readline's redraw only ever clears blank space.
141
+ renderer.endLine();
142
+ return;
143
+ case "conversation":
144
+ for (const message of event.messages) {
145
+ // Printed, not logged: the run's session log already has it.
146
+ stdout.write(message.role === "user" ? `${promptLabel}${message.text}\n` : `\n${tuiOptions.agentLabel ? `${tuiOptions.agentLabel} ` : ""}${message.text}\n`);
147
+ }
148
+ writeLine(ui.dim(`\n${t().resumed(runDate(event.updatedAt))}`));
149
+ return;
150
+ case "mode":
151
+ // The mode changed (/plan, or the agent leaving plan mode with present_plan): say so.
152
+ writeLine(ui.dim(`⏵⏵ ${t().mode(event.mode)}`));
153
+ return;
154
+ case "user":
155
+ case "session":
156
+ return; // readline already echoed the line; nothing to draw for a reopened session
208
157
  }
209
- return knownCommandTokens.has(token);
210
- }
211
- let turnInFlight = false;
212
- let turnInterrupted = false;
213
- function interruptTurn() {
214
- if (turnInterrupted)
215
- return;
216
- turnInterrupted = true;
217
- void run.interrupt();
218
- writeLine(ui.warn(t().interrupted));
219
- }
158
+ });
220
159
  rl.on("SIGINT", () => {
221
- if (turnInFlight) {
222
- interruptTurn();
223
- }
224
- else {
225
- queue.end();
160
+ if (controller.getState().busy)
161
+ controller.interrupt();
162
+ else
226
163
  rl.close();
227
- }
228
164
  });
229
165
  // Esc interrupts the turn in flight too, like Ctrl+C — but, unlike it, never ends the
230
166
  // chat, and does nothing while a human-in-the-loop checkpoint is asking on this same
@@ -232,121 +168,30 @@ export async function runChatTui(options, tuiOptions = {}) {
232
168
  // already emits "keypress" on stdin, and only reports name "escape" for a lone Esc, not
233
169
  // for the Esc-prefixed sequences of arrow/function keys.
234
170
  const onKeypress = (_chunk, key) => {
235
- if (key?.name === "escape" && turnInFlight && !isSharedQuestionActive())
236
- interruptTurn();
171
+ if (key?.name === "escape" && controller.getState().busy && !isSharedQuestionActive())
172
+ controller.interrupt();
237
173
  };
238
174
  stdin.on("keypress", onKeypress);
239
- /** Prints the run's earlier conversation (when it has one) and says it was resumed. */
240
- async function showConversation(folder) {
241
- if (!folder.sessionId)
242
- return;
243
- for (const message of await readConversation(folder.dir)) {
244
- // Printed, not logged: the run's session log already has it.
245
- stdout.write(message.role === "user" ? `${promptLabel}${message.text}\n` : `\n${tuiOptions.agentLabel ? `${tuiOptions.agentLabel} ` : ""}${message.text}\n`);
246
- }
247
- const [summary] = (await listRuns(path.dirname(folder.dir))).filter((r) => path.resolve(r.dir) === path.resolve(folder.dir));
248
- writeLine(ui.dim(`\n${t().resumed(runDate(summary?.updatedAt ?? new Date()))}`));
249
- }
250
- /** /resume: lists the runs, asks for one and switches the session to it. */
251
- async function resume() {
252
- if (!opener || !runsDir)
253
- return;
254
- const runs = await listRuns(runsDir);
255
- if (runs.length === 0) {
256
- writeLine(ui.dim(t().noEarlierRuns));
257
- return;
258
- }
259
- writeLine(ui.heading(t().resumeTitle));
260
- runs.forEach((summary, i) => writeLine(` ${i + 1}. ${runLabel(summary, current.run ?? undefined)}`));
261
- const answer = (await rl.question(t().resumeQuestion)).trim();
262
- const summary = runs[Number(answer) - 1];
263
- if (!answer || !summary || (current.run && path.resolve(summary.dir) === path.resolve(current.run.dir)))
264
- return;
265
- queue.end();
266
- run.close();
267
- current = await openSession(opener, await runFolderOf(summary));
268
- openLog();
269
- queue = createInputQueue({ modeControl: modeControl() });
270
- run = runQuery(queue.iterable, current.options);
271
- events = run.events[Symbol.asyncIterator]();
272
- watchMode();
273
- knownCommandTokens = null;
274
- await showConversation(current.run);
275
- }
276
175
  if (tuiOptions.welcomeMessage)
277
- writeLine(tuiOptions.welcomeMessage);
176
+ controller.notice(tuiOptions.welcomeMessage, "plain");
278
177
  try {
279
- if (current.run)
280
- await showConversation(current.run);
281
- if (tuiOptions.initialPrompt && !current.run?.sessionId) {
282
- mirror(`${promptLabel}${tuiOptions.initialPrompt}\n`);
283
- queue.push(tuiOptions.initialPrompt);
284
- turnInFlight = true;
285
- turnInterrupted = false;
286
- renderer.startTurn();
287
- await drainTurn(events, renderer.render);
288
- turnInFlight = false;
289
- renderer.endLine();
290
- }
178
+ await controller.start();
291
179
  while (true) {
292
180
  let line;
293
181
  try {
294
- line = (await rl.question(promptLabel)).trim();
182
+ line = await rl.question(promptLabel);
295
183
  }
296
184
  catch {
297
185
  break; // the interface closed underneath us (SIGINT/EOF while idle)
298
186
  }
299
- if (!line)
300
- continue;
301
- mirror(`${promptLabel}${line}\n`);
302
- if (tuiOptions.historyPath) {
303
- historyEntries = capHistory([...historyEntries, { text: line, timestamp: new Date().toISOString() }], historyLimit);
304
- await saveHistory(tuiOptions.historyPath, historyEntries);
305
- }
306
- if (exitCommands.has(line.toLowerCase()))
187
+ if ((await controller.send(line)) === "exit")
307
188
  break;
308
- if (runsDir && line.toLowerCase() === "/resume") {
309
- await resume();
310
- continue;
311
- }
312
- // Plan mode on and off (the Ink chat also has Shift+Tab); the line says where it is now.
313
- if (line.toLowerCase() === "/plan") {
314
- const control = modeControl();
315
- const next = control ? togglePlanMode(control) : null;
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)}`));
321
- continue;
322
- }
323
- // A line that *looks* like a slash command but matches nothing registered (a typo,
324
- // an unnamespaced plugin command, ...) would otherwise reach the model as literal
325
- // text with no special handling — confirmed empirically (captain-whiskers'
326
- // /chiste before it was renamed /captain-whiskers:chiste) that this can look
327
- // exactly like nothing happened at all. Caught here, before the turn even starts,
328
- // so it's an immediate, unambiguous message instead of a guess at what the model's
329
- // silence on unrecognized input might mean.
330
- const commandToken = slashCommandToken(line);
331
- if (commandToken && !(await isKnownSlashCommand(commandToken))) {
332
- writeLine(ui.warn(t().unknownCommand(commandToken)));
333
- continue;
334
- }
335
- queue.push(line);
336
- turnInFlight = true;
337
- turnInterrupted = false;
338
- renderer.startTurn();
339
- await drainTurn(events, renderer.render);
340
- turnInFlight = false;
341
- renderer.endLine();
342
189
  }
343
190
  }
344
191
  finally {
345
192
  stdin.off("keypress", onKeypress);
346
- queue.end();
347
- run.close();
193
+ controller.close();
348
194
  setSharedReadline(null);
349
195
  rl.close();
350
- sessionLog?.end();
351
196
  }
352
197
  }
@@ -0,0 +1,30 @@
1
+ import { type ExtensionDirs } from "../core/externalExtensions.js";
2
+ export { chatExtensionsCommand, describe } from "../chat/extensions.js";
3
+ /**
4
+ * The `extension` command an agent exposes in its own binary (#37), e.g. `captain extension add
5
+ * ./jokebook --project`: installs, removes, enables, disables and lists the extensions in its two
6
+ * scopes. Returns `false` when `argv` isn't this command (so the agent goes on), and sets
7
+ * `process.exitCode` to 1 on an error.
8
+ *
9
+ * - `extension list`
10
+ * - `extension info <name>`: its metadata, what it offers and where its README is
11
+ * - `extension add <folder | git URL[#ref] | plugin[@marketplace]> [--project] [--path <subfolder>] [--yes]`
12
+ * - `extension remove|enable|disable <name> [--project | --agent]`
13
+ * - `extension search [words]`: what the known marketplaces offer
14
+ * - `extension marketplace add <folder | git URL[#ref] | owner/repo> [--yes]`, `list`, `update [name]`, `remove <name>`
15
+ *
16
+ * Without a scope, `add` installs into the agent's (the project's when the agent has no scope of
17
+ * its own); the others act where the extension is, the project's first. The marketplaces live in
18
+ * the same folder. `official` is the agent's own marketplace (a folder or git URL): known without
19
+ * asking, and installed from without a confirmation. Any other is added after a warning and
20
+ * typing its name, and installing from it asks first; `--yes` answers for the person (a script).
21
+ */
22
+ export declare function runExtensionCommand(argv: readonly string[], options: {
23
+ dirs: ExtensionDirs;
24
+ command?: string;
25
+ write?: (line: string) => void;
26
+ /** The agent's official marketplace: a folder or a git URL (`#ref`). */
27
+ official?: string;
28
+ /** Asks the person (a confirmation); by default on the terminal, and nothing without one. */
29
+ ask?: (question: string) => Promise<string>;
30
+ }): Promise<boolean>;