@falkenslab/agent-kit 0.17.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 (111) 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 +41 -27
  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/fileScopeGate.d.ts +17 -2
  16. package/dist/core/hooks/fileScopeGate.js +45 -10
  17. package/dist/core/hooks/planGate.d.ts +2 -0
  18. package/dist/core/hooks/planGate.js +1 -10
  19. package/dist/core/hooks/stepGate.d.ts +1 -1
  20. package/dist/core/hooks/stepGate.js +3 -3
  21. package/dist/core/language.js +1 -1
  22. package/dist/core/messages/de.js +12 -33
  23. package/dist/core/messages/en.d.ts +13 -16
  24. package/dist/core/messages/en.js +12 -33
  25. package/dist/core/messages/es.js +12 -33
  26. package/dist/core/messages/fr.js +12 -33
  27. package/dist/core/messages/index.d.ts +6 -0
  28. package/dist/core/messages/index.js +12 -1
  29. package/dist/core/packaged.d.ts +10 -0
  30. package/dist/core/packaged.js +38 -0
  31. package/dist/core/pluginAgents.d.ts +20 -0
  32. package/dist/core/pluginAgents.js +76 -0
  33. package/dist/core/runner.js +16 -9
  34. package/dist/core/session.d.ts +42 -5
  35. package/dist/core/session.js +203 -99
  36. package/dist/core/sessionFacts.d.ts +80 -0
  37. package/dist/core/sessionFacts.js +48 -0
  38. package/dist/core/toolLabels.d.ts +24 -6
  39. package/dist/core/toolLabels.js +24 -39
  40. package/dist/core/tools/humanApproval.js +3 -3
  41. package/dist/extensions/awareness/index.d.ts +13 -0
  42. package/dist/extensions/awareness/index.js +39 -0
  43. package/dist/extensions/awareness/labels.d.ts +3 -0
  44. package/dist/extensions/awareness/labels.js +12 -0
  45. package/dist/extensions/awareness/tools.d.ts +5 -0
  46. package/dist/extensions/awareness/tools.js +54 -0
  47. package/dist/{core → extensions/knowledge}/fileKnowledgeStore.d.ts +1 -3
  48. package/dist/{core → extensions/knowledge}/fileKnowledgeStore.js +23 -29
  49. package/dist/extensions/knowledge/index.d.ts +8 -0
  50. package/dist/extensions/knowledge/index.js +35 -0
  51. package/dist/{core → extensions/knowledge}/knowledgeStore.d.ts +5 -9
  52. package/dist/{core → extensions/knowledge}/knowledgeStore.js +16 -2
  53. package/dist/extensions/knowledge/labels.d.ts +3 -0
  54. package/dist/extensions/knowledge/labels.js +119 -0
  55. package/dist/extensions/knowledge/prompt.d.ts +28 -0
  56. package/dist/extensions/knowledge/prompt.js +59 -0
  57. package/dist/{core/tools/knowledgeTools.d.ts → extensions/knowledge/tools.d.ts} +7 -7
  58. package/dist/{core/tools/knowledgeTools.js → extensions/knowledge/tools.js} +20 -55
  59. package/dist/extensions/memory/index.d.ts +12 -0
  60. package/dist/extensions/memory/index.js +56 -0
  61. package/dist/extensions/memory/labels.d.ts +3 -0
  62. package/dist/extensions/memory/labels.js +58 -0
  63. package/dist/extensions/memory/memoryStore.d.ts +53 -0
  64. package/dist/extensions/memory/memoryStore.js +88 -0
  65. package/dist/extensions/memory/tools.d.ts +9 -0
  66. package/dist/extensions/memory/tools.js +82 -0
  67. package/dist/extensions/sources/index.d.ts +9 -0
  68. package/dist/extensions/sources/index.js +40 -0
  69. package/dist/extensions/sources/labels.d.ts +3 -0
  70. package/dist/extensions/sources/labels.js +82 -0
  71. package/dist/{core → extensions/sources}/sources.d.ts +24 -31
  72. package/dist/{core → extensions/sources}/sources.js +45 -108
  73. package/dist/{core/tools/saveToSources.d.ts → extensions/sources/tools.d.ts} +7 -3
  74. package/dist/{core/tools/saveToSources.js → extensions/sources/tools.js} +22 -17
  75. package/dist/index.d.ts +14 -9
  76. package/dist/index.js +11 -7
  77. package/dist/tui/chatTui.d.ts +11 -20
  78. package/dist/tui/chatTui.js +75 -230
  79. package/dist/tui/extensionCommand.d.ts +21 -0
  80. package/dist/tui/extensionCommand.js +133 -0
  81. package/dist/tui/ink/runChatInk.d.ts +4 -3
  82. package/dist/tui/ink/runChatInk.js +101 -217
  83. package/extensions/awareness/.claude-plugin/plugin.json +10 -0
  84. package/{assets/agent-help → extensions/awareness/skills/help}/SKILL.md +4 -3
  85. package/{assets/knowledge-plugin → extensions/knowledge}/.claude-plugin/plugin.json +7 -1
  86. package/extensions/knowledge/commands/ingest.md +7 -0
  87. package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-ingest/SKILL.md +8 -3
  88. package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-lint/SKILL.md +12 -5
  89. package/extensions/memory/.claude-plugin/plugin.json +10 -0
  90. package/extensions/memory/commands/forget.md +8 -0
  91. package/extensions/memory/commands/list.md +5 -0
  92. package/extensions/sources/.claude-plugin/plugin.json +10 -0
  93. package/package.json +3 -2
  94. package/assets/knowledge-plugin/commands/ingest.md +0 -7
  95. package/assets/knowledge-plugin/commands/query.md +0 -5
  96. package/assets/knowledge-plugin-files/.claude-plugin/plugin.json +0 -4
  97. package/assets/knowledge-plugin-files/commands/ingest.md +0 -7
  98. package/assets/knowledge-plugin-files/commands/lint.md +0 -5
  99. package/assets/knowledge-plugin-files/skills/knowledge-ingest/SKILL.md +0 -46
  100. package/assets/knowledge-plugin-files/skills/knowledge-lint/SKILL.md +0 -26
  101. package/assets/knowledge-plugin-files/skills/knowledge-pages/SKILL.md +0 -137
  102. package/assets/knowledge-plugin-files/skills/knowledge-query/SKILL.md +0 -14
  103. package/dist/core/agentHelp.d.ts +0 -30
  104. package/dist/core/agentHelp.js +0 -55
  105. package/dist/core/knowledge.d.ts +0 -25
  106. package/dist/core/knowledge.js +0 -84
  107. /package/dist/{core → extensions/sources}/extractText.d.ts +0 -0
  108. /package/dist/{core → extensions/sources}/extractText.js +0 -0
  109. /package/{assets/knowledge-plugin → extensions/knowledge}/commands/lint.md +0 -0
  110. /package/{assets/knowledge-plugin-files → extensions/knowledge}/commands/query.md +0 -0
  111. /package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-query/SKILL.md +0 -0
@@ -0,0 +1,510 @@
1
+ import { createWriteStream } from "node:fs";
2
+ import path from "node:path";
3
+ import { getInteractionPort, setInteractionPort } from "../core/interaction.js";
4
+ import { getLanguage, switchLanguage, t } from "../core/messages/index.js";
5
+ import { LANGUAGE_NAMES } from "../core/language.js";
6
+ import { runQuery } from "../core/runner.js";
7
+ import { createRunFolder, listRuns, readConversation, readRunSession } from "../core/runs.js";
8
+ import { createInputQueue, togglePlanMode } from "../core/session.js";
9
+ import { hasOpenTodos, parseTodos, TODO_TOOL } from "../core/todos.js";
10
+ import { withToolLabels, withToolPhrases } from "../core/toolLabels.js";
11
+ import { createConsoleRenderer } from "../tui/consoleRenderer.js";
12
+ import { chatExtensionsCommand } from "./extensions.js";
13
+ import { setExtensionEnabled } from "../core/externalExtensions.js";
14
+ import { capHistory, loadHistory, saveHistory, slashCommandToken } from "./history.js";
15
+ import { firstRun, openSession, runFolderOf, runLabel } from "./runs.js";
16
+ const DEFAULT_EXIT_COMMANDS = ["/exit", "/quit"];
17
+ const DEFAULT_HISTORY_LIMIT = 100;
18
+ const DEFAULT_PROMPT_LABEL = "\n> ";
19
+ /**
20
+ * Opens the chat's session (the first run with a session opener, `--continue` picking the
21
+ * latest) and loads the history; `start()` then draws the run's conversation and sends the
22
+ * initial prompt, `send()` each line the person types.
23
+ */
24
+ export async function createChatController(options, settings = {}) {
25
+ const opener = typeof options === "function" ? options : null;
26
+ const runsDir = opener ? settings.runsDir : undefined;
27
+ if (opener && !runsDir)
28
+ throw new Error("A chat with a session opener needs `runsDir`.");
29
+ const exitCommands = new Set((settings.exitCommands ?? DEFAULT_EXIT_COMMANDS).map((command) => command.toLowerCase()));
30
+ const promptLabel = settings.promptLabel ?? DEFAULT_PROMPT_LABEL;
31
+ const historyLimit = settings.historyLimit ?? DEFAULT_HISTORY_LIMIT;
32
+ let historyEntries = settings.historyPath ? capHistory(await loadHistory(settings.historyPath), historyLimit) : [];
33
+ // The session in use: with runs, the run's folder, opened again on /resume or /extensions.
34
+ let current = opener && runsDir ? await openSession(opener, await firstRun(runsDir)) : { options: options, run: null };
35
+ const modeControl = () => current.modeControl ?? settings.modeControl;
36
+ const formatAction = withToolLabels(() => current.toolLabels ?? settings.toolLabels, settings.formatAction);
37
+ const toolPhrase = withToolPhrases(() => current.toolLabels ?? settings.toolLabels, settings.toolPhrase);
38
+ // State and listeners.
39
+ const stateListeners = new Set();
40
+ const eventListeners = new Set();
41
+ let nextId = 0;
42
+ let state = {
43
+ transcript: [],
44
+ busy: false,
45
+ turnStartedAt: null,
46
+ activity: null,
47
+ subagentActivity: null,
48
+ todos: null,
49
+ mode: null,
50
+ switchableModes: [],
51
+ turns: 0,
52
+ usage: null,
53
+ contextPercent: null,
54
+ suggestion: null,
55
+ commands: [],
56
+ commandDetails: [],
57
+ language: getLanguage(),
58
+ history: historyEntries.map((entry) => entry.text),
59
+ panel: null,
60
+ choice: null,
61
+ run: current.run,
62
+ extensions: current.extensions ?? null,
63
+ closed: false,
64
+ };
65
+ function update(changes) {
66
+ state = { ...state, ...changes };
67
+ for (const listener of stateListeners)
68
+ listener(state);
69
+ }
70
+ function emit(event) {
71
+ for (const listener of eventListeners)
72
+ listener(event);
73
+ }
74
+ function addEntry(entry) {
75
+ update({ transcript: [...state.transcript, entry] });
76
+ }
77
+ // The session log: the console's text, as the plain chat would print it.
78
+ let sessionLog = null;
79
+ function openLog() {
80
+ sessionLog?.end();
81
+ const file = current.run ? path.join(current.run.dir, "session.log") : settings.sessionLogPath;
82
+ sessionLog = file ? createWriteStream(file, { flags: "a" }) : null;
83
+ }
84
+ openLog();
85
+ // eslint-disable-next-line no-control-regex -- ESC starts the SGR sequences the renderer writes
86
+ const log = (text) => void sessionLog?.write(text.replace(/\x1b\[[0-9;]*m/g, ""));
87
+ const logRenderer = createConsoleRenderer({ formatAction, agentLabel: settings.agentLabel, output: () => { }, onWrite: log });
88
+ /** A notice: in the transcript, to the views, and in the session log. */
89
+ function notice(text, tone = "dim") {
90
+ logRenderer.writeLine(text);
91
+ addEntry({ id: nextId++, kind: "notice", tone, text });
92
+ emit({ type: "notice", tone, text });
93
+ }
94
+ /** What the agent does, into the transcript. */
95
+ function record(event) {
96
+ const last = state.transcript.at(-1);
97
+ switch (event.type) {
98
+ case "text":
99
+ if (last?.kind === "agent")
100
+ update({ transcript: [...state.transcript.slice(0, -1), { ...last, text: last.text + event.text }] });
101
+ else
102
+ addEntry({ id: nextId++, kind: "agent", text: event.text });
103
+ return;
104
+ case "action": {
105
+ const todos = event.toolName === TODO_TOOL ? parseTodos(event.input) : null;
106
+ if (todos) {
107
+ const doing = todos.find((todo) => todo.status === "in_progress");
108
+ update({ todos: hasOpenTodos(todos) ? todos : null, activity: doing?.activeForm ?? state.activity });
109
+ return;
110
+ }
111
+ const label = formatAction(event.toolName, event.input);
112
+ const call = { ...(event.toolUseId ? { id: event.toolUseId } : {}), toolName: event.toolName, label, result: null, children: [] };
113
+ const transcript = last?.kind === "tools" ? [...state.transcript.slice(0, -1), { ...last, calls: [...last.calls, call] }] : [...state.transcript, { id: nextId++, kind: "tools", calls: [call] }];
114
+ update({ transcript, activity: label, subagentActivity: null });
115
+ return;
116
+ }
117
+ case "tool-result":
118
+ case "subagent-action": {
119
+ if (event.type === "tool-result" && event.toolName === TODO_TOOL)
120
+ return;
121
+ const id = event.type === "tool-result" ? event.toolUseId : event.parentToolUseId;
122
+ const label = event.type === "subagent-action" ? formatAction(event.toolName, event.input) : null;
123
+ const transcript = state.transcript.map((entry) => entry.kind !== "tools" || !entry.calls.some((call) => call.id !== undefined && call.id === id)
124
+ ? entry
125
+ : {
126
+ ...entry,
127
+ calls: entry.calls.map((call) => call.id !== id ? call : event.type === "tool-result" ? { ...call, result: { isError: event.isError, text: event.text } } : { ...call, children: [...call.children, label] }),
128
+ });
129
+ update({ transcript, ...(label ? { subagentActivity: label } : {}) });
130
+ return;
131
+ }
132
+ case "mcp-error":
133
+ addEntry({ id: nextId++, kind: "notice", tone: "warn", text: t().mcpFailed(event.failedServers.join(", ")) });
134
+ return;
135
+ case "info":
136
+ addEntry({ id: nextId++, kind: "notice", tone: event.level === "warning" ? "warn" : "dim", text: `(${event.text})` });
137
+ return;
138
+ case "turn-end":
139
+ if (event.failed && event.errorText)
140
+ addEntry({ id: nextId++, kind: "notice", tone: "error", text: event.errorText });
141
+ update({ turns: state.turns + 1, usage: event.usage ?? state.usage });
142
+ return;
143
+ case "prompt-suggestion":
144
+ update({ suggestion: event.suggestion });
145
+ return;
146
+ }
147
+ }
148
+ // The agent run: one reader per session, never a for-await that could be broken out of
149
+ // (ADR-010); a reader left behind by a reopened session stops reporting.
150
+ let queue = createInputQueue();
151
+ let run;
152
+ let knownCommands;
153
+ let generation = 0;
154
+ let turnDone = null;
155
+ let readError = null;
156
+ let unsubscribeMode;
157
+ let interrupted = false;
158
+ function finishTurn() {
159
+ const done = turnDone;
160
+ turnDone = null;
161
+ done?.();
162
+ }
163
+ function connect() {
164
+ const own = ++generation;
165
+ queue = createInputQueue({ modeControl: modeControl() });
166
+ run = (settings.runQuery ?? runQuery)(queue.iterable, { ...current.options, promptSuggestions: settings.promptSuggestions ?? true });
167
+ const events = run.events[Symbol.asyncIterator]();
168
+ const control = modeControl();
169
+ update({ mode: control?.mode ?? null, switchableModes: control?.switchable ?? [], run: current.run, extensions: current.extensions ?? null });
170
+ unsubscribeMode?.();
171
+ unsubscribeMode = control?.subscribe?.((mode) => {
172
+ update({ mode });
173
+ emit({ type: "mode", mode });
174
+ });
175
+ const supported = run.supportedCommands().catch(() => null);
176
+ knownCommands = supported.then((commands) => (commands ? new Set(commands.flatMap((command) => [command.name, ...(command.aliases ?? [])])) : null));
177
+ void supported.then((commands) => {
178
+ if (own !== generation)
179
+ return;
180
+ const exits = [...exitCommands].filter((command) => command.startsWith("/")).map((command) => command.slice(1));
181
+ const viewCommands = Object.keys(settings.commands ?? {}).map((name) => ({ name, description: "", argumentHint: "" }));
182
+ const local = [
183
+ ...viewCommands,
184
+ ...(runsDir ? [{ name: "resume", description: t().commandResume, argumentHint: "" }] : []),
185
+ ...(control?.switchable.includes("plan") ? [{ name: "plan", description: t().commandPlan, argumentHint: "" }] : []),
186
+ { name: "extensions", description: t().commandExtensions, argumentHint: "[enable|disable <name>]" },
187
+ ];
188
+ const details = new Map();
189
+ for (const command of [...(commands ?? []).map(({ name, description, argumentHint }) => ({ name, description: description ?? "", argumentHint: argumentHint ?? "" })), ...local])
190
+ details.set(command.name, command);
191
+ for (const name of exits)
192
+ if (!details.has(name))
193
+ details.set(name, { name, description: "", argumentHint: "" });
194
+ const sorted = [...details.values()].sort((a, b) => a.name.localeCompare(b.name));
195
+ update({ commands: sorted.map((command) => command.name), commandDetails: sorted });
196
+ });
197
+ void (async () => {
198
+ try {
199
+ while (true) {
200
+ const { value, done } = await events.next();
201
+ if (own !== generation || done)
202
+ break;
203
+ if (value.type !== "prompt-suggestion")
204
+ logRenderer.render(value);
205
+ record(value);
206
+ emit({ type: "agent", event: value });
207
+ if (value.type === "turn-end")
208
+ finishTurn();
209
+ }
210
+ }
211
+ catch (error) {
212
+ if (own === generation)
213
+ readError = error;
214
+ }
215
+ if (own === generation)
216
+ finishTurn();
217
+ })();
218
+ }
219
+ // The agent's text in the latest turn (for /copy), kept when it ends.
220
+ let latestReply = "";
221
+ // A language switched to, told to the model with the next message (as a mode change is): the
222
+ // session's prompt says it already, but a conversation in another language pulls the replies.
223
+ let languageNotice = null;
224
+ async function runTurn(line) {
225
+ interrupted = false;
226
+ const from = state.transcript.length;
227
+ const startedAt = Date.now();
228
+ logRenderer.startTurn();
229
+ update({ busy: true, turnStartedAt: startedAt, activity: null, subagentActivity: null, suggestion: null });
230
+ emit({ type: "turn-start" });
231
+ const finished = new Promise((resolve) => (turnDone = resolve));
232
+ queue.push(languageNotice ? `${languageNotice}\n\n${line}` : line);
233
+ languageNotice = null;
234
+ await finished;
235
+ logRenderer.endLine();
236
+ latestReply = state.transcript.slice(from).flatMap((entry) => (entry.kind === "agent" ? [entry.text] : [])).join("").trim();
237
+ const seconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
238
+ addEntry({ id: nextId++, kind: "turn-summary", seconds });
239
+ update({ busy: false, turnStartedAt: null, activity: null, subagentActivity: null });
240
+ emit({ type: "turn-end", seconds });
241
+ void run.contextUsage().then((usage) => usage && update({ contextPercent: usage.percentage }));
242
+ if (readError)
243
+ throw readError;
244
+ }
245
+ /** Draws a run's earlier conversation, if it has one. */
246
+ async function showConversation(folder, fromResume) {
247
+ if (!folder.sessionId)
248
+ return;
249
+ const messages = await readConversation(folder.dir);
250
+ const [summary] = (await listRuns(path.dirname(folder.dir))).filter((other) => path.resolve(other.dir) === path.resolve(folder.dir));
251
+ const updatedAt = summary?.updatedAt ?? new Date();
252
+ const replayed = messages.map((message) => ({ id: nextId++, kind: message.role === "user" ? "user" : "agent", text: message.text }));
253
+ update({ transcript: fromResume ? replayed : [...state.transcript, ...replayed], turns: (fromResume ? 0 : state.turns) + messages.filter((message) => message.role === "user").length });
254
+ emit({ type: "conversation", messages, updatedAt, fromResume });
255
+ }
256
+ /** Switches to another session: a run resumed, or the same one opened again. */
257
+ async function switchTo(folder, fromResume) {
258
+ queue.end();
259
+ run.close();
260
+ current = await openSession(opener, folder);
261
+ if (fromResume)
262
+ openLog();
263
+ connect();
264
+ emit({ type: "session" });
265
+ if (fromResume)
266
+ await showConversation(current.run, true);
267
+ }
268
+ // A choice in place of the prompt, when the view doesn't ask it itself.
269
+ let choiceResolve = null;
270
+ function pick(title, options) {
271
+ if (settings.pick)
272
+ return settings.pick(title, options);
273
+ return new Promise((resolve) => {
274
+ choiceResolve = (value) => {
275
+ choiceResolve = null;
276
+ update({ choice: null });
277
+ resolve(value);
278
+ };
279
+ update({ choice: { title, options } });
280
+ });
281
+ }
282
+ // The controller's own interaction port (`panels: "state"`): a checkpoint is the state's panel.
283
+ const answers = new Map();
284
+ function ask(question, signal) {
285
+ const id = nextId++;
286
+ return new Promise((resolve) => {
287
+ const settle = (answer) => {
288
+ answers.delete(id);
289
+ if (state.panel?.id === id)
290
+ update({ panel: null });
291
+ resolve(answer);
292
+ };
293
+ answers.set(id, settle);
294
+ signal.addEventListener("abort", () => answers.has(id) && settle(""), { once: true });
295
+ update({ panel: { ...question, id } });
296
+ });
297
+ }
298
+ const statePort = {
299
+ askDecision: (prompt, signal) => ask({ kind: "decision", prompt }, signal),
300
+ askManualIntervention: (prompt, signal) => ask({ kind: "manual", prompt }, signal),
301
+ askText: (prompt, signal) => ask({ kind: "text", prompt }, signal),
302
+ askChoice: (prompt, choice, signal) => ask({ kind: "choice", prompt, choice }, signal),
303
+ notify: (message) => notice(message),
304
+ };
305
+ const previousPort = settings.panels === "state" ? getInteractionPort() : null;
306
+ if (settings.panels === "state")
307
+ setInteractionPort(statePort);
308
+ connect();
309
+ const controller = {
310
+ /** The state now. */
311
+ getState: () => state,
312
+ /** Calls `listener` with every new state; returns how to stop. */
313
+ subscribe(listener) {
314
+ stateListeners.add(listener);
315
+ return () => stateListeners.delete(listener);
316
+ },
317
+ /** Calls `listener` with every event, in order; returns how to stop. */
318
+ onEvent(listener) {
319
+ eventListeners.add(listener);
320
+ return () => eventListeners.delete(listener);
321
+ },
322
+ /** Tool call labels and phrases with the extensions' (for a view that renders events itself). */
323
+ formatAction,
324
+ toolPhrase,
325
+ /** The folder the session works in. */
326
+ cwd: () => current.options.cwd ?? process.cwd(),
327
+ /** Draws the run's earlier conversation, then sends the initial prompt (for a new run). */
328
+ async start() {
329
+ if (current.run)
330
+ await showConversation(current.run, false);
331
+ if (settings.initialPrompt && !current.run?.sessionId) {
332
+ // Logged but not shown: it's the agent's cue, not something the person typed.
333
+ log(`${promptLabel}${settings.initialPrompt}\n`);
334
+ await runTurn(settings.initialPrompt);
335
+ }
336
+ },
337
+ /**
338
+ * A line the person sent: an exit command ("exit"), one of the chat's commands, or a turn,
339
+ * resolved when it ends. An unknown slash command is caught before it reaches the model.
340
+ */
341
+ async send(raw) {
342
+ const line = raw.trim();
343
+ if (!line)
344
+ return;
345
+ log(`${promptLabel}${line}\n`);
346
+ addEntry({ id: nextId++, kind: "user", text: line });
347
+ emit({ type: "user", text: line });
348
+ if (settings.historyPath) {
349
+ historyEntries = capHistory([...historyEntries, { text: line, timestamp: new Date().toISOString() }], historyLimit);
350
+ update({ history: historyEntries.map((entry) => entry.text) });
351
+ await saveHistory(settings.historyPath, historyEntries);
352
+ }
353
+ const lower = line.toLowerCase();
354
+ if (exitCommands.has(lower))
355
+ return "exit";
356
+ const own = settings.commands?.[lower.slice(1)];
357
+ if (lower.startsWith("/") && own)
358
+ return void (await own());
359
+ if (runsDir && lower === "/resume")
360
+ return void (await controller.resume());
361
+ if (lower === "/plan")
362
+ return void controller.togglePlan();
363
+ const extensions = await chatExtensionsCommand(line, current.extensions, Boolean(opener && current.run));
364
+ if (extensions) {
365
+ for (const text of extensions.lines)
366
+ notice(text);
367
+ if (extensions.reopen)
368
+ await switchTo({ dir: current.run.dir, sessionId: await readRunSession(current.run.dir) }, false);
369
+ return;
370
+ }
371
+ const token = slashCommandToken(line);
372
+ if (token) {
373
+ const names = await knownCommands;
374
+ if (names && !names.has(token))
375
+ return notice(t().unknownCommand(token), "warn");
376
+ }
377
+ await runTurn(line);
378
+ },
379
+ /** Stops the turn running, if any. */
380
+ interrupt() {
381
+ if (!state.busy || interrupted)
382
+ return;
383
+ interrupted = true;
384
+ void run.interrupt();
385
+ notice(t().interrupted, "warn");
386
+ },
387
+ /** Switches to a mode the session allows; says so when it can't. */
388
+ setMode(mode) {
389
+ const control = modeControl();
390
+ if (!control || !control.switchable.includes(mode))
391
+ return notice(t().modeLocked(control?.mode ?? state.mode ?? undefined));
392
+ control.set(mode);
393
+ if (!control.subscribe) {
394
+ update({ mode });
395
+ emit({ type: "mode", mode });
396
+ }
397
+ },
398
+ /** The next mode the session allows (Shift+Tab). */
399
+ cycleMode() {
400
+ const control = modeControl();
401
+ if (!control || control.switchable.length < 2)
402
+ return notice(t().modeLocked(control?.mode ?? state.mode ?? undefined));
403
+ controller.setMode(control.switchable[(control.switchable.indexOf(control.mode) + 1) % control.switchable.length]);
404
+ },
405
+ /** Into plan mode, or back to the mode it was entered from (`/plan`). */
406
+ togglePlan() {
407
+ const control = modeControl();
408
+ const next = control ? togglePlanMode(control) : null;
409
+ if (!next)
410
+ return notice(t().modeLocked(control?.mode ?? state.mode ?? undefined));
411
+ if (!control?.subscribe) {
412
+ update({ mode: next });
413
+ emit({ type: "mode", mode: next });
414
+ }
415
+ },
416
+ /** The runs to resume, newest first, labelled for a person. */
417
+ async listRuns() {
418
+ if (!runsDir)
419
+ return [];
420
+ return (await listRuns(runsDir)).map((summary, index) => ({ label: `${index + 1}. ${runLabel(summary, current.run ?? undefined)}`, dir: summary.dir }));
421
+ },
422
+ /** `/resume`: with `dir`, switches to that run; without it, asks which. */
423
+ async resume(dir) {
424
+ if (!opener || !runsDir)
425
+ return;
426
+ const runs = await listRuns(runsDir);
427
+ if (runs.length === 0)
428
+ return notice(t().noEarlierRuns);
429
+ const chosen = dir ?? (await pick(t().resumeTitle, (await controller.listRuns()).map(({ label, dir: value }) => ({ label, value }))));
430
+ const summary = runs.find((other) => other.dir === chosen);
431
+ if (!summary || (current.run && path.resolve(summary.dir) === path.resolve(current.run.dir)))
432
+ return;
433
+ await switchTo(await runFolderOf(summary), true);
434
+ },
435
+ /**
436
+ * Starts a new conversation in a new run, leaving the current one to resume later: what a
437
+ * graphical view offers instead of quitting and starting again.
438
+ */
439
+ async newConversation() {
440
+ if (!opener || !runsDir)
441
+ return;
442
+ queue.end();
443
+ run.close();
444
+ current = await openSession(opener, await createRunFolder(runsDir));
445
+ openLog();
446
+ update({ transcript: [], turns: 0, usage: null, contextPercent: null, todos: null, suggestion: null });
447
+ connect();
448
+ emit({ type: "session" });
449
+ },
450
+ /** Enables or disables an installed extension, and opens the session again with the same conversation. */
451
+ async setExtension(name, enabled) {
452
+ // An action of its own, not a line the person typed: a graphical view's toggle.
453
+ if (!opener || !current.run)
454
+ return notice(t().extensionsCantReopen);
455
+ const installed = (current.extensions?.installed ?? []).find((extension) => extension.name === name && !extension.shadowed);
456
+ const dir = installed ? current.extensions?.dirs[installed.scope] : undefined;
457
+ if (!installed || !dir)
458
+ return notice(t().extensionNotInstalled(name));
459
+ await setExtensionEnabled(dir, name, enabled);
460
+ notice(t().extensionToggled(name, enabled));
461
+ await switchTo({ dir: current.run.dir, sessionId: await readRunSession(current.run.dir) }, false);
462
+ },
463
+ /**
464
+ * Switches the kit's language (`switchLanguage()`) and opens the session again in it, keeping
465
+ * the conversation: the agent's next reply, the labels and the kit's notices come in it.
466
+ * `note` adds the host's own words to what the model is told (the agent's name in that
467
+ * language: the earlier conversation keeps pulling the old one).
468
+ */
469
+ async setLanguage(language, { note } = {}) {
470
+ switchLanguage(language);
471
+ update({ language });
472
+ const name = LANGUAGE_NAMES[language];
473
+ languageNotice = `<system-reminder>The user switched the chat's language to ${name}. From now on, reply in ${name}, and keep everything you write in ${name}, whatever language earlier messages are in. The names your instructions give now (your own included) are the ones to use.${note ? ` ${note}` : ""}</system-reminder>`;
474
+ if (opener && current.run)
475
+ await switchTo({ dir: current.run.dir, sessionId: await readRunSession(current.run.dir) }, false);
476
+ },
477
+ /** What an active extension hands the host (its `api`), e.g. the sources' `addSource`; `undefined` when it isn't active. */
478
+ api(extension) {
479
+ return current.apis?.[extension];
480
+ },
481
+ /** Answers the state's panel (`panels: "state"`). */
482
+ answer(panelId, answer) {
483
+ answers.get(panelId)?.(answer);
484
+ },
485
+ /** Answers the state's choice (`null`: cancelled). */
486
+ choose(value) {
487
+ choiceResolve?.(value);
488
+ },
489
+ /** A notice from the view (a welcome): in the transcript and the session log. */
490
+ notice,
491
+ /** The agent's text in the latest turn that ended, plain (for `/copy`). */
492
+ lastReply: () => latestReply,
493
+ /** Ends the chat: the agent run, the session log, the controller's port. */
494
+ close() {
495
+ if (state.closed)
496
+ return;
497
+ queue.end();
498
+ run.close();
499
+ unsubscribeMode?.();
500
+ for (const settle of [...answers.values()])
501
+ settle("");
502
+ choiceResolve?.(null);
503
+ if (settings.panels === "state")
504
+ setInteractionPort(previousPort);
505
+ sessionLog?.end();
506
+ update({ closed: true, busy: false });
507
+ },
508
+ };
509
+ return controller;
510
+ }
@@ -0,0 +1,14 @@
1
+ import { type InstalledExtension } from "../core/externalExtensions.js";
2
+ import type { ExtensionsStatus } from "../core/session.js";
3
+ /** One line for an installed extension: name and version, scope, on or off, its author, where it came from. */
4
+ export declare function describe(extension: InstalledExtension): string;
5
+ /**
6
+ * `/extensions` in a chat (#37): with nothing after it, what the session runs with, what's off
7
+ * and why, and what's installed; with `enable <name>` or `disable <name>`, changes its lock and
8
+ * asks the chat to reopen the session (`reopen`), which keeps the conversation. `null` when
9
+ * `line` isn't this command.
10
+ */
11
+ export declare function chatExtensionsCommand(line: string, status: ExtensionsStatus | undefined, canReopen: boolean): Promise<{
12
+ lines: string[];
13
+ reopen: boolean;
14
+ } | null>;
@@ -0,0 +1,41 @@
1
+ import path from "node:path";
2
+ import { setExtensionEnabled } from "../core/externalExtensions.js";
3
+ import { t } from "../core/messages/index.js";
4
+ /** One line for an installed extension: name and version, scope, on or off, its author, where it came from. */
5
+ export function describe(extension) {
6
+ const state = extension.shadowed ? "not used (the project's wins)" : extension.lock.enabled ? "enabled" : "disabled";
7
+ const source = extension.lock.commit ? `${extension.lock.source} @ ${extension.lock.commit.slice(0, 12)}` : path.normalize(extension.lock.source);
8
+ const version = extension.manifest?.version ? ` ${extension.manifest.version}` : "";
9
+ const author = extension.manifest?.author ? ` by ${extension.manifest.author.name}` : "";
10
+ return `${extension.name}${version} [${extension.scope}] ${state}${author} from ${source}`;
11
+ }
12
+ /**
13
+ * `/extensions` in a chat (#37): with nothing after it, what the session runs with, what's off
14
+ * and why, and what's installed; with `enable <name>` or `disable <name>`, changes its lock and
15
+ * asks the chat to reopen the session (`reopen`), which keeps the conversation. `null` when
16
+ * `line` isn't this command.
17
+ */
18
+ export async function chatExtensionsCommand(line, status, canReopen) {
19
+ const [command, action, name, ...extra] = line.trim().split(/\s+/);
20
+ if (command?.toLowerCase() !== "/extensions")
21
+ return null;
22
+ if (!action) {
23
+ const lines = [status?.active.length ? t().extensionsRunning(status.active.join(", ")) : t().extensionsNone];
24
+ for (const { name: off, reason } of status?.inactive ?? [])
25
+ lines.push(t().extensionOff(off, reason));
26
+ if (status?.installed.length)
27
+ lines.push(t().extensionsInstalled, ...status.installed.map((extension) => ` ${describe(extension)}`));
28
+ return { lines, reopen: false };
29
+ }
30
+ const enable = action.toLowerCase() === "enable";
31
+ if ((!enable && action.toLowerCase() !== "disable") || !name || extra.length)
32
+ return { lines: [t().extensionsUsage], reopen: false };
33
+ if (!canReopen)
34
+ return { lines: [t().extensionsCantReopen], reopen: false };
35
+ const installed = (status?.installed ?? []).find((extension) => extension.name === name && !extension.shadowed);
36
+ const dir = installed ? status?.dirs[installed.scope] : undefined;
37
+ if (!installed || !dir)
38
+ return { lines: [t().extensionNotInstalled(name)], reopen: false };
39
+ await setExtensionEnabled(dir, name, enable);
40
+ return { lines: [t().extensionToggled(name, enable)], reopen: true };
41
+ }
@@ -0,0 +1,17 @@
1
+ /** One line the person sent, as the history file keeps it. */
2
+ export interface HistoryEntry {
3
+ text: string;
4
+ timestamp: string;
5
+ }
6
+ /** Keeps only the most recent `limit` entries (oldest-first order in, oldest-first out). */
7
+ export declare function capHistory(entries: readonly HistoryEntry[], limit: number): HistoryEntry[];
8
+ /** Oldest-first. Missing file reads as empty — nothing to load yet is the normal case. */
9
+ export declare function loadHistory(filePath: string): Promise<HistoryEntry[]>;
10
+ /** Overwrites the whole file (not appended) so a prior prune (oldest entries dropped) sticks. */
11
+ export declare function saveHistory(filePath: string, entries: readonly HistoryEntry[]): Promise<void>;
12
+ /**
13
+ * The command name a typed line would invoke (e.g. "chiste" for both "/chiste" and
14
+ * "/chiste con salsa"), or `null` for a line that isn't a slash command at all (doesn't
15
+ * start with "/") or is just a bare "/" with nothing after it.
16
+ */
17
+ export declare function slashCommandToken(line: string): string | null;
@@ -0,0 +1,39 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /** Keeps only the most recent `limit` entries (oldest-first order in, oldest-first out). */
4
+ export function capHistory(entries, limit) {
5
+ return entries.length > limit ? entries.slice(-limit) : entries.slice();
6
+ }
7
+ /** Oldest-first. Missing file reads as empty — nothing to load yet is the normal case. */
8
+ export async function loadHistory(filePath) {
9
+ let raw;
10
+ try {
11
+ raw = await readFile(filePath, "utf8");
12
+ }
13
+ catch (error) {
14
+ if (error.code === "ENOENT")
15
+ return [];
16
+ throw error;
17
+ }
18
+ return raw
19
+ .split("\n")
20
+ .filter((line) => line.trim().length > 0)
21
+ .map((line) => JSON.parse(line));
22
+ }
23
+ /** Overwrites the whole file (not appended) so a prior prune (oldest entries dropped) sticks. */
24
+ export async function saveHistory(filePath, entries) {
25
+ await mkdir(path.dirname(filePath), { recursive: true });
26
+ const body = entries.map((entry) => JSON.stringify(entry)).join("\n");
27
+ await writeFile(filePath, entries.length > 0 ? `${body}\n` : "", "utf8");
28
+ }
29
+ /**
30
+ * The command name a typed line would invoke (e.g. "chiste" for both "/chiste" and
31
+ * "/chiste con salsa"), or `null` for a line that isn't a slash command at all (doesn't
32
+ * start with "/") or is just a bare "/" with nothing after it.
33
+ */
34
+ export function slashCommandToken(line) {
35
+ if (!line.startsWith("/"))
36
+ return null;
37
+ const token = line.slice(1).split(/\s/, 1)[0];
38
+ return token ? token : null;
39
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Options } from "@anthropic-ai/claude-agent-sdk";
2
- import type { ModeControl } from "../core/session.js";
2
+ import type { ExtensionsStatus, ModeControl } from "../core/session.js";
3
+ import type { ToolLabels } from "../core/toolLabels.js";
3
4
  import { type RunFolder, type RunSummary } from "../core/runs.js";
4
5
  /**
5
6
  * Builds the session's options for a run folder, e.g.
@@ -10,11 +11,17 @@ import { type RunFolder, type RunSummary } from "../core/runs.js";
10
11
  export type SessionOpener = (run: RunFolder) => Promise<Options | {
11
12
  options: Options;
12
13
  modeControl?: ModeControl;
14
+ toolLabels?: ToolLabels;
15
+ extensions?: ExtensionsStatus;
16
+ apis?: Record<string, Record<string, unknown>>;
13
17
  }>;
14
18
  export interface OpenedSession {
15
19
  run: RunFolder;
16
20
  options: Options;
17
21
  modeControl?: ModeControl;
22
+ toolLabels?: ToolLabels;
23
+ extensions?: ExtensionsStatus;
24
+ apis?: Record<string, Record<string, unknown>>;
18
25
  }
19
26
  /** The run a chat starts with: the latest one with `--continue` (if any), a new one otherwise. */
20
27
  export declare function firstRun(runsDir: string, argv?: readonly string[]): Promise<RunFolder>;
@@ -13,7 +13,7 @@ export async function firstRun(runsDir, argv = process.argv.slice(2)) {
13
13
  export async function openSession(opener, run) {
14
14
  const opened = await opener(run);
15
15
  // The SDK's Options has no "options" field: this is buildSessionOptions()'s result.
16
- return "options" in opened ? { run, options: opened.options, modeControl: opened.modeControl } : { run, options: opened };
16
+ return "options" in opened ? { run, options: opened.options, modeControl: opened.modeControl, toolLabels: opened.toolLabels, extensions: opened.extensions, apis: opened.apis } : { run, options: opened };
17
17
  }
18
18
  /** A run's date for a person, in the kit's language. */
19
19
  export function runDate(date) {