@falkenslab/agent-kit 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/assets/knowledge-plugin/.claude-plugin/plugin.json +4 -0
  4. package/assets/knowledge-plugin/commands/ingest.md +7 -0
  5. package/assets/knowledge-plugin/commands/lint.md +5 -0
  6. package/assets/knowledge-plugin/commands/query.md +5 -0
  7. package/assets/knowledge-plugin/skills/knowledge-ingest/SKILL.md +42 -0
  8. package/assets/knowledge-plugin/skills/knowledge-lint/SKILL.md +26 -0
  9. package/assets/knowledge-plugin/skills/knowledge-pages/SKILL.md +137 -0
  10. package/assets/knowledge-plugin/skills/knowledge-query/SKILL.md +14 -0
  11. package/dist/core/agentSpec.d.ts +132 -0
  12. package/dist/core/agentSpec.js +1 -0
  13. package/dist/core/claudeAuth.d.ts +24 -0
  14. package/dist/core/claudeAuth.js +27 -0
  15. package/dist/core/hooks/fileScopeGate.d.ts +33 -0
  16. package/dist/core/hooks/fileScopeGate.js +68 -0
  17. package/dist/core/hooks/humanInput.d.ts +15 -0
  18. package/dist/core/hooks/humanInput.js +52 -0
  19. package/dist/core/hooks/stepGate.d.ts +11 -0
  20. package/dist/core/hooks/stepGate.js +47 -0
  21. package/dist/core/hooks/subagentBashGate.d.ts +28 -0
  22. package/dist/core/hooks/subagentBashGate.js +40 -0
  23. package/dist/core/hooks/subagentForegroundGate.d.ts +18 -0
  24. package/dist/core/hooks/subagentForegroundGate.js +34 -0
  25. package/dist/core/hooks/subagentTypeGate.d.ts +16 -0
  26. package/dist/core/hooks/subagentTypeGate.js +33 -0
  27. package/dist/core/hooks/transcriptLogger.d.ts +14 -0
  28. package/dist/core/hooks/transcriptLogger.js +66 -0
  29. package/dist/core/interaction.d.ts +29 -0
  30. package/dist/core/interaction.js +7 -0
  31. package/dist/core/knowledge.d.ts +15 -0
  32. package/dist/core/knowledge.js +54 -0
  33. package/dist/core/mcpPermissions.d.ts +23 -0
  34. package/dist/core/mcpPermissions.js +47 -0
  35. package/dist/core/promptTemplate.d.ts +6 -0
  36. package/dist/core/promptTemplate.js +20 -0
  37. package/dist/core/runner.d.ts +137 -0
  38. package/dist/core/runner.js +115 -0
  39. package/dist/core/session.d.ts +63 -0
  40. package/dist/core/session.js +222 -0
  41. package/dist/core/toolLabels.d.ts +23 -0
  42. package/dist/core/toolLabels.js +114 -0
  43. package/dist/core/tools/humanApproval.d.ts +14 -0
  44. package/dist/core/tools/humanApproval.js +37 -0
  45. package/dist/core/tools/manualLogin.d.ts +16 -0
  46. package/dist/core/tools/manualLogin.js +27 -0
  47. package/dist/core/tools/saveToSources.d.ts +10 -0
  48. package/dist/core/tools/saveToSources.js +105 -0
  49. package/dist/index.d.ts +31 -0
  50. package/dist/index.js +32 -0
  51. package/dist/tui/chatTui.d.ts +105 -0
  52. package/dist/tui/chatTui.js +265 -0
  53. package/dist/tui/claudeAuth.d.ts +19 -0
  54. package/dist/tui/claudeAuth.js +96 -0
  55. package/dist/tui/consoleRenderer.d.ts +32 -0
  56. package/dist/tui/consoleRenderer.js +72 -0
  57. package/dist/tui/ink/PromptInput.d.ts +40 -0
  58. package/dist/tui/ink/PromptInput.js +333 -0
  59. package/dist/tui/ink/SessionView.d.ts +42 -0
  60. package/dist/tui/ink/SessionView.js +223 -0
  61. package/dist/tui/ink/fileMentions.d.ts +13 -0
  62. package/dist/tui/ink/fileMentions.js +50 -0
  63. package/dist/tui/ink/fullscreen.d.ts +79 -0
  64. package/dist/tui/ink/fullscreen.js +175 -0
  65. package/dist/tui/ink/header.d.ts +17 -0
  66. package/dist/tui/ink/header.js +24 -0
  67. package/dist/tui/ink/inkInteraction.d.ts +22 -0
  68. package/dist/tui/ink/inkInteraction.js +64 -0
  69. package/dist/tui/ink/lineBuffer.d.ts +28 -0
  70. package/dist/tui/ink/lineBuffer.js +87 -0
  71. package/dist/tui/ink/markdown.d.ts +12 -0
  72. package/dist/tui/ink/markdown.js +148 -0
  73. package/dist/tui/ink/progressView.d.ts +27 -0
  74. package/dist/tui/ink/progressView.js +65 -0
  75. package/dist/tui/ink/promptEditing.d.ts +54 -0
  76. package/dist/tui/ink/promptEditing.js +123 -0
  77. package/dist/tui/ink/runChatInk.d.ts +103 -0
  78. package/dist/tui/ink/runChatInk.js +298 -0
  79. package/dist/tui/ink/sessionModel.d.ts +85 -0
  80. package/dist/tui/ink/sessionModel.js +262 -0
  81. package/dist/tui/ink/terminalCursor.d.ts +32 -0
  82. package/dist/tui/ink/terminalCursor.js +68 -0
  83. package/dist/tui/ink/terminalStatus.d.ts +20 -0
  84. package/dist/tui/ink/terminalStatus.js +55 -0
  85. package/dist/tui/ink/toolGroup.d.ts +36 -0
  86. package/dist/tui/ink/toolGroup.js +77 -0
  87. package/dist/tui/ink/wizard.d.ts +62 -0
  88. package/dist/tui/ink/wizard.js +167 -0
  89. package/dist/tui/promptErrors.d.ts +2 -0
  90. package/dist/tui/promptErrors.js +4 -0
  91. package/dist/tui/terminalInteraction.d.ts +18 -0
  92. package/dist/tui/terminalInteraction.js +85 -0
  93. package/dist/tui/ui.d.ts +21 -0
  94. package/dist/tui/ui.js +25 -0
  95. package/package.json +75 -0
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Builds a `loadPrompt(relativePath, vars)` bound to `rootDir` — so each agent built on this kit supplies its own
3
+ * prompts directory (its own domain content) and gets the same `{{key}}`
4
+ * substitution/fail-loudly behavior for free.
5
+ */
6
+ export declare function createPromptLoader(rootDir: string): (relativePath: string, vars?: Record<string, string>) => string;
@@ -0,0 +1,20 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Builds a `loadPrompt(relativePath, vars)` bound to `rootDir` — so each agent built on this kit supplies its own
5
+ * prompts directory (its own domain content) and gets the same `{{key}}`
6
+ * substitution/fail-loudly behavior for free.
7
+ */
8
+ export function createPromptLoader(rootDir) {
9
+ return (relativePath, vars = {}) => {
10
+ const raw = readFileSync(path.join(rootDir, relativePath), "utf-8");
11
+ return raw
12
+ .replace(/\{\{(\w+)\}\}/g, (_match, key) => {
13
+ if (!(key in vars)) {
14
+ throw new Error(`Prompt "${relativePath}": missing variable "${key}"`);
15
+ }
16
+ return vars[key];
17
+ })
18
+ .trimEnd();
19
+ };
20
+ }
@@ -0,0 +1,137 @@
1
+ import { type Options, type SDKUserMessage, type SlashCommand } from "@anthropic-ai/claude-agent-sdk";
2
+ /**
3
+ * A transport-agnostic view of one turn's worth of output from `query()` — the same
4
+ * normalized shape whether the caller is going to print it to a console, forward it to a
5
+ * chat REPL, or serialize it over Electron IPC (a real consuming agent had four
6
+ * separate entry points — CLI run, CLI chat, an exploration mode and a desktop chat — that
7
+ * all parsed the SDK's raw message stream themselves, nearly identically).
8
+ *
9
+ * Deliberately thin: no formatting (no "[agent]"/"[action]" prefixes, no friendly tool
10
+ * labels — those are domain/presentation concerns the caller owns), and no filtering
11
+ * beyond what's always correct regardless of caller (see `action` below).
12
+ */
13
+ export type AgentEvent =
14
+ /** One streamed text token from the agent's own reply. No separate "block started"
15
+ * event: a caller that wants a prefix before a new run of text (e.g. "\n[agent] ") can
16
+ * track "was the previous event also `text`?" itself — simpler than mirroring the SDK's
17
+ * own content-block boundaries, and equivalent in practice. */
18
+ {
19
+ type: "text";
20
+ text: string;
21
+ }
22
+ /** A tool call the main agent itself made — never from inside a subagent's own private
23
+ * turn (an `assistant` message with a non-null `parent_tool_use_id`, e.g. a subagent
24
+ * spawned via the `Agent` tool): those are already reported back to the `Agent` tool
25
+ * call that started them, so surfacing their *internal* tool use here would make it
26
+ * look like a subagent had taken over the session, not just handed a result back to the
27
+ * main agent. This filtering is the one piece of interpretation this module always
28
+ * applies, since it's correct for every caller, not a presentation choice. */
29
+ | {
30
+ type: "action";
31
+ toolName: string;
32
+ input: unknown;
33
+ toolUseId?: string;
34
+ }
35
+ /** A tool call made inside a subagent's own turn (see `action` above for why those are
36
+ * kept apart). Only for showing that a subagent is busy: a console log ignores it. */
37
+ | {
38
+ type: "subagent-action";
39
+ toolName: string;
40
+ input: unknown;
41
+ toolUseId?: string;
42
+ parentToolUseId?: string;
43
+ }
44
+ /** What one of the main agent's tool calls returned, paired with its `action` by
45
+ * `toolUseId`, as plain text (images and other non-text parts left out). Subagents'
46
+ * results stay out, as their actions do. A console log ignores it. */
47
+ | {
48
+ type: "tool-result";
49
+ toolUseId: string;
50
+ toolName: string;
51
+ isError: boolean;
52
+ text: string;
53
+ }
54
+ /** One or more MCP servers failed to connect at session startup. */
55
+ | {
56
+ type: "mcp-error";
57
+ failedServers: string[];
58
+ }
59
+ /** A turn (one full `query()` response cycle) has finished. `failed` is the real
60
+ * failure signal — the SDK can report `status: "success"` even when the turn actually
61
+ * failed on an API error (billing/access denied, etc.), with the real message landing
62
+ * in `resultText` instead of `errorText` in that specific case (confirmed empirically:
63
+ * an org-level access error came back as subtype "success", is_error: true, with the
64
+ * real message in `result`) — check `failed`, not `status === "success"`. */
65
+ | {
66
+ type: "turn-end";
67
+ status: string;
68
+ failed: boolean;
69
+ resultText: string | null;
70
+ errorText: string;
71
+ usage?: SessionUsage;
72
+ }
73
+ /** Out-of-band text from the CLI loop itself, not from the model: local-command output
74
+ * (e.g. built-in `/usage`) or an informational banner (hook feedback, an unrecognized
75
+ * `/slash-command` notice, ...). Without this, those `system` messages fell through
76
+ * `generateEvents()` unhandled — confirmed empirically: typing an unrecognized/
77
+ * unnamespaced plugin slash command produced total silence (no text, no error, nothing),
78
+ * because the SDK's own response to it arrives as exactly this message shape and this
79
+ * module simply dropped it. */
80
+ | {
81
+ type: "info";
82
+ text: string;
83
+ level: "info" | "notice" | "suggestion" | "warning" | "local-command";
84
+ }
85
+ /** The predicted next prompt, when `Options.promptSuggestions` is on. It arrives after
86
+ * that turn's `turn-end`, so a reader that stops at `turn-end` sees it first thing in the
87
+ * next turn; a console log ignores it. */
88
+ | {
89
+ type: "prompt-suggestion";
90
+ suggestion: string;
91
+ };
92
+ /**
93
+ * Running totals for the whole `query()` session so far, not for one turn: the SDK's own
94
+ * `total_cost_usd` and `modelUsage` are cumulative across turns in a streaming-input
95
+ * session, so the latest `turn-end` carries the session total (never sum them).
96
+ */
97
+ export interface SessionUsage {
98
+ /** Input tokens, cache reads and writes included, across every model the session used. */
99
+ inputTokens: number;
100
+ outputTokens: number;
101
+ /** An estimate, not a billing statement. */
102
+ costUsd: number;
103
+ }
104
+ /**
105
+ * A turn's errors without the CLI's internal diagnostics: an interrupted turn ends with
106
+ * one like "[ede_diagnostic] result_type=user ... stop_reason=tool_use" (seen on Esc),
107
+ * which says nothing to a person.
108
+ */
109
+ export declare function visibleErrors(errors: readonly string[]): string[];
110
+ /** A tool result's content as plain text: its text parts joined, anything else skipped. */
111
+ export declare function toolResultText(content: unknown): string;
112
+ export interface ContextUsage {
113
+ /** 0-100. */
114
+ percentage: number;
115
+ totalTokens: number;
116
+ maxTokens: number;
117
+ }
118
+ export interface AgentRun {
119
+ /** Normalized events for this run — iterate with `for await`. */
120
+ events: AsyncIterable<AgentEvent>;
121
+ /** Interrupts the current turn (doesn't end the session — a caller wanting a full stop should also call `close()`). */
122
+ interrupt: () => Promise<unknown>;
123
+ /** Ends the session. No further events arrive after this; safe to call even if `events` is still being iterated elsewhere (e.g. from a SIGINT handler racing the main loop). */
124
+ close: () => void;
125
+ /** This session's own slash commands (skills doubling as typable commands, etc.) — only meaningful once the session has actually started (see the SDK's own `Query.supportedCommands()`). */
126
+ supportedCommands: () => Promise<SlashCommand[]>;
127
+ /** How full the context window is now, or null if the session can't tell (see the SDK's `Query.getContextUsage()`). */
128
+ contextUsage: () => Promise<ContextUsage | null>;
129
+ }
130
+ /**
131
+ * Thin wrapper around the SDK's own `query()`: same two-argument shape (a plain string
132
+ * for a one-shot run, or an `AsyncIterable<SDKUserMessage>` — see `createInputQueue()` —
133
+ * for a multi-turn chat), translating its raw message stream into `AgentEvent`s as
134
+ * described above. Every other control surface of the underlying `Query` (`interrupt`,
135
+ * `close`, `supportedCommands`) is passed through unchanged.
136
+ */
137
+ export declare function runQuery(prompt: string | AsyncIterable<SDKUserMessage>, options: Options): AgentRun;
@@ -0,0 +1,115 @@
1
+ import { query } from "@anthropic-ai/claude-agent-sdk";
2
+ /**
3
+ * A turn's errors without the CLI's internal diagnostics: an interrupted turn ends with
4
+ * one like "[ede_diagnostic] result_type=user ... stop_reason=tool_use" (seen on Esc),
5
+ * which says nothing to a person.
6
+ */
7
+ export function visibleErrors(errors) {
8
+ return errors.filter((error) => !error.startsWith("[ede_diagnostic]"));
9
+ }
10
+ /** A tool result's content as plain text: its text parts joined, anything else skipped. */
11
+ export function toolResultText(content) {
12
+ if (typeof content === "string")
13
+ return content;
14
+ if (!Array.isArray(content))
15
+ return "";
16
+ return content
17
+ .filter((part) => typeof part === "object" && part !== null && part.type === "text")
18
+ .map((part) => part.text)
19
+ .join("\n");
20
+ }
21
+ /**
22
+ * Thin wrapper around the SDK's own `query()`: same two-argument shape (a plain string
23
+ * for a one-shot run, or an `AsyncIterable<SDKUserMessage>` — see `createInputQueue()` —
24
+ * for a multi-turn chat), translating its raw message stream into `AgentEvent`s as
25
+ * described above. Every other control surface of the underlying `Query` (`interrupt`,
26
+ * `close`, `supportedCommands`) is passed through unchanged.
27
+ */
28
+ export function runQuery(prompt, options) {
29
+ const result = query({ prompt, options });
30
+ // The main agent's tool calls by id, to name the results that come back for them.
31
+ const toolNames = new Map();
32
+ async function* generateEvents() {
33
+ for await (const message of result) {
34
+ if (message.type === "system" && message.subtype === "init") {
35
+ const failedServers = message.mcp_servers.filter((s) => s.status === "failed").map((s) => s.name);
36
+ if (failedServers.length > 0)
37
+ yield { type: "mcp-error", failedServers };
38
+ continue;
39
+ }
40
+ if (message.type === "system" && message.subtype === "informational") {
41
+ yield { type: "info", text: message.content, level: message.level };
42
+ continue;
43
+ }
44
+ if (message.type === "system" && message.subtype === "local_command_output") {
45
+ yield { type: "info", text: message.content, level: "local-command" };
46
+ continue;
47
+ }
48
+ if (message.type === "stream_event") {
49
+ const event = message.event;
50
+ if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
51
+ yield { type: "text", text: event.delta.text };
52
+ }
53
+ continue;
54
+ }
55
+ if (message.type === "assistant") {
56
+ const type = message.parent_tool_use_id === null ? "action" : "subagent-action";
57
+ for (const block of message.message.content) {
58
+ if (block.type === "tool_use") {
59
+ if (type === "action")
60
+ toolNames.set(block.id, block.name);
61
+ yield {
62
+ type,
63
+ toolName: block.name,
64
+ input: block.input,
65
+ toolUseId: block.id,
66
+ ...(message.parent_tool_use_id === null ? {} : { parentToolUseId: message.parent_tool_use_id }),
67
+ };
68
+ }
69
+ }
70
+ continue;
71
+ }
72
+ if (message.type === "user" && message.parent_tool_use_id === null && Array.isArray(message.message.content)) {
73
+ for (const block of message.message.content) {
74
+ if (block.type !== "tool_result")
75
+ continue;
76
+ const toolName = toolNames.get(block.tool_use_id);
77
+ if (!toolName)
78
+ continue;
79
+ yield { type: "tool-result", toolUseId: block.tool_use_id, toolName, isError: block.is_error === true, text: toolResultText(block.content) };
80
+ }
81
+ }
82
+ if (message.type === "prompt_suggestion") {
83
+ yield { type: "prompt-suggestion", suggestion: message.suggestion };
84
+ continue;
85
+ }
86
+ if (message.type === "result") {
87
+ const failed = message.is_error;
88
+ const resultText = message.subtype === "success" ? message.result : null;
89
+ const errorText = message.subtype === "success" ? message.result : visibleErrors(message.errors).join("; ");
90
+ const models = Object.values(message.modelUsage ?? {});
91
+ const usage = {
92
+ inputTokens: models.reduce((sum, m) => sum + m.inputTokens + m.cacheReadInputTokens + m.cacheCreationInputTokens, 0),
93
+ outputTokens: models.reduce((sum, m) => sum + m.outputTokens, 0),
94
+ costUsd: message.total_cost_usd,
95
+ };
96
+ yield { type: "turn-end", status: message.subtype, failed, resultText, errorText, usage };
97
+ }
98
+ }
99
+ }
100
+ return {
101
+ events: generateEvents(),
102
+ interrupt: () => result.interrupt(),
103
+ close: () => result.close(),
104
+ supportedCommands: () => result.supportedCommands(),
105
+ contextUsage: async () => {
106
+ try {
107
+ const usage = await result.getContextUsage();
108
+ return { percentage: usage.percentage, totalTokens: usage.totalTokens, maxTokens: usage.maxTokens };
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,63 @@
1
+ import type { Options, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
2
+ import { type TranscriptLogger } from "./hooks/transcriptLogger.js";
3
+ import type { AgentSpec, BaseSessionConfig, Mode } from "./agentSpec.js";
4
+ /**
5
+ * Builds the `options` object passed to the Agent SDK's `query()` — everything about
6
+ * *how* the agent runs a session (system prompt, tools, MCP servers, hooks), independent
7
+ * of *what* is said to start it off (that's the caller's `prompt`, a plain string for a
8
+ * one-shot run or an `AsyncIterable` for a multi-turn chat).
9
+ *
10
+ * Everything actually *about the domain* (which system prompt, which MCP servers besides
11
+ * the generic human-in-the-loop/knowledge ones below, which plugin roots, which
12
+ * subagents) is behind `spec` (see agentSpec.ts) — this function only knows the generic
13
+ * fields on `BaseSessionConfig`.
14
+ *
15
+ * The per-mode behavioral differences (whether the approval/manual-intervention tools
16
+ * exist, whether the interactive step gate hook runs) all fall out of `config.mode`
17
+ * alone.
18
+ */
19
+ export declare function buildSessionOptions<TConfig extends BaseSessionConfig>(config: TConfig, runDir: string, spec: AgentSpec<TConfig>, options?: {
20
+ autoCompactEnabled?: boolean;
21
+ }): Promise<{
22
+ options: Options;
23
+ transcriptLogger: TranscriptLogger;
24
+ transcriptPath: string;
25
+ modeControl: ModeControl;
26
+ }>;
27
+ /**
28
+ * The supervision mode of a running session. Only "guided" and "interactive" can switch into
29
+ * each other: they share the same tools (the approval tool) and the step gate is registered
30
+ * in both, asking only in "interactive". "autonomous" has no approval tool at all, and a tool
31
+ * can't appear or vanish mid-session, so a session that starts autonomous stays autonomous,
32
+ * and one that doesn't can't become autonomous. The system prompt keeps the mode it was
33
+ * built with.
34
+ */
35
+ export interface ModeControl {
36
+ readonly mode: Mode;
37
+ /** The modes this session can be in; fewer than two means it can't switch. */
38
+ readonly switchable: readonly Mode[];
39
+ /** Switches to `next` if this session allows it; returns whether it did. */
40
+ set(next: Mode): boolean;
41
+ }
42
+ export declare function createModeControl(initial: Mode): ModeControl;
43
+ /**
44
+ * User message queue backed by a single long-lived generator, for any multi-turn
45
+ * (chat-shaped) caller of `query()`.
46
+ *
47
+ * The SDK closes the transport as soon as the AsyncIterable passed as `prompt` (or to
48
+ * `streamInput()`) finishes iterating — so a "one message and done" generator leaves the
49
+ * transport closed right after sending that message, and the next call blows up with
50
+ * "ProcessTransport is not ready for writing". A real multi-turn conversation needs a
51
+ * single generator that never finishes on its own: messages get pushed into it with
52
+ * `push()` and it's only explicitly closed with `end()` when leaving the chat.
53
+ */
54
+ export declare function createInputQueue(): {
55
+ push: (text: string) => void;
56
+ end: () => void;
57
+ iterable: AsyncIterable<SDKUserMessage>;
58
+ };
59
+ /** Externally-controllable promise — used to know when a chat turn has finished. */
60
+ export declare function createDeferred(): {
61
+ promise: Promise<void>;
62
+ resolve: () => void;
63
+ };
@@ -0,0 +1,222 @@
1
+ import path from "node:path";
2
+ import { createTranscriptLogger } from "./hooks/transcriptLogger.js";
3
+ import { createStepGate } from "./hooks/stepGate.js";
4
+ import { createSubagentBashGate } from "./hooks/subagentBashGate.js";
5
+ import { createSubagentTypeGate } from "./hooks/subagentTypeGate.js";
6
+ import { createSubagentForegroundGate } from "./hooks/subagentForegroundGate.js";
7
+ import { createFileScopeGate } from "./hooks/fileScopeGate.js";
8
+ import { createHumanApprovalServer } from "./tools/humanApproval.js";
9
+ import { createManualLoginServer } from "./tools/manualLogin.js";
10
+ import { createSaveToSourcesServer } from "./tools/saveToSources.js";
11
+ import { allowAnyMcpTool } from "./mcpPermissions.js";
12
+ import { knowledgePluginRoot, knowledgePromptSection } from "./knowledge.js";
13
+ /**
14
+ * Builds the `options` object passed to the Agent SDK's `query()` — everything about
15
+ * *how* the agent runs a session (system prompt, tools, MCP servers, hooks), independent
16
+ * of *what* is said to start it off (that's the caller's `prompt`, a plain string for a
17
+ * one-shot run or an `AsyncIterable` for a multi-turn chat).
18
+ *
19
+ * Everything actually *about the domain* (which system prompt, which MCP servers besides
20
+ * the generic human-in-the-loop/knowledge ones below, which plugin roots, which
21
+ * subagents) is behind `spec` (see agentSpec.ts) — this function only knows the generic
22
+ * fields on `BaseSessionConfig`.
23
+ *
24
+ * The per-mode behavioral differences (whether the approval/manual-intervention tools
25
+ * exist, whether the interactive step gate hook runs) all fall out of `config.mode`
26
+ * alone.
27
+ */
28
+ export async function buildSessionOptions(config, runDir, spec, options = {}) {
29
+ const { mode } = config;
30
+ const modeControl = createModeControl(mode);
31
+ // The human approval server only exists outside autonomous mode: the point of
32
+ // autonomous is that the agent has no channel to ask for human intervention at all.
33
+ const includeApprovalTool = mode !== "autonomous";
34
+ // Manual intervention only makes sense for a domain that drives some live UI a human
35
+ // could actually step into by hand — `spec.manualInterventionTexts` being set is that
36
+ // domain's own opt-in signal (see its doc comment in agentSpec.ts), not a generic
37
+ // boolean this kit could infer on its own.
38
+ const manualInterventionTexts = mode !== "autonomous" ? spec.manualInterventionTexts : undefined;
39
+ // The file tools exist as soon as the project has a place for the agent's notes
40
+ // (`knowledgeDir`) or for original files (`sourcesDir`) — there is no separate "context"
41
+ // folder: material the user provides is just more of `sourcesDir`.
42
+ const includeFileTools = Boolean(config.knowledgeDir || config.sourcesDir);
43
+ const fileTools = includeFileTools ? ["Read", "Write", "Edit", "Glob", "Grep"] : [];
44
+ // Where Write/Edit may act and Grep may search (see hooks/fileScopeGate.ts): the
45
+ // project's own folders, never the whole cwd — which would include whatever else the
46
+ // project directory holds (the user's config...). `sourcesDir` is searchable but
47
+ // deliberately NOT writable: originals stay as obtained, and the only way to add to it is
48
+ // the `save_to_sources` tool (which never overwrites).
49
+ const writableDirs = [config.knowledgeDir, ...(config.extraWritableDirs ?? [])].filter((d) => Boolean(d));
50
+ const searchableDirs = [config.knowledgeDir, config.sourcesDir, ...(config.extraWritableDirs ?? [])].filter((d) => Boolean(d));
51
+ const readOnlyDirs = config.sourcesDir ? [config.sourcesDir] : [];
52
+ // Skills/commands/plugins are a separate concern from the file tools: an agent that
53
+ // wants a plugin-provided skill/command but has no notes or sources folder shouldn't have
54
+ // to invent one just to get `cwd`/`skills: "all"`/`plugins` wired up. Gated on either
55
+ // signal, not on `pluginRoots` alone, so an agent with file tools keeps getting the SDK's
56
+ // own project-level `.claude/skills`/`.claude/commands` discovery.
57
+ // The built-in knowledge base (rules in the system prompt + its skills/commands as a plugin) needs
58
+ // somewhere to keep the wiki, so it follows `knowledgeDir`; `spec.knowledgeBase: false` opts out.
59
+ const includeKnowledgeBase = Boolean(config.knowledgeDir) && spec.knowledgeBase !== false;
60
+ const pluginRoots = [...spec.pluginRoots(config), ...(includeKnowledgeBase ? [knowledgePluginRoot()] : [])];
61
+ const includeSkillsAndPlugins = includeFileTools || pluginRoots.length > 0;
62
+ const skillTools = includeSkillsAndPlugins ? ["Skill"] : [];
63
+ // Whatever opt-in subagents `spec` wants for this config, or undefined if none apply.
64
+ // Either opt-in feature needs the Agent tool (to delegate to a subagent) and Bash (used
65
+ // only by the subagent itself — see createSubagentBashGate() below, which denies Bash
66
+ // to the main agent regardless of which one put it in `tools`) — Bash has to be present
67
+ // in this session's own tools for any subagent to be allowed to use it at all
68
+ // (confirmed empirically the SDK refuses to spawn a subagent whose own tools list names
69
+ // anything not already present here — which is why the hook, not just omitting "Bash"
70
+ // from this list, is what actually confines it to subagent use).
71
+ const subagents = spec.buildSubagents(config);
72
+ const includeSubagentTools = subagents !== undefined;
73
+ // The exact set of subagent_type values the Agent tool may spawn in this session — see
74
+ // createSubagentTypeGate() below for why this can't just be "whatever's in `agents`
75
+ // below": the SDK's own built-in "general-purpose" type is spawnable regardless of that
76
+ // map, and inherits the full session tools (Bash included) rather than the narrow list
77
+ // each declared subagent actually declares.
78
+ const allowedSubagentTypes = subagents?.allowedSubagentTypes ?? [];
79
+ const transcriptPath = path.join(runDir, "transcript.jsonl");
80
+ const transcriptLogger = createTranscriptLogger(transcriptPath, config.secrets ?? []);
81
+ const sdkOptions = {
82
+ systemPrompt: includeKnowledgeBase && config.knowledgeDir
83
+ ? `${spec.buildSystemPrompt(config)}\n\n${knowledgePromptSection(config.projectDir, config.knowledgeDir, config.sourcesDir)}`
84
+ : spec.buildSystemPrompt(config),
85
+ settings: { autoCompactEnabled: options.autoCompactEnabled ?? true },
86
+ // No built-in tools except, if applicable, Read/Write/Edit/Glob/Grep scoped to the
87
+ // project's own folders (fileScopeGate below), and WebFetch/WebSearch
88
+ // with no domain restriction (not conditioned on includeFileTools: it's read-only,
89
+ // doesn't depend on there being a project directory) — any priority order between
90
+ // them is the domain's own system prompt's job to establish, not this function's.
91
+ // "Skill" has to be listed explicitly: with an explicit `tools` list the SDK loads the
92
+ // skills (they show up in the init message) but doesn't offer the tool to invoke them,
93
+ // so the model ends up looking for SKILL.md files by hand (confirmed empirically).
94
+ tools: [...fileTools, ...skillTools, "WebFetch", "WebSearch", ...(includeSubagentTools ? ["Agent", "Bash"] : [])],
95
+ allowedTools: [...fileTools, ...skillTools, "WebFetch", "WebSearch", ...(includeSubagentTools ? ["Agent", "Bash"] : [])],
96
+ // Every mcp__* tool (whatever spec.buildMcpServers() registers, approvals/manualLogin
97
+ // when enabled below, sourceFiles, and any server a project's own .mcp.json
98
+ // declares) is approved generically here rather than listed one by one — see
99
+ // mcpPermissions.ts for why a fixed allowedTools wildcard can't cover a server whose
100
+ // name isn't known ahead of time.
101
+ canUseTool: allowAnyMcpTool,
102
+ disallowedTools: spec.disallowedTools ?? [],
103
+ // Real text streaming instead of silently waiting for the turn's complete message —
104
+ // see the caller's message loop.
105
+ includePartialMessages: true,
106
+ ...(includeSkillsAndPlugins
107
+ ? {
108
+ // With cwd pointing at the project, the SDK's own "project settings" discovery
109
+ // finds <project>/.claude/skills/ on its own — the user's own custom
110
+ // skills, on top of the plugin-provided built-ins from spec.pluginRoots()
111
+ // below. skipMcpDiscovery: true on those plugins is the caller's choice.
112
+ cwd: config.projectDir,
113
+ // Empty when there's no knowledgeDir/sourcesDir (the plugin-only case) — an
114
+ // empty array is a valid, no-op value for this SDK option, not omitted, since
115
+ // this whole block is already conditioned on includeSkillsAndPlugins.
116
+ additionalDirectories: searchableDirs,
117
+ // "skills" acts as a name whitelist, not an addition: "all" enables both the
118
+ // SDK's own official skills (pdf/docx), the project's own custom ones, and the
119
+ // plugin-provided built-ins below.
120
+ skills: "all",
121
+ plugins: pluginRoots.map((pluginPath) => ({ type: "local", path: pluginPath, skipMcpDiscovery: true })),
122
+ }
123
+ : {}),
124
+ maxTurns: 400,
125
+ ...(subagents ? { agents: subagents.agents } : {}),
126
+ mcpServers: {
127
+ ...spec.buildMcpServers(config, runDir),
128
+ ...(includeApprovalTool ? { approvals: createHumanApprovalServer(runDir, spec.humanApprovalTexts) } : {}),
129
+ ...(manualInterventionTexts ? { manualLogin: createManualLoginServer(runDir, manualInterventionTexts) } : {}),
130
+ ...(config.sourcesDir ? { sourceFiles: createSaveToSourcesServer(runDir, config.sourcesDir, spec.saveToSourcesDescription) } : {}),
131
+ },
132
+ hooks: {
133
+ PreToolUse: [
134
+ { hooks: [transcriptLogger.preToolUse] },
135
+ ...(includeFileTools
136
+ ? [{ hooks: [createFileScopeGate({ projectDir: config.projectDir, writableDirs, searchableDirs, readOnlyDirs, deniedPaths: config.deniedPaths ?? [] })] }]
137
+ : []),
138
+ // Registered wherever the approval tool is, and asking only while the current mode is
139
+ // "interactive": a guided session can switch to interactive and back (ModeControl).
140
+ ...(includeApprovalTool ? [{ hooks: [createStepGate(runDir, () => modeControl.mode === "interactive")] }] : []),
141
+ ...(includeSubagentTools
142
+ ? [
143
+ { hooks: [createSubagentTypeGate(allowedSubagentTypes)] },
144
+ { hooks: [createSubagentBashGate()] },
145
+ { hooks: [createSubagentForegroundGate(allowedSubagentTypes)] },
146
+ ]
147
+ : []),
148
+ ],
149
+ PostToolUse: [{ hooks: [transcriptLogger.postToolUse] }],
150
+ },
151
+ };
152
+ return { options: sdkOptions, transcriptLogger, transcriptPath, modeControl };
153
+ }
154
+ export function createModeControl(initial) {
155
+ let current = initial;
156
+ const switchable = initial === "autonomous" ? ["autonomous"] : ["guided", "interactive"];
157
+ return {
158
+ get mode() {
159
+ return current;
160
+ },
161
+ switchable,
162
+ set(next) {
163
+ if (!switchable.includes(next))
164
+ return false;
165
+ current = next;
166
+ return true;
167
+ },
168
+ };
169
+ }
170
+ /**
171
+ * User message queue backed by a single long-lived generator, for any multi-turn
172
+ * (chat-shaped) caller of `query()`.
173
+ *
174
+ * The SDK closes the transport as soon as the AsyncIterable passed as `prompt` (or to
175
+ * `streamInput()`) finishes iterating — so a "one message and done" generator leaves the
176
+ * transport closed right after sending that message, and the next call blows up with
177
+ * "ProcessTransport is not ready for writing". A real multi-turn conversation needs a
178
+ * single generator that never finishes on its own: messages get pushed into it with
179
+ * `push()` and it's only explicitly closed with `end()` when leaving the chat.
180
+ */
181
+ export function createInputQueue() {
182
+ const pending = [];
183
+ let wake = null;
184
+ let ended = false;
185
+ async function* generator() {
186
+ while (!ended) {
187
+ const text = pending.shift();
188
+ if (text === undefined) {
189
+ await new Promise((resolve) => {
190
+ wake = resolve;
191
+ });
192
+ continue;
193
+ }
194
+ yield {
195
+ type: "user",
196
+ message: { role: "user", content: text },
197
+ parent_tool_use_id: null,
198
+ };
199
+ }
200
+ }
201
+ return {
202
+ push(text) {
203
+ pending.push(text);
204
+ wake?.();
205
+ wake = null;
206
+ },
207
+ end() {
208
+ ended = true;
209
+ wake?.();
210
+ wake = null;
211
+ },
212
+ iterable: generator(),
213
+ };
214
+ }
215
+ /** Externally-controllable promise — used to know when a chat turn has finished. */
216
+ export function createDeferred() {
217
+ let resolveFn;
218
+ const promise = new Promise((resolve) => {
219
+ resolveFn = resolve;
220
+ });
221
+ return { promise, resolve: resolveFn };
222
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Turns a raw tool name + input into a short, readable console description, instead of
3
+ * the tool's technical name (e.g. "mcp__playwright__browser_click"). Covers the tools
4
+ * this kit itself provides (Read/Write/Edit/Glob/Grep/Bash/Agent/WebFetch/WebSearch/Skill,
5
+ * plus its own human-approval/manual-intervention/save-to-sources MCP tools)
6
+ * generically; a
7
+ * concrete agent supplies a `describe` callback for its own domain-specific tools (e.g.
8
+ * Playwright's browser_* cases) via `createFriendlyToolLabel()`.
9
+ */
10
+ export declare function truncate(text: string, max?: number): string;
11
+ export declare function truncatePath(text: string, max?: number): string;
12
+ export type ToolDescriber = (shortName: string, input: Record<string, unknown>) => string | undefined;
13
+ /**
14
+ * Builds a `friendlyToolLabel(toolName, toolInput)` — `describe` lets the host agent
15
+ * layer its own domain-specific cases (e.g. Playwright's browser_* tools) on top of this
16
+ * kit's generic ones; `extraLocalServers` names any *additional* MCP server (beyond this
17
+ * kit's own "approvals"/"manualLogin"/"sourceFiles") whose tools should be unwrapped
18
+ * without a "[server] " prefix, e.g. "playwright".
19
+ */
20
+ export declare function createFriendlyToolLabel(options?: {
21
+ describe?: ToolDescriber;
22
+ extraLocalServers?: readonly string[];
23
+ }): (toolName: string, toolInput: unknown) => string;