@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.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/assets/knowledge-plugin/.claude-plugin/plugin.json +4 -0
- package/assets/knowledge-plugin/commands/ingest.md +7 -0
- package/assets/knowledge-plugin/commands/lint.md +5 -0
- package/assets/knowledge-plugin/commands/query.md +5 -0
- package/assets/knowledge-plugin/skills/knowledge-ingest/SKILL.md +42 -0
- package/assets/knowledge-plugin/skills/knowledge-lint/SKILL.md +26 -0
- package/assets/knowledge-plugin/skills/knowledge-pages/SKILL.md +137 -0
- package/assets/knowledge-plugin/skills/knowledge-query/SKILL.md +14 -0
- package/dist/core/agentSpec.d.ts +132 -0
- package/dist/core/agentSpec.js +1 -0
- package/dist/core/claudeAuth.d.ts +24 -0
- package/dist/core/claudeAuth.js +27 -0
- package/dist/core/hooks/fileScopeGate.d.ts +33 -0
- package/dist/core/hooks/fileScopeGate.js +68 -0
- package/dist/core/hooks/humanInput.d.ts +15 -0
- package/dist/core/hooks/humanInput.js +52 -0
- package/dist/core/hooks/stepGate.d.ts +11 -0
- package/dist/core/hooks/stepGate.js +47 -0
- package/dist/core/hooks/subagentBashGate.d.ts +28 -0
- package/dist/core/hooks/subagentBashGate.js +40 -0
- package/dist/core/hooks/subagentForegroundGate.d.ts +18 -0
- package/dist/core/hooks/subagentForegroundGate.js +34 -0
- package/dist/core/hooks/subagentTypeGate.d.ts +16 -0
- package/dist/core/hooks/subagentTypeGate.js +33 -0
- package/dist/core/hooks/transcriptLogger.d.ts +14 -0
- package/dist/core/hooks/transcriptLogger.js +66 -0
- package/dist/core/interaction.d.ts +29 -0
- package/dist/core/interaction.js +7 -0
- package/dist/core/knowledge.d.ts +15 -0
- package/dist/core/knowledge.js +54 -0
- package/dist/core/mcpPermissions.d.ts +23 -0
- package/dist/core/mcpPermissions.js +47 -0
- package/dist/core/promptTemplate.d.ts +6 -0
- package/dist/core/promptTemplate.js +20 -0
- package/dist/core/runner.d.ts +137 -0
- package/dist/core/runner.js +115 -0
- package/dist/core/session.d.ts +63 -0
- package/dist/core/session.js +222 -0
- package/dist/core/toolLabels.d.ts +23 -0
- package/dist/core/toolLabels.js +114 -0
- package/dist/core/tools/humanApproval.d.ts +14 -0
- package/dist/core/tools/humanApproval.js +37 -0
- package/dist/core/tools/manualLogin.d.ts +16 -0
- package/dist/core/tools/manualLogin.js +27 -0
- package/dist/core/tools/saveToSources.d.ts +10 -0
- package/dist/core/tools/saveToSources.js +105 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +32 -0
- package/dist/tui/chatTui.d.ts +105 -0
- package/dist/tui/chatTui.js +265 -0
- package/dist/tui/claudeAuth.d.ts +19 -0
- package/dist/tui/claudeAuth.js +96 -0
- package/dist/tui/consoleRenderer.d.ts +32 -0
- package/dist/tui/consoleRenderer.js +72 -0
- package/dist/tui/ink/PromptInput.d.ts +40 -0
- package/dist/tui/ink/PromptInput.js +333 -0
- package/dist/tui/ink/SessionView.d.ts +42 -0
- package/dist/tui/ink/SessionView.js +223 -0
- package/dist/tui/ink/fileMentions.d.ts +13 -0
- package/dist/tui/ink/fileMentions.js +50 -0
- package/dist/tui/ink/fullscreen.d.ts +79 -0
- package/dist/tui/ink/fullscreen.js +175 -0
- package/dist/tui/ink/header.d.ts +17 -0
- package/dist/tui/ink/header.js +24 -0
- package/dist/tui/ink/inkInteraction.d.ts +22 -0
- package/dist/tui/ink/inkInteraction.js +64 -0
- package/dist/tui/ink/lineBuffer.d.ts +28 -0
- package/dist/tui/ink/lineBuffer.js +87 -0
- package/dist/tui/ink/markdown.d.ts +12 -0
- package/dist/tui/ink/markdown.js +148 -0
- package/dist/tui/ink/progressView.d.ts +27 -0
- package/dist/tui/ink/progressView.js +65 -0
- package/dist/tui/ink/promptEditing.d.ts +54 -0
- package/dist/tui/ink/promptEditing.js +123 -0
- package/dist/tui/ink/runChatInk.d.ts +103 -0
- package/dist/tui/ink/runChatInk.js +298 -0
- package/dist/tui/ink/sessionModel.d.ts +85 -0
- package/dist/tui/ink/sessionModel.js +262 -0
- package/dist/tui/ink/terminalCursor.d.ts +32 -0
- package/dist/tui/ink/terminalCursor.js +68 -0
- package/dist/tui/ink/terminalStatus.d.ts +20 -0
- package/dist/tui/ink/terminalStatus.js +55 -0
- package/dist/tui/ink/toolGroup.d.ts +36 -0
- package/dist/tui/ink/toolGroup.js +77 -0
- package/dist/tui/ink/wizard.d.ts +62 -0
- package/dist/tui/ink/wizard.js +167 -0
- package/dist/tui/promptErrors.d.ts +2 -0
- package/dist/tui/promptErrors.js +4 -0
- package/dist/tui/terminalInteraction.d.ts +18 -0
- package/dist/tui/terminalInteraction.js +85 -0
- package/dist/tui/ui.d.ts +21 -0
- package/dist/tui/ui.js +25 -0
- 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;
|