itermbot 1.0.2 → 1.0.4

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 (128) hide show
  1. package/.github/workflows/ci.yml +15 -20
  2. package/.github/workflows/release.yml +32 -20
  3. package/README.md +11 -20
  4. package/cleanup-unused.patch +108 -0
  5. package/config/app.yaml +32 -13
  6. package/config/memory.yaml +38 -31
  7. package/config/model.yaml +33 -0
  8. package/config/skill.yaml +8 -0
  9. package/config/tool.yaml +50 -17
  10. package/config/tsconfig.json +4 -1
  11. package/dist/chat/builtin-commands.d.ts +8 -0
  12. package/dist/chat/builtin-commands.d.ts.map +1 -0
  13. package/dist/chat/builtin-commands.js +53 -0
  14. package/dist/chat/builtin-commands.js.map +1 -0
  15. package/dist/chat/progress.d.ts +3 -0
  16. package/dist/chat/progress.d.ts.map +1 -0
  17. package/dist/chat/progress.js +23 -0
  18. package/dist/chat/progress.js.map +1 -0
  19. package/dist/chat/response-safety.d.ts +8 -0
  20. package/dist/chat/response-safety.d.ts.map +1 -0
  21. package/dist/chat/response-safety.js +126 -0
  22. package/dist/chat/response-safety.js.map +1 -0
  23. package/dist/chat/step-display.d.ts +2 -0
  24. package/dist/chat/step-display.d.ts.map +1 -0
  25. package/dist/chat/step-display.js +50 -0
  26. package/dist/chat/step-display.js.map +1 -0
  27. package/dist/chat/tool-result.d.ts +4 -0
  28. package/dist/chat/tool-result.d.ts.map +1 -0
  29. package/dist/chat/tool-result.js +24 -0
  30. package/dist/chat/tool-result.js.map +1 -0
  31. package/dist/config.d.ts +11 -6
  32. package/dist/config.d.ts.map +1 -1
  33. package/dist/config.js +26 -12
  34. package/dist/config.js.map +1 -1
  35. package/dist/index.js +308 -151
  36. package/dist/index.js.map +1 -1
  37. package/dist/iterm/direct-command-router.d.ts +24 -0
  38. package/dist/iterm/direct-command-router.d.ts.map +1 -0
  39. package/dist/iterm/direct-command-router.js +213 -0
  40. package/dist/iterm/direct-command-router.js.map +1 -0
  41. package/dist/iterm/session-hint.d.ts +10 -0
  42. package/dist/iterm/session-hint.d.ts.map +1 -0
  43. package/dist/iterm/session-hint.js +43 -0
  44. package/dist/iterm/session-hint.js.map +1 -0
  45. package/dist/iterm/target-panel-policy.d.ts +12 -0
  46. package/dist/iterm/target-panel-policy.d.ts.map +1 -0
  47. package/dist/iterm/target-panel-policy.js +287 -0
  48. package/dist/iterm/target-panel-policy.js.map +1 -0
  49. package/dist/runtime/text-tool-call-recovery.d.ts +23 -0
  50. package/dist/runtime/text-tool-call-recovery.d.ts.map +1 -0
  51. package/dist/runtime/text-tool-call-recovery.js +211 -0
  52. package/dist/runtime/text-tool-call-recovery.js.map +1 -0
  53. package/dist/startup/colors.d.ts +37 -0
  54. package/dist/startup/colors.d.ts.map +1 -0
  55. package/dist/{startup-colors.js → startup/colors.js} +30 -15
  56. package/dist/startup/colors.js.map +1 -0
  57. package/dist/startup/diagnostics.d.ts +8 -0
  58. package/dist/startup/diagnostics.d.ts.map +1 -0
  59. package/dist/startup/diagnostics.js +18 -0
  60. package/dist/startup/diagnostics.js.map +1 -0
  61. package/dist/startup/os.d.ts +10 -0
  62. package/dist/startup/os.d.ts.map +1 -0
  63. package/dist/startup/os.js +67 -0
  64. package/dist/startup/os.js.map +1 -0
  65. package/dist/startup/ui.d.ts +11 -0
  66. package/dist/startup/ui.d.ts.map +1 -0
  67. package/dist/startup/ui.js +49 -0
  68. package/dist/startup/ui.js.map +1 -0
  69. package/package.json +23 -13
  70. package/scripts/internal-package-refs.mjs +158 -0
  71. package/scripts/patch-buildin-cache.sh +1 -4
  72. package/scripts/resolve-deps.js +5 -0
  73. package/scripts/test-llm.mjs +11 -5
  74. package/skills/gpu-ssh-monitor/SKILL.md +22 -3
  75. package/src/chat/builtin-commands.ts +70 -0
  76. package/src/chat/progress.ts +26 -0
  77. package/src/chat/response-safety.ts +134 -0
  78. package/src/chat/step-display.ts +54 -0
  79. package/src/chat/tool-result.ts +22 -0
  80. package/src/config.ts +48 -21
  81. package/src/index.ts +377 -167
  82. package/src/iterm/direct-command-router.ts +274 -0
  83. package/src/iterm/session-hint.ts +49 -0
  84. package/src/iterm/target-panel-policy.ts +341 -0
  85. package/src/runtime/text-tool-call-recovery.ts +257 -0
  86. package/src/{startup-colors.ts → startup/colors.ts} +42 -27
  87. package/src/startup/diagnostics.ts +25 -0
  88. package/src/startup/os.ts +63 -0
  89. package/src/startup/ui.ts +56 -0
  90. package/src/types/marked-terminal.d.ts +3 -0
  91. package/test/builtin-commands.test.mjs +50 -0
  92. package/test/chat-flow.integration.test.mjs +235 -0
  93. package/test/chat-progress.test.mjs +83 -0
  94. package/test/config.test.mjs +22 -0
  95. package/test/diagnostics.test.mjs +45 -0
  96. package/test/direct-command-router.test.mjs +149 -0
  97. package/test/live-iterm-llm.integration.test.mjs +153 -0
  98. package/test/response-safety.test.mjs +44 -0
  99. package/test/session-hint.test.mjs +78 -0
  100. package/test/startup-colors.test.mjs +145 -0
  101. package/test/target-panel-policy.test.mjs +180 -0
  102. package/test/tool-call-recovery.test.mjs +199 -0
  103. package/config/agent.yaml +0 -121
  104. package/config/models.yaml +0 -36
  105. package/config/skills.yaml +0 -4
  106. package/dist/agent.d.ts +0 -14
  107. package/dist/agent.d.ts.map +0 -1
  108. package/dist/agent.js +0 -16
  109. package/dist/agent.js.map +0 -1
  110. package/dist/context.d.ts +0 -12
  111. package/dist/context.d.ts.map +0 -1
  112. package/dist/context.js +0 -20
  113. package/dist/context.js.map +0 -1
  114. package/dist/session-hint.d.ts +0 -4
  115. package/dist/session-hint.d.ts.map +0 -1
  116. package/dist/session-hint.js +0 -25
  117. package/dist/session-hint.js.map +0 -1
  118. package/dist/startup-colors.d.ts +0 -26
  119. package/dist/startup-colors.d.ts.map +0 -1
  120. package/dist/startup-colors.js.map +0 -1
  121. package/dist/target-routing.d.ts +0 -15
  122. package/dist/target-routing.d.ts.map +0 -1
  123. package/dist/target-routing.js +0 -355
  124. package/dist/target-routing.js.map +0 -1
  125. package/src/agent.ts +0 -35
  126. package/src/context.ts +0 -35
  127. package/src/session-hint.ts +0 -28
  128. package/src/target-routing.ts +0 -419
@@ -0,0 +1,274 @@
1
+ import {
2
+ normalizeToolList,
3
+ } from "@easynet/agent-common/utils";
4
+ import { AgentContextTokens } from "@easynet/agent-common/context";
5
+ import { normalizeToolInvokeResult, stringifyToolResult } from "../chat/tool-result.js";
6
+
7
+ type RuntimeLike = {
8
+ context: {
9
+ get<T>(token: unknown): T;
10
+ };
11
+ };
12
+
13
+ type ToolLike = {
14
+ name: string;
15
+ invoke: (args: unknown) => Promise<unknown>;
16
+ };
17
+
18
+ export interface DirectTargetCommand {
19
+ command: string;
20
+ reason: string;
21
+ }
22
+
23
+ export interface DirectTargetHint {
24
+ windowId?: number;
25
+ tabIndex?: number;
26
+ sessionId?: string;
27
+ }
28
+
29
+ export interface DirectCommandIntentRule {
30
+ pattern?: string;
31
+ command?: string;
32
+ reason?: string;
33
+ }
34
+ const runtimeOutputCache = new WeakMap<object, string>();
35
+
36
+ function getCachedRawOutput(runtime: RuntimeLike): string {
37
+ return runtimeOutputCache.get(runtime as unknown as object) ?? "";
38
+ }
39
+
40
+ function setCachedRawOutput(runtime: RuntimeLike, output: string): void {
41
+ runtimeOutputCache.set(runtime as unknown as object, output);
42
+ }
43
+
44
+ export function setDirectCommandIntentRules(_rules: DirectCommandIntentRule[] | null | undefined): void {}
45
+
46
+ function isToolLike(tool: unknown): tool is ToolLike {
47
+ return Boolean(
48
+ tool
49
+ && typeof tool === "object"
50
+ && typeof (tool as { name?: unknown }).name === "string"
51
+ && typeof (tool as { invoke?: unknown }).invoke === "function",
52
+ );
53
+ }
54
+
55
+ function findItermRunCommandTool(runtime: RuntimeLike): ToolLike | null {
56
+ const toolsRaw = runtime.context.get<unknown>(AgentContextTokens.Tools);
57
+ const tools = normalizeToolList<ToolLike>(toolsRaw, isToolLike);
58
+ const found = tools.find((tool) => tool.name.includes("itermRunCommandInSession"));
59
+ return found ?? null;
60
+ }
61
+
62
+ function isLikelyShellCommand(input: string): boolean {
63
+ const trimmed = input.trim();
64
+ if (!trimmed) return false;
65
+ if (trimmed.includes("\n")) return false;
66
+ if (trimmed.length > 220) return false;
67
+ if (/[?.!。,!?]$/.test(trimmed)) return false;
68
+ if (/[|&;<>$`]/.test(trimmed)) return true;
69
+ if (/^(\.\/|\/|~\/)/.test(trimmed)) return true;
70
+
71
+ const parts = trimmed.split(/\s+/);
72
+ if (parts.length === 1) {
73
+ const token = parts[0] ?? "";
74
+ if (!/^[A-Za-z0-9_./-]+$/.test(token)) return false;
75
+ return token.includes("/");
76
+ }
77
+
78
+ const hasFlag = parts.some((part) => part.startsWith("-"));
79
+ const hasPathLike = parts.some((part) => /[/.]/.test(part));
80
+ const hasAssign = parts.some((part) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(part));
81
+ const hasQuote = /['"]/.test(trimmed);
82
+ return hasFlag || hasPathLike || hasAssign || hasQuote;
83
+ }
84
+
85
+ function hasDefiniteShellSyntax(input: string): boolean {
86
+ const trimmed = input.trim();
87
+ if (!trimmed) return false;
88
+ if (/[|&;<>$`]/.test(trimmed)) return true;
89
+ if (/^(\.\/|\/|~\/)/.test(trimmed)) return true;
90
+ const parts = trimmed.split(/\s+/);
91
+ if (parts.length <= 1) return false;
92
+ const hasFlag = parts.some((part) => part.startsWith("-"));
93
+ const hasPathLike = parts.some((part) => /[/.]/.test(part));
94
+ const hasAssign = parts.some((part) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(part));
95
+ const hasQuote = /['"]/.test(trimmed);
96
+ return hasFlag || hasPathLike || hasAssign || hasQuote;
97
+ }
98
+
99
+ export function detectDirectTargetPanelCommand(input: string): DirectTargetCommand | null {
100
+ const trimmed = input.trim();
101
+ if (!trimmed) return null;
102
+ if (isLikelyShellCommand(trimmed)) {
103
+ return {
104
+ command: trimmed,
105
+ reason: "raw_shell_command",
106
+ };
107
+ }
108
+
109
+ return null;
110
+ }
111
+
112
+ function extractToolOutput(output: unknown): {
113
+ outputText: string;
114
+ readError: string | null;
115
+ } {
116
+ const stripMarkers = (text: string): string =>
117
+ text
118
+ .split("\n")
119
+ .filter((line) => !/__ITB_(BEGIN|END)_[A-Za-z0-9_]+/.test(line))
120
+ .join("\n")
121
+ .trim();
122
+
123
+ const normalized = output;
124
+ const result =
125
+ normalized && typeof normalized === "object" && "result" in normalized
126
+ ? (normalized as { result?: unknown }).result
127
+ : normalized;
128
+
129
+ if (result && typeof result === "object") {
130
+ const record = result as Record<string, unknown>;
131
+ if (typeof record.output === "string" && record.output.trim()) {
132
+ return { outputText: stripMarkers(record.output), readError: null };
133
+ }
134
+ if (typeof record.readError === "string" && record.readError.trim()) {
135
+ return { outputText: "", readError: record.readError.trim() };
136
+ }
137
+ }
138
+
139
+ try {
140
+ return { outputText: stringifyToolResult(result ?? normalized), readError: null };
141
+ } catch {
142
+ return { outputText: String(result ?? normalized), readError: null };
143
+ }
144
+ }
145
+
146
+ function buildExecutionSummary(command: string, outputText: string): string {
147
+ const lines = outputText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
148
+ const totalLines = lines.length;
149
+ const preview = lines.slice(0, 3);
150
+ const singleLineValue = totalLines === 1 ? lines[0] ?? "" : "";
151
+ return [
152
+ "### Command Result",
153
+ `- Command: \`${command}\``,
154
+ `- Captured lines: **${totalLines}**`,
155
+ ...(singleLineValue ? [`- Value: \`${singleLineValue}\``] : []),
156
+ ...(preview.length > 0
157
+ ? [
158
+ "- Preview:",
159
+ ...preview.map((line) => `- \`${line}\``),
160
+ ]
161
+ : []),
162
+ "- Raw output: hidden by default",
163
+ "",
164
+ "_Use `show raw output` if you want the full captured output._",
165
+ ].join("\n");
166
+ }
167
+
168
+ function formatDirectCommandResult(args: {
169
+ command: string;
170
+ outputText: string;
171
+ readError: string | null;
172
+ }): string {
173
+ if (args.readError) {
174
+ return [
175
+ "### Command Error",
176
+ `- Command: \`${args.command}\``,
177
+ `- Error: ${args.readError}`,
178
+ ].join("\n");
179
+ }
180
+ if (!args.outputText.trim()) {
181
+ return [
182
+ "### Command Result",
183
+ `- Command: \`${args.command}\``,
184
+ "- Output: _(empty)_",
185
+ ].join("\n");
186
+ }
187
+ return buildExecutionSummary(args.command, args.outputText);
188
+ }
189
+
190
+ async function finalizeDirectCommandResult(args: {
191
+ command: string;
192
+ outputText: string;
193
+ readError: string | null;
194
+ }): Promise<string> {
195
+ return formatDirectCommandResult({
196
+ command: args.command,
197
+ outputText: args.outputText,
198
+ readError: args.readError,
199
+ });
200
+ }
201
+
202
+ function truncateOutputLines(text: string, maxLines = 300): { text: string; truncated: boolean } {
203
+ const lines = text.split("\n");
204
+ if (lines.length <= maxLines) return { text, truncated: false };
205
+ return { text: lines.slice(0, maxLines).join("\n"), truncated: true };
206
+ }
207
+
208
+ function formatRawOutputMarkdown(rawText: string): string {
209
+ const clipped = truncateOutputLines(rawText, 300);
210
+ return [
211
+ "### Raw Output",
212
+ "```text",
213
+ clipped.text,
214
+ "```",
215
+ ...(clipped.truncated ? ["", "_Output truncated to 300 lines._"] : []),
216
+ ].join("\n");
217
+ }
218
+
219
+ async function runAndFormatDirectCommand(args: {
220
+ tool: ToolLike;
221
+ command: string;
222
+ input: string;
223
+ runtime: RuntimeLike;
224
+ targetHint?: DirectTargetHint;
225
+ }): Promise<string> {
226
+ const out = normalizeToolInvokeResult(await args.tool.invoke({
227
+ command: args.command,
228
+ maxOutputLines: 300,
229
+ ...(args.targetHint?.windowId != null ? { windowId: args.targetHint.windowId } : {}),
230
+ ...(args.targetHint?.tabIndex != null ? { tabIndex: args.targetHint.tabIndex } : {}),
231
+ ...(args.targetHint?.sessionId ? { sessionId: args.targetHint.sessionId } : {}),
232
+ }));
233
+ const extracted = extractToolOutput(out);
234
+ setCachedRawOutput(args.runtime, extracted.outputText);
235
+ const formatted = await finalizeDirectCommandResult({
236
+ command: args.command,
237
+ outputText: extracted.outputText,
238
+ readError: extracted.readError,
239
+ });
240
+ return formatted;
241
+ }
242
+
243
+ function resolveDirectCommand(directIntent: DirectTargetCommand | null): string {
244
+ if (directIntent?.command) return directIntent.command;
245
+ return "";
246
+ }
247
+
248
+ function shouldBypassWithNoCommand(command: string): boolean {
249
+ return !command.trim();
250
+ }
251
+
252
+ export async function tryHandleDirectTargetPanelCommand(
253
+ input: string,
254
+ runtime: RuntimeLike,
255
+ targetHint?: DirectTargetHint,
256
+ ): Promise<string | null> {
257
+ const trimmed = input.trim();
258
+ if (!trimmed) return null;
259
+ const directIntent = detectDirectTargetPanelCommand(trimmed);
260
+ if (!hasDefiniteShellSyntax(trimmed)) return null;
261
+ const tool = findItermRunCommandTool(runtime);
262
+ if (!tool) return null;
263
+
264
+ const command = resolveDirectCommand(directIntent);
265
+ if (shouldBypassWithNoCommand(command)) return null;
266
+
267
+ return runAndFormatDirectCommand({
268
+ tool,
269
+ command,
270
+ input: trimmed,
271
+ runtime,
272
+ targetHint,
273
+ });
274
+ }
@@ -0,0 +1,49 @@
1
+ import type { StartupResult } from "../startup/colors.js";
2
+ import type { PromptTemplates } from "../config.js";
3
+
4
+ function buildTargetSessionSection(startup: StartupResult): string {
5
+ if (!startup.targetSessionId) return "";
6
+ return [
7
+ "## Target Panel Session",
8
+ "Routed target panel (may change at runtime):",
9
+ `- sessionId: "${startup.targetSessionId}"`,
10
+ `- windowId: ${String(startup.windowId)}`,
11
+ `- tabIndex: ${String(startup.tabIndex)}`,
12
+ "Never use `windowId: 0` and never guess IDs.",
13
+ ].join("\n");
14
+ }
15
+
16
+ function buildTargetOsSection(targetOs: string): string {
17
+ if (!targetOs) return "";
18
+ return [
19
+ "## Target System OS",
20
+ `- os: ${targetOs}`,
21
+ "- Commands in plans must be compatible with this OS unless tool evidence suggests otherwise.",
22
+ ].join("\n");
23
+ }
24
+
25
+ /**
26
+ * Build the full system prompt by prepending iTerm policy and session info
27
+ * to an optional base prompt. Called before createReactAgentRuntime().
28
+ */
29
+ export function buildSystemPrompt(
30
+ templates: PromptTemplates | undefined,
31
+ startup: StartupResult,
32
+ basePrompt = "",
33
+ options?: { targetOs?: string },
34
+ ): string {
35
+ const template = templates?.systemPrompt?.trim() ?? "";
36
+ const targetOs = options?.targetOs?.trim() ?? "";
37
+ const rendered = template
38
+ .replaceAll("{{targetSessionSection}}", buildTargetSessionSection(startup))
39
+ .replaceAll("{{targetOsSection}}", buildTargetOsSection(targetOs))
40
+ .trim();
41
+
42
+ const parts: string[] = [];
43
+ if (rendered) parts.push(rendered);
44
+ if (basePrompt.trim()) parts.push(basePrompt.trim());
45
+ return parts
46
+ .join("\n\n")
47
+ .replace(/\n{3,}/g, "\n\n")
48
+ .trim();
49
+ }
@@ -0,0 +1,341 @@
1
+ const BLOCKED_SHORT_NAMES = new Set([
2
+ "listDir",
3
+ "readText",
4
+ "writeText",
5
+ "runCommand",
6
+ "gitRead",
7
+ "gitAdd",
8
+ "gitCommit",
9
+ "gitDiff",
10
+ "gitPull",
11
+ "gitPush",
12
+ "gitSwitchBranch",
13
+ "gitLogHistory",
14
+ ]);
15
+
16
+ type ToolLike = {
17
+ name: string;
18
+ invoke: (args: unknown) => Promise<unknown>;
19
+ };
20
+
21
+ type ToolContainer = unknown[] | { tools?: unknown[] };
22
+ type CommandMapping = {
23
+ command: string;
24
+ maxOutputLines?: number;
25
+ meta?: Record<string, unknown>;
26
+ };
27
+
28
+ type RedirectedDirEntry = {
29
+ name: string;
30
+ kind: "file" | "directory" | "symlink" | "other";
31
+ size: number;
32
+ mtime: string;
33
+ };
34
+
35
+ export interface TargetPanelHint {
36
+ windowId?: number;
37
+ tabIndex?: number;
38
+ sessionId?: string;
39
+ }
40
+
41
+ let targetPanelHint: TargetPanelHint | null = null;
42
+
43
+ export function setTargetPanelHint(hint: TargetPanelHint | null): void {
44
+ targetPanelHint = hint;
45
+ }
46
+
47
+ function shortName(name: string): string {
48
+ const parts = name.split(".");
49
+ return parts[parts.length - 1] ?? name;
50
+ }
51
+
52
+ function shouldBlockTool(name: string): boolean {
53
+ if (name.includes("itermRunCommandInSession")) return false;
54
+ if (name.includes("iterm")) return true;
55
+ return BLOCKED_SHORT_NAMES.has(shortName(name));
56
+ }
57
+
58
+ function policyError(toolName: string): Error {
59
+ return new Error(
60
+ `Tool "${toolName}" is blocked in iTermBot policy. `
61
+ + "Use itermRunCommandInSession on target panel, then analyze returned output.",
62
+ );
63
+ }
64
+
65
+ function asRecord(input: unknown): Record<string, unknown> {
66
+ if (!input || typeof input !== "object" || Array.isArray(input)) return {};
67
+ return input as Record<string, unknown>;
68
+ }
69
+
70
+ function quoteForBash(value: string): string {
71
+ return `'${value.replaceAll("'", `'\\''`)}'`;
72
+ }
73
+
74
+ function asBoolean(value: unknown, fallback: boolean): boolean {
75
+ return typeof value === "boolean" ? value : fallback;
76
+ }
77
+
78
+ function asBoundedInt(value: unknown, fallback: number, min: number, max: number): number {
79
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
80
+ const normalized = Math.floor(value);
81
+ return Math.max(min, Math.min(max, normalized));
82
+ }
83
+
84
+ function buildListDirCommand(args: Record<string, unknown>): CommandMapping {
85
+ const path = typeof args.path === "string" && args.path.trim() ? args.path.trim() : ".";
86
+ const recursive = asBoolean(args.recursive, false);
87
+ const includeHidden = asBoolean(args.includeHidden, false);
88
+ const maxDepth = asBoundedInt(args.maxDepth, 3, 1, 20);
89
+ const maxEntries = asBoundedInt(args.maxEntries, 200, 1, 2000);
90
+ const quotedPath = quoteForBash(path);
91
+ return {
92
+ command: recursive
93
+ ? `find ${quotedPath} -maxdepth ${maxDepth} -mindepth 1 -print`
94
+ : includeHidden
95
+ ? `ls -1A ${quotedPath}`
96
+ : `ls -1 ${quotedPath}`,
97
+ maxOutputLines: Math.min(maxEntries + 50, 3000),
98
+ meta: { requestedPath: path },
99
+ };
100
+ }
101
+
102
+ function buildReadTextCommand(args: Record<string, unknown>): { command: string; maxOutputLines: number } | null {
103
+ const path = typeof args.path === "string" ? args.path.trim() : "";
104
+ if (!path) return null;
105
+ const maxBytes = asBoundedInt(args.maxBytes, 24_000, 256, 200_000);
106
+ const maxLines = asBoundedInt(Math.floor(maxBytes / 120), 200, 20, 2000);
107
+ return {
108
+ command: `sed -n '1,${maxLines}p' ${quoteForBash(path)}`,
109
+ maxOutputLines: Math.min(maxLines + 40, 2500),
110
+ };
111
+ }
112
+
113
+ function buildRunCommandCommand(args: Record<string, unknown>): { command: string } | null {
114
+ if (typeof args.command === "string" && args.command.trim()) return { command: args.command.trim() };
115
+ if (typeof args.cmd === "string" && args.cmd.trim()) return { command: args.cmd.trim() };
116
+ if (Array.isArray(args.cmdArray) && args.cmdArray.length > 0) {
117
+ const joined = args.cmdArray.map((part) => String(part)).join(" ").trim();
118
+ if (joined) return { command: joined };
119
+ }
120
+ return null;
121
+ }
122
+
123
+ function toItermCommandArgs(toolName: string, args: unknown): CommandMapping | null {
124
+ const tool = shortName(toolName);
125
+ const record = asRecord(args);
126
+ if (tool === "listDir") return buildListDirCommand(record);
127
+ if (tool === "readText") return buildReadTextCommand(record);
128
+ if (tool === "runCommand") return buildRunCommandCommand(record);
129
+ if (tool === "itermListWindows") return { command: "pwd" };
130
+ if (tool === "itermListCurrentWindowSessions") return { command: "pwd" };
131
+ if (tool === "itermGetSessionInfo") return { command: "pwd" };
132
+ return null;
133
+ }
134
+
135
+ function isRedirectableTool(toolName: string): boolean {
136
+ return (
137
+ toolName === "listDir"
138
+ || toolName === "readText"
139
+ || toolName === "runCommand"
140
+ || toolName === "itermListWindows"
141
+ || toolName === "itermListCurrentWindowSessions"
142
+ || toolName === "itermGetSessionInfo"
143
+ );
144
+ }
145
+
146
+ function isToolLike(tool: unknown): tool is ToolLike {
147
+ return Boolean(
148
+ tool
149
+ && typeof tool === "object"
150
+ && typeof (tool as { name?: unknown }).name === "string"
151
+ && typeof (tool as { invoke?: unknown }).invoke === "function",
152
+ );
153
+ }
154
+
155
+ function findItermCommandTool(tools: unknown[]): ToolLike | null {
156
+ const tool = tools.find((item) => isToolLike(item) && item.name.includes("itermRunCommandInSession"));
157
+ return isToolLike(tool) ? tool : null;
158
+ }
159
+
160
+ function getOutputText(output: unknown): string {
161
+ const root = asRecord(output);
162
+ const result = asRecord(root?.result);
163
+ const text = result?.output;
164
+ return typeof text === "string" ? text : "";
165
+ }
166
+
167
+ function extractCommandResultMeta(output: unknown): {
168
+ outputText?: string;
169
+ commandCompleted?: boolean;
170
+ exitCode?: number;
171
+ readError?: string;
172
+ sessionId?: string;
173
+ } {
174
+ const root = asRecord(output);
175
+ const result = asRecord(root?.result);
176
+ return {
177
+ ...(typeof result.output === "string" ? { outputText: result.output } : {}),
178
+ ...(typeof result.commandCompleted === "boolean" ? { commandCompleted: result.commandCompleted } : {}),
179
+ ...(typeof result.exitCode === "number" ? { exitCode: result.exitCode } : {}),
180
+ ...(typeof result.readError === "string" ? { readError: result.readError } : {}),
181
+ ...(typeof result.sessionId === "string" ? { sessionId: result.sessionId } : {}),
182
+ };
183
+ }
184
+
185
+ function parseListDirResolutionMeta(rawOutput: unknown, requestedPath: string): {
186
+ requestedPath: string;
187
+ resolvedPath: string;
188
+ pathFallbackUsed: boolean;
189
+ } {
190
+ return {
191
+ requestedPath,
192
+ resolvedPath: requestedPath,
193
+ pathFallbackUsed: false,
194
+ };
195
+ }
196
+
197
+ function normalizeListEntryName(line: string, resolvedPath: string): string {
198
+ const trimmed = line.trim();
199
+ if (!trimmed) return "";
200
+ if (trimmed.startsWith("./")) return trimmed.slice(2);
201
+ if (resolvedPath !== "." && trimmed.startsWith(`${resolvedPath}/`)) {
202
+ return trimmed.slice(resolvedPath.length + 1);
203
+ }
204
+ if (trimmed.startsWith("/")) return trimmed.split("/").filter(Boolean).slice(-1)[0] ?? trimmed;
205
+ return trimmed;
206
+ }
207
+
208
+ function parseListDirEntries(rawOutput: unknown, resolvedPath: string): RedirectedDirEntry[] {
209
+ const output = getOutputText(rawOutput);
210
+ return output
211
+ .split("\n")
212
+ .map((line) => line.trim())
213
+ .filter((line) => line.length > 0)
214
+ .map((line) => normalizeListEntryName(line, resolvedPath))
215
+ .filter(Boolean)
216
+ .map((name) => ({
217
+ name,
218
+ kind: "other" as const,
219
+ size: 0,
220
+ mtime: "",
221
+ }));
222
+ }
223
+
224
+ async function invokeRedirect(blockedName: string, blockedArgs: unknown, commandTool: ToolLike | null): Promise<unknown> {
225
+ const mapped = toItermCommandArgs(blockedName, blockedArgs);
226
+ if (!mapped || !commandTool || typeof commandTool.invoke !== "function") {
227
+ throw policyError(blockedName);
228
+ }
229
+ const hint = targetPanelHint;
230
+ const invokeArgs = {
231
+ command: mapped.command,
232
+ ...(typeof mapped.maxOutputLines === "number" ? { maxOutputLines: mapped.maxOutputLines } : {}),
233
+ ...(hint?.windowId != null ? { windowId: hint.windowId } : {}),
234
+ ...(hint?.tabIndex != null ? { tabIndex: hint.tabIndex } : {}),
235
+ ...(hint?.sessionId ? { sessionId: hint.sessionId } : {}),
236
+ };
237
+ const output = await commandTool.invoke(invokeArgs);
238
+ const blockedToolShortName = shortName(blockedName);
239
+ const redirectMeta =
240
+ blockedToolShortName === "listDir"
241
+ ? parseListDirResolutionMeta(
242
+ output,
243
+ typeof mapped.meta?.requestedPath === "string" ? mapped.meta.requestedPath : ".",
244
+ )
245
+ : {};
246
+ const commandResultMeta = extractCommandResultMeta(output);
247
+ const resolvedListPath =
248
+ blockedToolShortName === "listDir" && typeof (redirectMeta as { resolvedPath?: unknown }).resolvedPath === "string"
249
+ ? (redirectMeta as { resolvedPath: string }).resolvedPath
250
+ : ".";
251
+ const listDirEntries =
252
+ blockedToolShortName === "listDir"
253
+ ? parseListDirEntries(output, resolvedListPath)
254
+ : [];
255
+ return {
256
+ result: {
257
+ blockedTool: blockedName,
258
+ redirectedTool: commandTool.name,
259
+ redirected: true,
260
+ originalArgs: asRecord(blockedArgs),
261
+ mappedCommand: mapped.command,
262
+ ...redirectMeta,
263
+ ...commandResultMeta,
264
+ ...(blockedToolShortName === "listDir"
265
+ ? {
266
+ path: resolvedListPath,
267
+ entries: listDirEntries,
268
+ totalEntries: listDirEntries.length,
269
+ truncated: false,
270
+ }
271
+ : {}),
272
+ targetHint: hint ?? undefined,
273
+ output,
274
+ },
275
+ evidence: [
276
+ {
277
+ type: "policy",
278
+ ref: "target-panel-policy",
279
+ summary: `Redirected ${blockedName} to ${String(commandTool.name)} with target-panel command execution`,
280
+ createdAt: new Date().toISOString(),
281
+ },
282
+ ],
283
+ };
284
+ }
285
+
286
+ function blockedResult(toolName: string): unknown {
287
+ return {
288
+ result: {
289
+ blocked: true,
290
+ blockedTool: toolName,
291
+ requiredTool: "itermRunCommandInSession",
292
+ message:
293
+ `Tool "${toolName}" is blocked in iTermBot policy. `
294
+ + "Use itermRunCommandInSession on target panel, then analyze returned output.",
295
+ suggestedCommand: "pwd",
296
+ },
297
+ evidence: [
298
+ {
299
+ type: "policy",
300
+ ref: "target-panel-policy",
301
+ summary: `Blocked ${toolName}; returned policy guidance for target-panel command execution`,
302
+ createdAt: new Date().toISOString(),
303
+ },
304
+ ],
305
+ };
306
+ }
307
+
308
+ function normalizeTools(input: ToolContainer): unknown[] {
309
+ if (Array.isArray(input)) return input;
310
+ if (input && typeof input === "object" && Array.isArray((input as { tools?: unknown[] }).tools)) {
311
+ return (input as { tools: unknown[] }).tools;
312
+ }
313
+ return [];
314
+ }
315
+
316
+ export function enforceTargetPanelExecutionPolicy(tools: ToolContainer): () => void {
317
+ const unpatchFns: Array<() => void> = [];
318
+ const allTools = normalizeTools(tools);
319
+ const itermCommandTool = findItermCommandTool(allTools);
320
+
321
+ for (const tool of allTools) {
322
+ if (!isToolLike(tool)) continue;
323
+ if (!shouldBlockTool(tool.name)) continue;
324
+
325
+ const originalInvoke = tool.invoke.bind(tool);
326
+ tool.invoke = async (args: unknown): Promise<unknown> => {
327
+ const toolShortName = shortName(tool.name);
328
+ if (isRedirectableTool(toolShortName)) {
329
+ return invokeRedirect(tool.name, args, itermCommandTool);
330
+ }
331
+ return blockedResult(tool.name);
332
+ };
333
+ unpatchFns.push(() => {
334
+ tool.invoke = originalInvoke;
335
+ });
336
+ }
337
+
338
+ return () => {
339
+ for (const unpatch of unpatchFns) unpatch();
340
+ };
341
+ }