@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,68 @@
1
+ import path from "node:path";
2
+ /** Whether `candidate` is `base` itself or somewhere inside it (case-insensitive on Windows, like path.relative). */
3
+ export function isWithin(base, candidate) {
4
+ const relative = path.relative(base, candidate);
5
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
6
+ }
7
+ function describeDirs(scope, dirs) {
8
+ return dirs.map((dir) => `${path.relative(scope.projectDir, dir) || "."}/`).join(", ");
9
+ }
10
+ /**
11
+ * The reason to deny `toolName` with `input` under `scope`, or `undefined` to let it
12
+ * through. Tools other than Read/Write/Edit/Grep are never judged here, and a call
13
+ * without a path is left for the tool itself to reject.
14
+ */
15
+ export function checkFileScope(scope, toolName, input) {
16
+ const resolve = (value) => typeof value === "string" && value !== "" ? path.resolve(scope.projectDir, value) : undefined;
17
+ const isDenied = (target) => scope.deniedPaths.some((denied) => isWithin(denied, target));
18
+ switch (toolName) {
19
+ case "Read": {
20
+ const target = resolve(input.file_path);
21
+ return target && isDenied(target) ? `"${input.file_path}" is off limits: it can't be read.` : undefined;
22
+ }
23
+ case "Write":
24
+ case "Edit": {
25
+ const target = resolve(input.file_path);
26
+ if (!target)
27
+ return undefined;
28
+ if (!isDenied(target) && scope.writableDirs.some((dir) => isWithin(dir, target)))
29
+ return undefined;
30
+ if (!isDenied(target) && scope.readOnlyDirs?.some((dir) => isWithin(dir, target))) {
31
+ return `"${input.file_path}" is read-only: originals are never modified — write your own notes inside ${describeDirs(scope, scope.writableDirs)} instead.`;
32
+ }
33
+ return `"${input.file_path}" can't be written: writing is only allowed inside ${describeDirs(scope, scope.writableDirs)}.`;
34
+ }
35
+ case "Grep": {
36
+ const target = resolve(input.path) ?? scope.projectDir;
37
+ const inside = scope.searchableDirs.some((dir) => isWithin(dir, target));
38
+ // A folder that merely *contains* a denied file would still leak it through a match.
39
+ const containsDenied = scope.deniedPaths.some((denied) => isWithin(target, denied));
40
+ if (inside && !isDenied(target) && !containsDenied)
41
+ return undefined;
42
+ return `Grep only searches inside ${describeDirs(scope, scope.searchableDirs)} — pass one of them (or a folder inside) as "path".`;
43
+ }
44
+ default:
45
+ return undefined;
46
+ }
47
+ }
48
+ /**
49
+ * PreToolUse hook enforcing `scope` on Read/Write/Edit/Grep, for the main agent and its
50
+ * subagents alike. Bash, when a subagent has it, is not covered: it can reach any path,
51
+ * which is why Bash-granting features stay opt-in.
52
+ */
53
+ export function createFileScopeGate(scope) {
54
+ return async (input) => {
55
+ const pre = input;
56
+ const toolInput = pre.tool_input && typeof pre.tool_input === "object" ? pre.tool_input : {};
57
+ const reason = checkFileScope(scope, pre.tool_name, toolInput);
58
+ if (!reason)
59
+ return {};
60
+ return {
61
+ hookSpecificOutput: {
62
+ hookEventName: pre.hook_event_name,
63
+ permissionDecision: "deny",
64
+ permissionDecisionReason: reason,
65
+ },
66
+ };
67
+ };
68
+ }
@@ -0,0 +1,15 @@
1
+ import { type ApprovalPrompt } from "../interaction.js";
2
+ export type { ApprovalPrompt } from "../interaction.js";
3
+ /**
4
+ * Asks the human for a decision through two channels in parallel, whichever answers
5
+ * first wins: (1) the installed `InteractionPort` (the keyboard, for a person running the
6
+ * process in their own terminal, see tui/terminalInteraction.ts), and (2) a response file,
7
+ * for when something else (e.g. Claude Code, or a non-terminal host driving its own UI —
8
+ * an Electron main process, a Tauri sidecar) is piloting the run and has no interactive
9
+ * stdin to write to.
10
+ *
11
+ * Returns the answer lowercased and trimmed ("", "y", "n", "q"...).
12
+ */
13
+ export declare function askForDecision(runDir: string, prompt: ApprovalPrompt): Promise<string>;
14
+ /** Like `askForDecision()`, for a manual-intervention checkpoint (see tools/manualLogin.ts). */
15
+ export declare function askForManualIntervention(runDir: string, prompt: ApprovalPrompt): Promise<string>;
@@ -0,0 +1,52 @@
1
+ import { readFile, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getInteractionPort } from "../interaction.js";
4
+ /**
5
+ * Asks the human for a decision through two channels in parallel, whichever answers
6
+ * first wins: (1) the installed `InteractionPort` (the keyboard, for a person running the
7
+ * process in their own terminal, see tui/terminalInteraction.ts), and (2) a response file,
8
+ * for when something else (e.g. Claude Code, or a non-terminal host driving its own UI —
9
+ * an Electron main process, a Tauri sidecar) is piloting the run and has no interactive
10
+ * stdin to write to.
11
+ *
12
+ * Returns the answer lowercased and trimmed ("", "y", "n", "q"...).
13
+ */
14
+ export async function askForDecision(runDir, prompt) {
15
+ return await raceWithResponseFile(runDir, (port, signal) => port.askDecision(prompt, signal));
16
+ }
17
+ /** Like `askForDecision()`, for a manual-intervention checkpoint (see tools/manualLogin.ts). */
18
+ export async function askForManualIntervention(runDir, prompt) {
19
+ return await raceWithResponseFile(runDir, (port, signal) => port.askManualIntervention(prompt, signal));
20
+ }
21
+ function never() {
22
+ return new Promise(() => { });
23
+ }
24
+ async function raceWithResponseFile(runDir, ask) {
25
+ const responseFile = path.join(runDir, "approval-response.txt");
26
+ await rm(responseFile, { force: true });
27
+ let settled = false;
28
+ const controller = new AbortController();
29
+ const port = getInteractionPort();
30
+ // A port that fails to ask (e.g. a non-interactive stdin) must not win the race: let
31
+ // the response file answer instead.
32
+ const fromPort = port ? ask(port, controller.signal).catch(never) : never();
33
+ const fromFile = (async () => {
34
+ while (!settled) {
35
+ await new Promise((resolve) => setTimeout(resolve, 1000));
36
+ try {
37
+ const content = (await readFile(responseFile, "utf-8")).trim();
38
+ if (content)
39
+ return content;
40
+ }
41
+ catch {
42
+ // the file doesn't exist yet, keep waiting
43
+ }
44
+ }
45
+ return "";
46
+ })();
47
+ const raw = await Promise.race([fromPort, fromFile]);
48
+ settled = true;
49
+ controller.abort();
50
+ await rm(responseFile, { force: true });
51
+ return raw.trim().toLowerCase();
52
+ }
@@ -0,0 +1,11 @@
1
+ import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
2
+ /**
3
+ * PreToolUse hook for "interactive" mode: pauses before every action and asks for
4
+ * confirmation (by keyboard or by file, see humanInput.ts), like reviewing a plan step
5
+ * by step.
6
+ *
7
+ * `isActive` lets one session switch between "guided" and "interactive" while it runs (see
8
+ * session.ts's `ModeControl`): the hook stays registered and, while inactive, gives no
9
+ * decision at all, so the tool call goes on exactly as if the hook weren't there.
10
+ */
11
+ export declare function createStepGate(runDir: string, isActive?: () => boolean): HookCallback;
@@ -0,0 +1,47 @@
1
+ import { askForDecision } from "./humanInput.js";
2
+ /**
3
+ * PreToolUse hook for "interactive" mode: pauses before every action and asks for
4
+ * confirmation (by keyboard or by file, see humanInput.ts), like reviewing a plan step
5
+ * by step.
6
+ *
7
+ * `isActive` lets one session switch between "guided" and "interactive" while it runs (see
8
+ * session.ts's `ModeControl`): the hook stays registered and, while inactive, gives no
9
+ * decision at all, so the tool call goes on exactly as if the hook weren't there.
10
+ */
11
+ export function createStepGate(runDir, isActive = () => true) {
12
+ return async (input) => {
13
+ if (!isActive())
14
+ return {};
15
+ const pre = input;
16
+ const answer = await askForDecision(runDir, {
17
+ title: "Proposed action",
18
+ lines: [`Tool: ${pre.tool_name}`, `Parameters: ${JSON.stringify(pre.tool_input, null, 2)}`],
19
+ });
20
+ if (answer === "q") {
21
+ return {
22
+ continue: false,
23
+ hookSpecificOutput: {
24
+ hookEventName: pre.hook_event_name,
25
+ permissionDecision: "deny",
26
+ permissionDecisionReason: "Execution stopped manually by the user.",
27
+ },
28
+ };
29
+ }
30
+ const approved = answer === "" || answer === "y" || answer === "yes";
31
+ if (!approved) {
32
+ return {
33
+ hookSpecificOutput: {
34
+ hookEventName: pre.hook_event_name,
35
+ permissionDecision: "deny",
36
+ permissionDecisionReason: "Action rejected by the user in step-by-step mode.",
37
+ },
38
+ };
39
+ }
40
+ return {
41
+ hookSpecificOutput: {
42
+ hookEventName: pre.hook_event_name,
43
+ permissionDecision: "allow",
44
+ },
45
+ };
46
+ };
47
+ }
@@ -0,0 +1,28 @@
1
+ import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
2
+ /**
3
+ * PreToolUse hook restricting the Bash tool to subagents only, never the main agent
4
+ * directly. See session.ts's buildSessionOptions(), which attaches this hook whenever
5
+ * `AgentSpec.buildSubagents()` returned something for this config.
6
+ *
7
+ * Bash has to be present in the session's own `tools`/`allowedTools` for any subagent to
8
+ * be able to use it at all: the SDK refuses to spawn a subagent whose own
9
+ * `AgentDefinition.tools` names anything not already present in the session's tools
10
+ * ("would be spawned with zero tools... unrecognized [Bash]", confirmed empirically). But
11
+ * once it's there, it's also available to the main agent directly and bypasses
12
+ * `canUseTool` entirely for built-in tools like Bash (also confirmed empirically — this is
13
+ * why allowAnyMcpTool in mcpPermissions.ts, which only ever gated mcp__* tools, can't help
14
+ * here) — a PreToolUse hook is the mechanism the SDK's own
15
+ * CLAUDE_SDK_CAN_USE_TOOL_SHADOWED warning points at for gating a tool call by call.
16
+ *
17
+ * The hook input's `agent_id` field is present only when the call originates from within
18
+ * a subagent (confirmed empirically, matching its own doc comment in sdk.d.ts: "Absent
19
+ * for the main thread... Use this field (not agent_type) to distinguish subagent calls
20
+ * from main-thread calls.") — so denying Bash exactly when `agent_id` is absent confines
21
+ * it to subagent use without needing per-subagent sandboxing here. That "without needing
22
+ * per-subagent sandboxing" only holds together with subagentTypeGate.ts's sibling hook,
23
+ * registered right alongside this one in session.ts: without it, the SDK's own built-in
24
+ * "general-purpose" subagent_type is spawnable regardless of what `agents` declares,
25
+ * inherits Bash the same as an intended subagent (agent_id set either way), and has no
26
+ * AgentDefinition.tools list narrowing what it can do with it.
27
+ */
28
+ export declare function createSubagentBashGate(): HookCallback;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * PreToolUse hook restricting the Bash tool to subagents only, never the main agent
3
+ * directly. See session.ts's buildSessionOptions(), which attaches this hook whenever
4
+ * `AgentSpec.buildSubagents()` returned something for this config.
5
+ *
6
+ * Bash has to be present in the session's own `tools`/`allowedTools` for any subagent to
7
+ * be able to use it at all: the SDK refuses to spawn a subagent whose own
8
+ * `AgentDefinition.tools` names anything not already present in the session's tools
9
+ * ("would be spawned with zero tools... unrecognized [Bash]", confirmed empirically). But
10
+ * once it's there, it's also available to the main agent directly and bypasses
11
+ * `canUseTool` entirely for built-in tools like Bash (also confirmed empirically — this is
12
+ * why allowAnyMcpTool in mcpPermissions.ts, which only ever gated mcp__* tools, can't help
13
+ * here) — a PreToolUse hook is the mechanism the SDK's own
14
+ * CLAUDE_SDK_CAN_USE_TOOL_SHADOWED warning points at for gating a tool call by call.
15
+ *
16
+ * The hook input's `agent_id` field is present only when the call originates from within
17
+ * a subagent (confirmed empirically, matching its own doc comment in sdk.d.ts: "Absent
18
+ * for the main thread... Use this field (not agent_type) to distinguish subagent calls
19
+ * from main-thread calls.") — so denying Bash exactly when `agent_id` is absent confines
20
+ * it to subagent use without needing per-subagent sandboxing here. That "without needing
21
+ * per-subagent sandboxing" only holds together with subagentTypeGate.ts's sibling hook,
22
+ * registered right alongside this one in session.ts: without it, the SDK's own built-in
23
+ * "general-purpose" subagent_type is spawnable regardless of what `agents` declares,
24
+ * inherits Bash the same as an intended subagent (agent_id set either way), and has no
25
+ * AgentDefinition.tools list narrowing what it can do with it.
26
+ */
27
+ export function createSubagentBashGate() {
28
+ return async (input) => {
29
+ const pre = input;
30
+ if (pre.tool_name !== "Bash" || pre.agent_id)
31
+ return {};
32
+ return {
33
+ hookSpecificOutput: {
34
+ hookEventName: pre.hook_event_name,
35
+ permissionDecision: "deny",
36
+ permissionDecisionReason: "Bash is only available to dedicated subagents, not the main agent directly — use the Agent tool to delegate to one of them instead.",
37
+ },
38
+ };
39
+ };
40
+ }
@@ -0,0 +1,18 @@
1
+ import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
2
+ /**
3
+ * PreToolUse hook forcing `run_in_background: false` on every Agent-tool call that spawns
4
+ * one of this session's own subagents. The Agent tool's own input schema defaults to
5
+ * background execution ("Agents run in the background by default", its own description
6
+ * says) unless the model explicitly opts out per call — confirmed empirically (in a real
7
+ * consuming agent) that with a less directive prompt, the model left it on that default, the
8
+ * main turn's "result" fired before the subagent had done anything at all, and the whole
9
+ * invocation was silently lost: no file written, no error, nothing — because the caller's
10
+ * message loop had no code watching for background-task completion
11
+ * (SDKTaskStartedMessage/SDKTaskUpdatedMessage/etc. are never handled). Setting
12
+ * `background: false` on the AgentDefinition itself (session.ts's `agents: {...}`) does
13
+ * NOT fix this — confirmed empirically it has no effect on which way the model's own
14
+ * per-call `run_in_background` input resolves. Rewriting the tool input here, before it
15
+ * ever executes, is the only lever that actually forces synchronous execution regardless
16
+ * of what the model asked for.
17
+ */
18
+ export declare function createSubagentForegroundGate(allowedTypes: readonly string[]): HookCallback;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * PreToolUse hook forcing `run_in_background: false` on every Agent-tool call that spawns
3
+ * one of this session's own subagents. The Agent tool's own input schema defaults to
4
+ * background execution ("Agents run in the background by default", its own description
5
+ * says) unless the model explicitly opts out per call — confirmed empirically (in a real
6
+ * consuming agent) that with a less directive prompt, the model left it on that default, the
7
+ * main turn's "result" fired before the subagent had done anything at all, and the whole
8
+ * invocation was silently lost: no file written, no error, nothing — because the caller's
9
+ * message loop had no code watching for background-task completion
10
+ * (SDKTaskStartedMessage/SDKTaskUpdatedMessage/etc. are never handled). Setting
11
+ * `background: false` on the AgentDefinition itself (session.ts's `agents: {...}`) does
12
+ * NOT fix this — confirmed empirically it has no effect on which way the model's own
13
+ * per-call `run_in_background` input resolves. Rewriting the tool input here, before it
14
+ * ever executes, is the only lever that actually forces synchronous execution regardless
15
+ * of what the model asked for.
16
+ */
17
+ export function createSubagentForegroundGate(allowedTypes) {
18
+ return async (input) => {
19
+ const pre = input;
20
+ if (pre.tool_name !== "Agent")
21
+ return {};
22
+ const toolInput = pre.tool_input;
23
+ if (!toolInput?.subagent_type || !allowedTypes.includes(toolInput.subagent_type))
24
+ return {};
25
+ if (toolInput.run_in_background === false)
26
+ return {}; // already foreground, nothing to change
27
+ return {
28
+ hookSpecificOutput: {
29
+ hookEventName: pre.hook_event_name,
30
+ updatedInput: { ...toolInput, run_in_background: false },
31
+ },
32
+ };
33
+ };
34
+ }
@@ -0,0 +1,16 @@
1
+ import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
2
+ /**
3
+ * PreToolUse hook restricting the Agent tool to the subagent types this session actually
4
+ * registered (session.ts's `agents: {...}`, from `AgentSpec.buildSubagents()`) —
5
+ * confirmed empirically that the Agent SDK also exposes a built-in "general-purpose"
6
+ * subagent_type regardless of what `agents` lists, and that spawning one that way
7
+ * inherits the *whole* session's own tools (including Bash, whenever any opt-in subagent
8
+ * turned it on) rather than the narrow `AgentDefinition.tools` each declared subagent
9
+ * actually declares. Without this hook, that built-in type is a way to route around
10
+ * subagentBashGate.ts entirely: once inside it, `agent_id` is set exactly like an
11
+ * intended subagent, so the Bash-from-main-thread check alone can't tell them apart —
12
+ * this is exactly what happened in practice in a real consuming agent (the model delegated a plain
13
+ * file deletion to a spontaneous "general-purpose" agent to get at Bash, instead of using
14
+ * its own tools and leaving the stray file alone).
15
+ */
16
+ export declare function createSubagentTypeGate(allowedTypes: readonly string[]): HookCallback;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * PreToolUse hook restricting the Agent tool to the subagent types this session actually
3
+ * registered (session.ts's `agents: {...}`, from `AgentSpec.buildSubagents()`) —
4
+ * confirmed empirically that the Agent SDK also exposes a built-in "general-purpose"
5
+ * subagent_type regardless of what `agents` lists, and that spawning one that way
6
+ * inherits the *whole* session's own tools (including Bash, whenever any opt-in subagent
7
+ * turned it on) rather than the narrow `AgentDefinition.tools` each declared subagent
8
+ * actually declares. Without this hook, that built-in type is a way to route around
9
+ * subagentBashGate.ts entirely: once inside it, `agent_id` is set exactly like an
10
+ * intended subagent, so the Bash-from-main-thread check alone can't tell them apart —
11
+ * this is exactly what happened in practice in a real consuming agent (the model delegated a plain
12
+ * file deletion to a spontaneous "general-purpose" agent to get at Bash, instead of using
13
+ * its own tools and leaving the stray file alone).
14
+ */
15
+ export function createSubagentTypeGate(allowedTypes) {
16
+ return async (input) => {
17
+ const pre = input;
18
+ if (pre.tool_name !== "Agent")
19
+ return {};
20
+ const subagentType = pre.tool_input?.subagent_type;
21
+ if (subagentType && allowedTypes.includes(subagentType))
22
+ return {};
23
+ return {
24
+ hookSpecificOutput: {
25
+ hookEventName: pre.hook_event_name,
26
+ permissionDecision: "deny",
27
+ permissionDecisionReason: `Delegation is only available for ${allowedTypes.map((t) => `"${t}"`).join("/")} in ` +
28
+ `this session, not "${subagentType ?? "(unset)"}" - do the task directly with your own ` +
29
+ "tools instead of spawning another agent for it.",
30
+ },
31
+ };
32
+ };
33
+ }
@@ -0,0 +1,14 @@
1
+ import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
2
+ export interface TranscriptLogger {
3
+ preToolUse: HookCallback;
4
+ postToolUse: HookCallback;
5
+ }
6
+ /**
7
+ * Logs every tool call (request + result) as one JSON line, redacting `secrets` (e.g. a
8
+ * domain-specific password — see `BaseSessionConfig.secrets`) plus
9
+ * `CLAUDE_CODE_OAUTH_TOKEN`, which this kit always scrubs regardless of domain since it
10
+ * owns the auth-token concept (see claudeAuth.ts).
11
+ */
12
+ export declare function createTranscriptLogger(transcriptPath: string, secrets?: readonly string[]): TranscriptLogger;
13
+ /** Avoids bloating the transcript with large payloads (browser-tool screenshots/base64). */
14
+ export declare function summarizeToolResponse(response: unknown): unknown;
@@ -0,0 +1,66 @@
1
+ import { appendFile } from "node:fs/promises";
2
+ function redact(text, secrets) {
3
+ let redacted = text;
4
+ for (const secret of secrets) {
5
+ if (secret)
6
+ redacted = redacted.split(secret).join("***");
7
+ }
8
+ return redacted;
9
+ }
10
+ /**
11
+ * Logs every tool call (request + result) as one JSON line, redacting `secrets` (e.g. a
12
+ * domain-specific password — see `BaseSessionConfig.secrets`) plus
13
+ * `CLAUDE_CODE_OAUTH_TOKEN`, which this kit always scrubs regardless of domain since it
14
+ * owns the auth-token concept (see claudeAuth.ts).
15
+ */
16
+ export function createTranscriptLogger(transcriptPath, secrets = []) {
17
+ const allSecrets = [...secrets, process.env.CLAUDE_CODE_OAUTH_TOKEN].filter((s) => Boolean(s));
18
+ const write = async (entry) => {
19
+ const line = redact(JSON.stringify(entry), allSecrets);
20
+ await appendFile(transcriptPath, `${line}\n`, "utf-8");
21
+ };
22
+ const preToolUse = async (input) => {
23
+ const pre = input;
24
+ await write({
25
+ ts: new Date().toISOString(),
26
+ event: "pre_tool_use",
27
+ tool: pre.tool_name,
28
+ input: pre.tool_input,
29
+ });
30
+ return {};
31
+ };
32
+ const postToolUse = async (input) => {
33
+ const post = input;
34
+ await write({
35
+ ts: new Date().toISOString(),
36
+ event: "post_tool_use",
37
+ tool: post.tool_name,
38
+ result: summarizeToolResponse(post.tool_response),
39
+ });
40
+ return {};
41
+ };
42
+ return { preToolUse, postToolUse };
43
+ }
44
+ /** Avoids bloating the transcript with large payloads (browser-tool screenshots/base64). */
45
+ export function summarizeToolResponse(response) {
46
+ const truncate = (text) => text.length > 2000 ? `${text.slice(0, 2000)}... [truncated]` : text;
47
+ if (typeof response === "string") {
48
+ return truncate(response);
49
+ }
50
+ if (Array.isArray(response)) {
51
+ return response.map((block) => {
52
+ if (block && typeof block === "object" && "type" in block) {
53
+ const typed = block;
54
+ if (typed.type === "text" && typeof typed.text === "string") {
55
+ return truncate(typed.text);
56
+ }
57
+ if (typeof typed.data === "string") {
58
+ return `[${typed.type} omitted, ${typed.data.length} bytes base64]`;
59
+ }
60
+ return typed;
61
+ }
62
+ return block;
63
+ });
64
+ }
65
+ return response;
66
+ }
@@ -0,0 +1,29 @@
1
+ export interface ApprovalPrompt {
2
+ title: string;
3
+ lines: string[];
4
+ /** Terminal question text; defaults to the approve/reject one. */
5
+ question?: string;
6
+ }
7
+ /**
8
+ * The UI side of a human-in-the-loop checkpoint: how a host shows the question and reads a
9
+ * person's answer. `askForDecision()` races it against the response file, so a port only
10
+ * covers the keyboard (or window) channel and never needs to know about the file.
11
+ *
12
+ * A method that can't ask (no TTY, no window) returns a promise that never resolves, so the
13
+ * file wins. `signal` aborts once the race is settled by the other channel; a port must
14
+ * drop its question then and leave its UI usable.
15
+ *
16
+ * Kept in `core/` so the checkpoints never depend on a terminal (ADR-001): the terminal
17
+ * port lives in `tui/` and is installed by the package entry point; a non-terminal host
18
+ * installs its own, or `null` to answer through the response file alone.
19
+ */
20
+ export interface InteractionPort {
21
+ /** A step-gate or approval checkpoint; resolves to the raw answer ("", "y", "n", "q"...). */
22
+ askDecision(prompt: ApprovalPrompt, signal: AbortSignal): Promise<string>;
23
+ /** Waits until the person confirms they intervened by hand (e.g. logged in). */
24
+ askManualIntervention(prompt: ApprovalPrompt, signal: AbortSignal): Promise<string>;
25
+ /** A one-way message for the person. */
26
+ notify(message: string): void;
27
+ }
28
+ export declare function setInteractionPort(port: InteractionPort | null): void;
29
+ export declare function getInteractionPort(): InteractionPort | null;
@@ -0,0 +1,7 @@
1
+ let current = null;
2
+ export function setInteractionPort(port) {
3
+ current = port;
4
+ }
5
+ export function getInteractionPort() {
6
+ return current;
7
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The built-in knowledge base (an "LLM wiki"): `sourcesDir` holds the originals, `knowledgeDir`
3
+ * is the wiki the agent writes and maintains, and the rules below plus the plugin's skills
4
+ * (knowledge-pages, knowledge-ingest, knowledge-query, knowledge-lint) and commands (/knowledge:ingest, /knowledge:query,
5
+ * /knowledge:lint) are the schema that tells the agent how. Domain-agnostic on purpose: an agent
6
+ * with its own page types (topics, activities...) opts out with `AgentSpec.knowledgeBase: false` and
7
+ * writes its own rules, or extends these.
8
+ */
9
+ /** Absolute path of the plugin shipped with the kit — `assets/knowledge-plugin` next to `dist/` (or `src/` under tsx). */
10
+ export declare function knowledgePluginRoot(): string;
11
+ /**
12
+ * The "Knowledge base" section appended to the system prompt: the layout and the working
13
+ * rules. The exact page templates live in the `knowledge-pages` skill, to keep this short.
14
+ */
15
+ export declare function knowledgePromptSection(projectDir: string, knowledgeDir: string, sourcesDir?: string): string;
@@ -0,0 +1,54 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ /**
4
+ * The built-in knowledge base (an "LLM wiki"): `sourcesDir` holds the originals, `knowledgeDir`
5
+ * is the wiki the agent writes and maintains, and the rules below plus the plugin's skills
6
+ * (knowledge-pages, knowledge-ingest, knowledge-query, knowledge-lint) and commands (/knowledge:ingest, /knowledge:query,
7
+ * /knowledge:lint) are the schema that tells the agent how. Domain-agnostic on purpose: an agent
8
+ * with its own page types (topics, activities...) opts out with `AgentSpec.knowledgeBase: false` and
9
+ * writes its own rules, or extends these.
10
+ */
11
+ /** Absolute path of the plugin shipped with the kit — `assets/knowledge-plugin` next to `dist/` (or `src/` under tsx). */
12
+ export function knowledgePluginRoot() {
13
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "assets", "knowledge-plugin");
14
+ }
15
+ /** A path as shown to the model: relative to the project, forward slashes, with a trailing slash. */
16
+ function shown(projectDir, dir) {
17
+ return `${path.relative(projectDir, dir).split(path.sep).join("/")}/`;
18
+ }
19
+ /**
20
+ * The "Knowledge base" section appended to the system prompt: the layout and the working
21
+ * rules. The exact page templates live in the `knowledge-pages` skill, to keep this short.
22
+ */
23
+ export function knowledgePromptSection(projectDir, knowledgeDir, sourcesDir) {
24
+ const notes = shown(projectDir, knowledgeDir);
25
+ const originals = sourcesDir ? shown(projectDir, sourcesDir) : undefined;
26
+ return `## Knowledge base (${notes})
27
+ \`${notes}\` is your memory across sessions, kept as an interlinked knowledge base of markdown pages that you write and maintain yourself — a wiki, not a pile of notes. A future session only knows what is written there, so anything worth remembering must end up in a page, not just in this turn's reply. Write page content in the language you are using with the human; file names stay lowercase ASCII with hyphens.
28
+
29
+ ### Layers
30
+ ${originals ? `- **Originals (read-only for you)**: \`${originals}\`. Never rewrite one; build pages *about* them. Add a file there only with \`save_to_sources\`.\n` : ""}- **The knowledge base**: \`${notes}\`, entirely yours to write.
31
+ - **The schema**: these rules plus the \`knowledge-pages\` skill (exact template of every page type — load it before creating a page) and the \`knowledge-ingest\`, \`knowledge-query\` and \`knowledge-lint\` skills.
32
+
33
+ ### Layout
34
+ \`\`\`
35
+ ${notes}
36
+ index.md catalog of every page, one line each: read it FIRST, always
37
+ log.md append-only log of what you did to the knowledge base
38
+ overview.md living synthesis of the whole knowledge base
39
+ summaries/<slug>.md one page per ingested source
40
+ concepts/<slug>.md one page per idea
41
+ entities/<slug>.md one page per concrete thing (system, component, organization, document)
42
+ syntheses/<slug>.md answers worth keeping: comparisons, analyses, reports
43
+ \`\`\`
44
+
45
+ ### Working rules
46
+ - Start by reading \`index.md\`, then only the pages the task needs — don't read the whole knowledge base.
47
+ - Links are standard markdown links relative to the page, and go both ways: when A links to B, add the link back in B. To find who links to a page, \`Grep\` for its file name inside the knowledge base.
48
+ - Never rename, move or delete a page (every link to it would break): if it is superseded, say so at its top and link to the new one.
49
+ - Prefer \`Edit\` for changes to an existing page; \`Write\` only for new pages.
50
+ - After any change, update \`index.md\` and append \`## [YYYY-MM-DD] <operation> | <what>\` to \`log.md\`, followed by the pages created/updated. Operations: \`ingest\`, \`query\`, \`lint\`, \`update\`.
51
+ - Every claim must be traceable to a summary page, an original, or — clearly labelled as outside the knowledge base — the web.
52
+ - Contradictions are kept, not overwritten: record both versions with attribution in the affected pages.
53
+ - Short, focused, well-linked pages beat long ones: when a page mixes two things, split it.`;
54
+ }
@@ -0,0 +1,23 @@
1
+ import type { CanUseTool } from "@anthropic-ai/claude-agent-sdk";
2
+ /**
3
+ * Approves any MCP tool call ("mcp__<server>__<tool>"), regardless of which server it
4
+ * comes from, and denies everything else. This is what lets a project's own .mcp.json
5
+ * declare arbitrary additional MCP servers without the host agent's code needing to know
6
+ * their names ahead of time: a fixed allowedTools wildcard like "mcp__playwright__*" only
7
+ * matches that one named server — confirmed empirically that a bare "mcp__*" entry does
8
+ * NOT match "mcp__<server>__<tool>" the way a real per-server wildcard does — so a
9
+ * dynamic decision here is the only way to cover every MCP server generically.
10
+ *
11
+ * Dropping a .mcp.json inside a project is itself the user's opt-in (the same trust model
12
+ * already used for a custom skill dropped into .claude/skills/ — no separate confirmation
13
+ * step there either), so this approves silently rather than prompting. `disallowedTools`
14
+ * still takes full precedence over this — confirmed empirically that a disallowed tool
15
+ * never even reaches this callback, let alone executes.
16
+ *
17
+ * Every non-MCP tool the agent can use (Read/Write/Glob/WebFetch/WebSearch, ...) is
18
+ * normally granted via the `tools`/`allowedTools` arrays passed to `query()`, which
19
+ * pre-approves them before this callback would ever be consulted — so the "deny"
20
+ * fallback below is a defensive default for anything unexpected, not something normal
21
+ * operation should ever hit.
22
+ */
23
+ export declare const allowAnyMcpTool: CanUseTool;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The Agent SDK emits this process warning (via process.emitWarning, confirmed by
3
+ * grepping the installed package) whenever canUseTool is combined with a non-empty
4
+ * allowedTools/tools list, to flag that the bare entries in that list (Read/Write/Glob/
5
+ * WebFetch/WebSearch, typically) get auto-approved before canUseTool is ever consulted.
6
+ * That's expected and intentional for every caller of allowAnyMcpTool below (they all
7
+ * pair it with exactly that setup), so suppressing it here, once, covers every case
8
+ * rather than repeating a warning filter per call site. A plain "warning" event listener
9
+ * doesn't suppress Node's own default console output (confirmed empirically: Node prints
10
+ * the warning regardless of registered listeners), so this patches emitWarning itself
11
+ * instead, letting every other warning (e.g. a real deprecation notice) through
12
+ * unchanged.
13
+ */
14
+ const originalEmitWarning = process.emitWarning.bind(process);
15
+ process.emitWarning = ((warning, options, ...rest) => {
16
+ const code = typeof options === "object" && options !== null ? options.code : undefined;
17
+ if (code === "CLAUDE_SDK_CAN_USE_TOOL_SHADOWED")
18
+ return;
19
+ originalEmitWarning(warning, options, ...rest);
20
+ });
21
+ /**
22
+ * Approves any MCP tool call ("mcp__<server>__<tool>"), regardless of which server it
23
+ * comes from, and denies everything else. This is what lets a project's own .mcp.json
24
+ * declare arbitrary additional MCP servers without the host agent's code needing to know
25
+ * their names ahead of time: a fixed allowedTools wildcard like "mcp__playwright__*" only
26
+ * matches that one named server — confirmed empirically that a bare "mcp__*" entry does
27
+ * NOT match "mcp__<server>__<tool>" the way a real per-server wildcard does — so a
28
+ * dynamic decision here is the only way to cover every MCP server generically.
29
+ *
30
+ * Dropping a .mcp.json inside a project is itself the user's opt-in (the same trust model
31
+ * already used for a custom skill dropped into .claude/skills/ — no separate confirmation
32
+ * step there either), so this approves silently rather than prompting. `disallowedTools`
33
+ * still takes full precedence over this — confirmed empirically that a disallowed tool
34
+ * never even reaches this callback, let alone executes.
35
+ *
36
+ * Every non-MCP tool the agent can use (Read/Write/Glob/WebFetch/WebSearch, ...) is
37
+ * normally granted via the `tools`/`allowedTools` arrays passed to `query()`, which
38
+ * pre-approves them before this callback would ever be consulted — so the "deny"
39
+ * fallback below is a defensive default for anything unexpected, not something normal
40
+ * operation should ever hit.
41
+ */
42
+ export const allowAnyMcpTool = async (toolName, input) => {
43
+ if (toolName.startsWith("mcp__")) {
44
+ return { behavior: "allow", updatedInput: input };
45
+ }
46
+ return { behavior: "deny", message: `"${toolName}" isn't an allowed tool in this session.` };
47
+ };