@falkenslab/agent-kit 0.18.0 → 0.19.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 (96) hide show
  1. package/assets/extension-launcher.mjs +24 -0
  2. package/dist/chat/chatController.d.ts +264 -0
  3. package/dist/chat/chatController.js +510 -0
  4. package/dist/chat/extensions.d.ts +14 -0
  5. package/dist/chat/extensions.js +41 -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 +8 -1
  9. package/dist/{tui → chat}/runs.js +1 -1
  10. package/dist/core/agentSpec.d.ts +30 -16
  11. package/dist/core/extensions.d.ts +119 -0
  12. package/dist/core/extensions.js +97 -0
  13. package/dist/core/externalExtensions.d.ts +131 -0
  14. package/dist/core/externalExtensions.js +340 -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/messages/de.js +11 -32
  20. package/dist/core/messages/en.d.ts +13 -16
  21. package/dist/core/messages/en.js +11 -32
  22. package/dist/core/messages/es.js +11 -32
  23. package/dist/core/messages/fr.js +11 -32
  24. package/dist/core/messages/index.d.ts +6 -0
  25. package/dist/core/messages/index.js +12 -1
  26. package/dist/core/packaged.d.ts +10 -0
  27. package/dist/core/packaged.js +38 -0
  28. package/dist/core/pluginAgents.d.ts +20 -0
  29. package/dist/core/pluginAgents.js +76 -0
  30. package/dist/core/runner.js +16 -9
  31. package/dist/core/session.d.ts +30 -5
  32. package/dist/core/session.js +143 -94
  33. package/dist/core/sessionFacts.d.ts +80 -0
  34. package/dist/core/sessionFacts.js +48 -0
  35. package/dist/core/toolLabels.d.ts +24 -6
  36. package/dist/core/toolLabels.js +24 -39
  37. package/dist/extensions/awareness/index.d.ts +13 -0
  38. package/dist/extensions/awareness/index.js +39 -0
  39. package/dist/extensions/awareness/labels.d.ts +3 -0
  40. package/dist/extensions/awareness/labels.js +12 -0
  41. package/dist/extensions/awareness/tools.d.ts +5 -0
  42. package/dist/extensions/awareness/tools.js +54 -0
  43. package/dist/extensions/knowledge/index.d.ts +8 -0
  44. package/dist/extensions/knowledge/index.js +35 -0
  45. package/dist/{core → extensions/knowledge}/knowledgeStore.d.ts +1 -0
  46. package/dist/extensions/knowledge/labels.d.ts +3 -0
  47. package/dist/extensions/knowledge/labels.js +119 -0
  48. package/dist/{core/knowledge.d.ts → extensions/knowledge/prompt.d.ts} +1 -1
  49. package/dist/{core/knowledge.js → extensions/knowledge/prompt.js} +7 -4
  50. package/dist/{core/tools/knowledgeTools.d.ts → extensions/knowledge/tools.d.ts} +1 -1
  51. package/dist/{core/tools/knowledgeTools.js → extensions/knowledge/tools.js} +2 -2
  52. package/dist/extensions/memory/index.d.ts +12 -0
  53. package/dist/extensions/memory/index.js +56 -0
  54. package/dist/extensions/memory/labels.d.ts +3 -0
  55. package/dist/extensions/memory/labels.js +58 -0
  56. package/dist/extensions/memory/memoryStore.d.ts +53 -0
  57. package/dist/extensions/memory/memoryStore.js +88 -0
  58. package/dist/extensions/memory/tools.d.ts +9 -0
  59. package/dist/extensions/memory/tools.js +82 -0
  60. package/dist/extensions/sources/index.d.ts +9 -0
  61. package/dist/extensions/sources/index.js +40 -0
  62. package/dist/extensions/sources/labels.d.ts +3 -0
  63. package/dist/extensions/sources/labels.js +82 -0
  64. package/dist/{core/tools/saveToSources.d.ts → extensions/sources/tools.d.ts} +1 -1
  65. package/dist/{core/tools/saveToSources.js → extensions/sources/tools.js} +5 -5
  66. package/dist/index.d.ts +14 -9
  67. package/dist/index.js +11 -7
  68. package/dist/tui/chatTui.d.ts +11 -20
  69. package/dist/tui/chatTui.js +75 -230
  70. package/dist/tui/extensionCommand.d.ts +21 -0
  71. package/dist/tui/extensionCommand.js +133 -0
  72. package/dist/tui/ink/runChatInk.d.ts +4 -3
  73. package/dist/tui/ink/runChatInk.js +101 -217
  74. package/extensions/awareness/.claude-plugin/plugin.json +10 -0
  75. package/{assets/agent-help → extensions/awareness/skills/help}/SKILL.md +4 -3
  76. package/{assets/knowledge-plugin → extensions/knowledge}/.claude-plugin/plugin.json +7 -1
  77. package/extensions/memory/.claude-plugin/plugin.json +10 -0
  78. package/extensions/memory/commands/forget.md +8 -0
  79. package/extensions/memory/commands/list.md +5 -0
  80. package/extensions/sources/.claude-plugin/plugin.json +10 -0
  81. package/package.json +3 -2
  82. package/dist/core/agentHelp.d.ts +0 -30
  83. package/dist/core/agentHelp.js +0 -55
  84. /package/dist/{core → extensions/knowledge}/fileKnowledgeStore.d.ts +0 -0
  85. /package/dist/{core → extensions/knowledge}/fileKnowledgeStore.js +0 -0
  86. /package/dist/{core → extensions/knowledge}/knowledgeStore.js +0 -0
  87. /package/dist/{core → extensions/sources}/extractText.d.ts +0 -0
  88. /package/dist/{core → extensions/sources}/extractText.js +0 -0
  89. /package/dist/{core → extensions/sources}/sources.d.ts +0 -0
  90. /package/dist/{core → extensions/sources}/sources.js +0 -0
  91. /package/{assets/knowledge-plugin → extensions/knowledge}/commands/ingest.md +0 -0
  92. /package/{assets/knowledge-plugin → extensions/knowledge}/commands/lint.md +0 -0
  93. /package/{assets/knowledge-plugin → extensions/knowledge}/commands/query.md +0 -0
  94. /package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-ingest/SKILL.md +0 -0
  95. /package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-lint/SKILL.md +0 -0
  96. /package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-query/SKILL.md +0 -0
@@ -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,21 @@
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]> [--project] [--path <subfolder>]`
12
+ * - `extension remove|enable|disable <name> [--project | --agent]`
13
+ *
14
+ * Without a scope, `add` installs into the agent's (the project's when the agent has no scope of
15
+ * its own); the others act where the extension is, the project's first.
16
+ */
17
+ export declare function runExtensionCommand(argv: readonly string[], options: {
18
+ dirs: ExtensionDirs;
19
+ command?: string;
20
+ write?: (line: string) => void;
21
+ }): Promise<boolean>;
@@ -0,0 +1,133 @@
1
+ import { stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { addExtension, listInstalled, removeExtension, setExtensionEnabled, } from "../core/externalExtensions.js";
4
+ import { describe } from "../chat/extensions.js";
5
+ export { chatExtensionsCommand, describe } from "../chat/extensions.js";
6
+ /**
7
+ * The `extension` command an agent exposes in its own binary (#37), e.g. `captain extension add
8
+ * ./jokebook --project`: installs, removes, enables, disables and lists the extensions in its two
9
+ * scopes. Returns `false` when `argv` isn't this command (so the agent goes on), and sets
10
+ * `process.exitCode` to 1 on an error.
11
+ *
12
+ * - `extension list`
13
+ * - `extension info <name>`: its metadata, what it offers and where its README is
14
+ * - `extension add <folder | git URL[#ref]> [--project] [--path <subfolder>]`
15
+ * - `extension remove|enable|disable <name> [--project | --agent]`
16
+ *
17
+ * Without a scope, `add` installs into the agent's (the project's when the agent has no scope of
18
+ * its own); the others act where the extension is, the project's first.
19
+ */
20
+ export async function runExtensionCommand(argv, options) {
21
+ if (argv[0] !== "extension")
22
+ return false;
23
+ const write = options.write ?? ((line) => console.log(line));
24
+ const command = options.command ?? "agent";
25
+ const [action, ...rest] = argv.slice(1);
26
+ const flag = (name) => rest.includes(`--${name}`);
27
+ const value = (name) => {
28
+ const index = rest.indexOf(`--${name}`);
29
+ return index >= 0 ? rest[index + 1] : undefined;
30
+ };
31
+ const positional = rest.filter((arg, index) => !arg.startsWith("--") && rest[index - 1] !== "--path");
32
+ const askedScope = flag("project") ? "project" : flag("agent") ? "agent" : undefined;
33
+ const fail = (message) => {
34
+ write(message);
35
+ process.exitCode = 1;
36
+ return true;
37
+ };
38
+ const scopeDir = (scope) => options.dirs[scope];
39
+ try {
40
+ switch (action) {
41
+ case "list": {
42
+ const installed = await listInstalled(options.dirs);
43
+ if (!installed.length) {
44
+ write(`No extensions installed. \`${command} extension add <folder | git URL>\` installs one.`);
45
+ return true;
46
+ }
47
+ for (const extension of installed)
48
+ write(describe(extension));
49
+ return true;
50
+ }
51
+ case "info": {
52
+ const name = positional[0];
53
+ if (!name)
54
+ return fail(`Usage: ${command} extension info <name>`);
55
+ const found = (await listInstalled(options.dirs)).find((extension) => extension.name === name && !extension.shadowed);
56
+ if (!found)
57
+ return fail(`${name} isn't installed. \`${command} extension list\` lists them.`);
58
+ for (const line of await info(found))
59
+ write(line);
60
+ return true;
61
+ }
62
+ case "add": {
63
+ const source = positional[0];
64
+ if (!source)
65
+ return fail(`Usage: ${command} extension add <folder | git URL[#ref]> [--project] [--path <subfolder>]`);
66
+ const scope = askedScope ?? (options.dirs.agent ? "agent" : "project");
67
+ const dir = scopeDir(scope);
68
+ if (!dir)
69
+ return fail(`This agent has no ${scope} scope for extensions.`);
70
+ const subdir = value("path");
71
+ const result = await addExtension(source, dir, subdir ? { subdir } : {});
72
+ write(`${result.replaced ? "Reinstalled" : "Installed"} ${result.name} in the ${scope} scope (${dir})${result.commit ? `, at ${result.commit.slice(0, 12)}` : ""}. It's enabled: it runs from the next session.`);
73
+ return true;
74
+ }
75
+ case "remove":
76
+ case "enable":
77
+ case "disable": {
78
+ const name = positional[0];
79
+ if (!name)
80
+ return fail(`Usage: ${command} extension ${action} <name> [--project | --agent]`);
81
+ const found = (await listInstalled(options.dirs)).filter((extension) => extension.name === name && (!askedScope || extension.scope === askedScope));
82
+ if (!found.length)
83
+ return fail(`${name} isn't installed${askedScope ? ` in the ${askedScope} scope` : ""}. \`${command} extension list\` lists them.`);
84
+ if (found.length > 1 && !askedScope)
85
+ return fail(`${name} is installed in both scopes: say which, with --project or --agent.`);
86
+ const { scope } = found[0];
87
+ const dir = scopeDir(scope);
88
+ if (action === "remove")
89
+ await removeExtension(dir, name);
90
+ else
91
+ await setExtensionEnabled(dir, name, action === "enable");
92
+ write(`${name} ${action === "remove" ? "removed" : `${action}d`} in the ${scope} scope. It applies from the next session (in the chat, \`/extensions\` reopens it).`);
93
+ return true;
94
+ }
95
+ default:
96
+ return fail([
97
+ `Usage: ${command} extension <action>`,
98
+ " list what's installed, in which scope, on or off",
99
+ " info <name> its metadata, what it offers, its README",
100
+ " add <folder | git URL[#ref]> [--project] [--path <subfolder>]",
101
+ " remove|enable|disable <name> [--project | --agent]",
102
+ ].join("\n"));
103
+ }
104
+ }
105
+ catch (error) {
106
+ return fail(error instanceof Error ? error.message : String(error));
107
+ }
108
+ }
109
+ /** Everything an installed extension's manifest says about it, for `extension info`. */
110
+ async function info(extension) {
111
+ const manifest = extension.manifest;
112
+ if (!manifest)
113
+ return [describe(extension), "Its manifest can't be read."];
114
+ const author = manifest.author ? [manifest.author.name, manifest.author.email && `<${manifest.author.email}>`, manifest.author.url && `(${manifest.author.url})`].filter(Boolean).join(" ") : undefined;
115
+ const readme = path.join(extension.dir, "README.md");
116
+ const field = (label, value) => (value ? [`${label.padEnd(13)}${value}`] : []);
117
+ return [
118
+ `${manifest.name}${manifest.version ? ` ${manifest.version}` : ""}: ${manifest.description}`,
119
+ ...field("Author", author),
120
+ ...field("License", manifest.license),
121
+ ...field("Homepage", manifest.homepage),
122
+ ...field("Repository", manifest.repository),
123
+ ...field("Keywords", manifest.keywords?.join(", ")),
124
+ ...field("Works with", manifest.kit && `agent-kit ${manifest.kit}`),
125
+ ...field("Provides", manifest.provides.join(", ") || undefined),
126
+ ...field("Requires", manifest.requires.join(", ") || undefined),
127
+ ...field("Servers", Object.keys(manifest.servers).join(", ") || undefined),
128
+ ...field("Only read", manifest.readOnlyTools.join(", ") || undefined),
129
+ ...field("Variables", Object.values(manifest.servers).flatMap((server) => Object.keys(server.env ?? {})).join(", ") || undefined),
130
+ ...field("Installed", `${extension.scope} scope, ${extension.lock.enabled ? "enabled" : "disabled"}, on ${extension.lock.installed}, from ${describe(extension).split(" from ")[1]}`),
131
+ ...field("README", (await stat(readme).catch(() => null))?.isFile() ? readme : undefined),
132
+ ];
133
+ }
@@ -1,8 +1,8 @@
1
1
  import type { Options } from "@anthropic-ai/claude-agent-sdk";
2
2
  import type { Mode } from "../../core/agentSpec.js";
3
- import { type ModeControl } from "../../core/session.js";
3
+ import type { ModeControl } from "../../core/session.js";
4
4
  import { type ChatTuiOptions } from "../chatTui.js";
5
- import { type SessionOpener } from "../runs.js";
5
+ import { type SessionOpener } from "../../chat/runs.js";
6
6
  import { type RenderApproval } from "./SessionView.js";
7
7
  import type { ResultFormatter, ToolDetail, ToolPhrase } from "./toolGroup.js";
8
8
  import { type HeaderInfo } from "./header.js";
@@ -74,12 +74,13 @@ export interface InkChatOptions extends ChatTuiOptions {
74
74
  */
75
75
  firstPromptSuggestion?: string;
76
76
  }
77
- /** The chat's own commands, never sent to the model (the agent-help skill lists them; a test keeps both in step). */
77
+ /** The chat's own commands, never sent to the model (the awareness extension's help skill lists them; a test keeps both in step). */
78
78
  export declare const DEFAULT_EXIT_COMMANDS: readonly string[];
79
79
  export declare const LOCAL_COMMANDS: {
80
80
  readonly copy: "/copy";
81
81
  readonly resume: "/resume";
82
82
  readonly plan: "/plan";
83
+ readonly extensions: "/extensions";
83
84
  };
84
85
  /** A choice asked in place of the prompt (the /resume list). */
85
86
  export interface ChatPicker {