gsd-pi 2.10.2 → 2.10.5
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/README.md +2 -0
- package/dist/cli.js +7 -0
- package/dist/loader.js +1 -0
- package/dist/onboarding.js +104 -59
- package/dist/update-cmd.d.ts +1 -0
- package/dist/update-cmd.js +40 -0
- package/node_modules/@gsd/native/dist/hasher/index.d.ts +32 -0
- package/node_modules/@gsd/native/dist/hasher/index.js +37 -0
- package/node_modules/@gsd/native/dist/native.d.ts +4 -1
- package/node_modules/@gsd/native/dist/native.js +39 -9
- package/node_modules/@gsd/native/dist/xxhash/index.d.ts +14 -0
- package/node_modules/@gsd/native/dist/xxhash/index.js +17 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.d.ts +6 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.js +58 -9
- package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.js.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.d.ts +72 -12
- package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.d.ts.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.js +254 -43
- package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.js.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.d.ts +2 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.d.ts.map +1 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.js +159 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.js.map +1 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.d.ts +4 -2
- package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.js +6 -4
- package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.js.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.d.ts +15 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.js +12 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts +40 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts.map +1 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.js +92 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.js.map +1 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts +2 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts.map +1 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.js +156 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.js.map +1 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.d.ts +8 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.js +18 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.d.ts +1 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.js +1 -0
- package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.js.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/index.d.ts +2 -2
- package/node_modules/@gsd/pi-coding-agent/dist/index.d.ts.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/index.js +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/index.js.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
- package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/node_modules/@gsd/pi-coding-agent/src/core/agent-session.ts +65 -9
- package/node_modules/@gsd/pi-coding-agent/src/core/auth-storage.test.ts +194 -0
- package/node_modules/@gsd/pi-coding-agent/src/core/auth-storage.ts +283 -53
- package/node_modules/@gsd/pi-coding-agent/src/core/model-registry.ts +6 -4
- package/node_modules/@gsd/pi-coding-agent/src/core/settings-manager.ts +29 -0
- package/node_modules/@gsd/pi-coding-agent/src/core/tools/bash-interceptor.test.ts +198 -0
- package/node_modules/@gsd/pi-coding-agent/src/core/tools/bash-interceptor.ts +115 -0
- package/node_modules/@gsd/pi-coding-agent/src/core/tools/bash.ts +29 -0
- package/node_modules/@gsd/pi-coding-agent/src/core/tools/index.ts +8 -0
- package/node_modules/@gsd/pi-coding-agent/src/index.ts +6 -0
- package/node_modules/@gsd/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
- package/package.json +8 -2
- package/packages/native/dist/hasher/index.d.ts +32 -0
- package/packages/native/dist/hasher/index.js +37 -0
- package/packages/native/dist/native.d.ts +4 -1
- package/packages/native/dist/native.js +39 -9
- package/packages/native/dist/xxhash/index.d.ts +14 -0
- package/packages/native/dist/xxhash/index.js +17 -0
- package/packages/native/src/native.ts +39 -9
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts +6 -0
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.js +58 -9
- package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/auth-storage.d.ts +72 -12
- package/packages/pi-coding-agent/dist/core/auth-storage.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/auth-storage.js +254 -43
- package/packages/pi-coding-agent/dist/core/auth-storage.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/auth-storage.test.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/auth-storage.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/core/auth-storage.test.js +159 -0
- package/packages/pi-coding-agent/dist/core/auth-storage.test.js.map +1 -0
- package/packages/pi-coding-agent/dist/core/model-registry.d.ts +4 -2
- package/packages/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-registry.js +6 -4
- package/packages/pi-coding-agent/dist/core/model-registry.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +15 -0
- package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/settings-manager.js +12 -0
- package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts +40 -0
- package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.js +92 -0
- package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.js.map +1 -0
- package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.js +156 -0
- package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.js.map +1 -0
- package/packages/pi-coding-agent/dist/core/tools/bash.d.ts +8 -0
- package/packages/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/tools/bash.js +18 -0
- package/packages/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/tools/index.d.ts +1 -0
- package/packages/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/tools/index.js +1 -0
- package/packages/pi-coding-agent/dist/core/tools/index.js.map +1 -1
- package/packages/pi-coding-agent/dist/index.d.ts +2 -2
- package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/index.js +1 -1
- package/packages/pi-coding-agent/dist/index.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/packages/pi-coding-agent/src/core/agent-session.ts +65 -9
- package/packages/pi-coding-agent/src/core/auth-storage.test.ts +194 -0
- package/packages/pi-coding-agent/src/core/auth-storage.ts +283 -53
- package/packages/pi-coding-agent/src/core/model-registry.ts +6 -4
- package/packages/pi-coding-agent/src/core/settings-manager.ts +29 -0
- package/packages/pi-coding-agent/src/core/tools/bash-interceptor.test.ts +198 -0
- package/packages/pi-coding-agent/src/core/tools/bash-interceptor.ts +115 -0
- package/packages/pi-coding-agent/src/core/tools/bash.ts +29 -0
- package/packages/pi-coding-agent/src/core/tools/index.ts +8 -0
- package/packages/pi-coding-agent/src/index.ts +6 -0
- package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
- package/src/resources/extensions/async-jobs/async-bash-tool.ts +211 -0
- package/src/resources/extensions/async-jobs/await-tool.ts +101 -0
- package/src/resources/extensions/async-jobs/cancel-job-tool.ts +34 -0
- package/src/resources/extensions/async-jobs/index.ts +133 -0
- package/src/resources/extensions/async-jobs/job-manager.ts +250 -0
- package/src/resources/extensions/gsd/git-service.ts +13 -3
- package/src/resources/extensions/gsd/prompts/system.md +5 -2
- package/src/resources/extensions/gsd/tests/git-service.test.ts +36 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
checkBashInterception,
|
|
5
|
+
compileInterceptor,
|
|
6
|
+
DEFAULT_BASH_INTERCEPTOR_RULES,
|
|
7
|
+
type BashInterceptorRule,
|
|
8
|
+
} from "./bash-interceptor.js";
|
|
9
|
+
|
|
10
|
+
const ALL_TOOLS = ["read", "grep", "find", "edit", "write"];
|
|
11
|
+
const NO_TOOLS: string[] = [];
|
|
12
|
+
|
|
13
|
+
describe("checkBashInterception", () => {
|
|
14
|
+
describe("read rule (cat/head/tail/less/more)", () => {
|
|
15
|
+
it("blocks cat with a file argument", () => {
|
|
16
|
+
const r = checkBashInterception("cat README.md", ALL_TOOLS);
|
|
17
|
+
assert.equal(r.block, true);
|
|
18
|
+
assert.equal(r.suggestedTool, "read");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("blocks head and tail", () => {
|
|
22
|
+
assert.equal(checkBashInterception("head -n 20 file.ts", ALL_TOOLS).block, true);
|
|
23
|
+
assert.equal(checkBashInterception("tail -f app.log", ALL_TOOLS).block, true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("does NOT block cat used as heredoc (cat <<EOF)", () => {
|
|
27
|
+
const r = checkBashInterception("cat <<EOF > file.txt", ALL_TOOLS);
|
|
28
|
+
assert.notEqual(r.suggestedTool, "read");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("does NOT block when read tool is absent", () => {
|
|
32
|
+
assert.equal(checkBashInterception("cat README.md", NO_TOOLS).block, false);
|
|
33
|
+
assert.equal(checkBashInterception("cat README.md", ["grep"]).block, false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("grep rule", () => {
|
|
38
|
+
it("blocks grep and rg", () => {
|
|
39
|
+
assert.equal(checkBashInterception("grep foo bar.ts", ALL_TOOLS).block, true);
|
|
40
|
+
assert.equal(checkBashInterception("rg -r pattern .", ALL_TOOLS).block, true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("blocks grep with leading whitespace", () => {
|
|
44
|
+
assert.equal(checkBashInterception(" grep -r foo .", ALL_TOOLS).block, true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("does NOT block when grep tool is absent", () => {
|
|
48
|
+
assert.equal(checkBashInterception("grep foo bar", ["read", "edit"]).block, false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("find rule", () => {
|
|
53
|
+
it("blocks find with -name flag", () => {
|
|
54
|
+
assert.equal(checkBashInterception('find . -name "*.ts"', ALL_TOOLS).block, true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("blocks find with -type flag", () => {
|
|
58
|
+
assert.equal(checkBashInterception("find /tmp -maxdepth 1 -type f", ALL_TOOLS).block, true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does NOT block find without name/type flags", () => {
|
|
62
|
+
assert.equal(checkBashInterception("find /tmp -maxdepth 1", ALL_TOOLS).block, false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("does NOT block when find tool is absent", () => {
|
|
66
|
+
assert.equal(checkBashInterception('find . -name "*.ts"', ["read", "grep"]).block, false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("edit rule (sed/perl/awk)", () => {
|
|
71
|
+
it("blocks sed -i", () => {
|
|
72
|
+
assert.equal(checkBashInterception("sed -i 's/foo/bar/' file.ts", ALL_TOOLS).block, true);
|
|
73
|
+
assert.equal(checkBashInterception("sed --in-place 's/x/y/' f", ALL_TOOLS).block, true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does NOT block sed without -i (read-only)", () => {
|
|
77
|
+
assert.equal(checkBashInterception("sed 's/foo/bar/' file.ts", ALL_TOOLS).block, false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("blocks perl -pi and perl -p -i", () => {
|
|
81
|
+
assert.equal(checkBashInterception("perl -pi -e 's/foo/bar/' file", ALL_TOOLS).block, true);
|
|
82
|
+
assert.equal(checkBashInterception("perl -p -i -e 's/x/y/' f", ALL_TOOLS).block, true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("blocks awk -i inplace", () => {
|
|
86
|
+
assert.equal(checkBashInterception("awk -i inplace '{print}' file", ALL_TOOLS).block, true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("does NOT block when edit tool is absent", () => {
|
|
90
|
+
assert.equal(checkBashInterception("sed -i 's/a/b/' f", ["read", "grep"]).block, false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("write rule (echo/printf/heredoc redirect)", () => {
|
|
95
|
+
it("blocks echo with > redirect", () => {
|
|
96
|
+
assert.equal(checkBashInterception("echo hello > file.txt", ALL_TOOLS).block, true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("blocks printf with > redirect", () => {
|
|
100
|
+
assert.equal(checkBashInterception('printf "%s" content > out.txt', ALL_TOOLS).block, true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("does NOT block echo without redirect", () => {
|
|
104
|
+
assert.equal(checkBashInterception("echo hello", ALL_TOOLS).block, false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("does NOT block >> append redirect (write tool does not support appending)", () => {
|
|
108
|
+
assert.equal(checkBashInterception("echo hello >> file.txt", ALL_TOOLS).block, false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does NOT block stderr redirect (2>)", () => {
|
|
112
|
+
assert.equal(checkBashInterception("echo test 2> /dev/null", ALL_TOOLS).block, false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("does NOT block pipe (echo foo | grep bar)", () => {
|
|
116
|
+
assert.equal(checkBashInterception("echo foo | grep bar", ALL_TOOLS).block, false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("does NOT block when write tool is absent", () => {
|
|
120
|
+
assert.equal(checkBashInterception("echo hello > file.txt", ["read", "grep"]).block, false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("pass-through commands", () => {
|
|
125
|
+
it("passes npm install", () => {
|
|
126
|
+
assert.equal(checkBashInterception("npm install", ALL_TOOLS).block, false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("passes ls > output.txt (not an echo/printf/cat)", () => {
|
|
130
|
+
assert.equal(checkBashInterception("ls > output.txt", ALL_TOOLS).block, false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("passes tee file.txt", () => {
|
|
134
|
+
assert.equal(checkBashInterception("tee file.txt", ALL_TOOLS).block, false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("passes git log", () => {
|
|
138
|
+
assert.equal(checkBashInterception("git log --oneline", ALL_TOOLS).block, false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("block message content", () => {
|
|
143
|
+
it("includes the original command in the block message", () => {
|
|
144
|
+
const r = checkBashInterception("cat README.md", ALL_TOOLS);
|
|
145
|
+
assert.ok(r.message?.includes("cat README.md"), "message should contain original command");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns block:false with no message when not blocked", () => {
|
|
149
|
+
const r = checkBashInterception("npm install", ALL_TOOLS);
|
|
150
|
+
assert.equal(r.block, false);
|
|
151
|
+
assert.equal(r.message, undefined);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("compileInterceptor", () => {
|
|
157
|
+
it("produces same results as checkBashInterception", () => {
|
|
158
|
+
const interceptor = compileInterceptor(DEFAULT_BASH_INTERCEPTOR_RULES);
|
|
159
|
+
const cases: [string, string[], boolean][] = [
|
|
160
|
+
["cat README.md", ALL_TOOLS, true],
|
|
161
|
+
["npm install", ALL_TOOLS, false],
|
|
162
|
+
["grep foo bar", ALL_TOOLS, true],
|
|
163
|
+
["echo hello >> file", ALL_TOOLS, false],
|
|
164
|
+
["echo test 2> /dev/null", ALL_TOOLS, false],
|
|
165
|
+
];
|
|
166
|
+
for (const [cmd, tools, expected] of cases) {
|
|
167
|
+
assert.equal(
|
|
168
|
+
interceptor.check(cmd, tools).block,
|
|
169
|
+
expected,
|
|
170
|
+
`pre-compiled: "${cmd}" expected block=${expected}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("silently skips rules with invalid regex patterns", () => {
|
|
176
|
+
const rules: BashInterceptorRule[] = [
|
|
177
|
+
{ pattern: "[invalid(", tool: "read", message: "broken" },
|
|
178
|
+
{ pattern: "^\\s*cat\\s+", tool: "read", message: "valid" },
|
|
179
|
+
];
|
|
180
|
+
const interceptor = compileInterceptor(rules);
|
|
181
|
+
assert.equal(interceptor.check("cat file.txt", ["read"]).block, true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("returns block:false when available tools list is empty", () => {
|
|
185
|
+
const interceptor = compileInterceptor(DEFAULT_BASH_INTERCEPTOR_RULES);
|
|
186
|
+
assert.equal(interceptor.check("cat README.md", []).block, false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("allows custom rule override", () => {
|
|
190
|
+
const customRules: BashInterceptorRule[] = [
|
|
191
|
+
{ pattern: "^\\s*curl\\s+", tool: "fetch", message: "Use fetch tool instead." },
|
|
192
|
+
];
|
|
193
|
+
const interceptor = compileInterceptor(customRules);
|
|
194
|
+
assert.equal(interceptor.check("curl https://example.com", ["fetch"]).block, true);
|
|
195
|
+
// default rules not active
|
|
196
|
+
assert.equal(interceptor.check("cat file.txt", ["read"]).block, false);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash command interceptor — blocks shell commands that duplicate dedicated tools.
|
|
3
|
+
*
|
|
4
|
+
* Each rule defines a regex pattern, a suggested replacement tool, and a message.
|
|
5
|
+
* A command is only blocked when the suggested tool exists in the session's active tool list.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface BashInterceptorRule {
|
|
9
|
+
pattern: string;
|
|
10
|
+
flags?: string;
|
|
11
|
+
tool: string;
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
|
|
16
|
+
{
|
|
17
|
+
// cat/head/tail for file viewing — excludes heredoc syntax (cat <<)
|
|
18
|
+
pattern: "^\\s*(cat(?!\\s*<<)|head|tail|less|more)\\s+",
|
|
19
|
+
tool: "read",
|
|
20
|
+
message: "Use the read tool to view file contents instead of shell commands.",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
pattern: "^\\s*(grep|rg|ripgrep|ag|ack)\\s+",
|
|
24
|
+
tool: "grep",
|
|
25
|
+
message: "Use the grep tool for searching file contents instead of shell commands.",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
pattern: "^\\s*(find|fd|locate)\\s+.*(-name|-iname|-type|--type|-glob)",
|
|
29
|
+
tool: "find",
|
|
30
|
+
message: "Use the find tool for locating files by name/type instead of shell commands.",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
pattern: "^\\s*sed\\s+(-i|--in-place)",
|
|
34
|
+
tool: "edit",
|
|
35
|
+
message: "Use the edit tool for in-place file modifications instead of sed.",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
pattern: "^\\s*perl\\s+.*-[pn]?i",
|
|
39
|
+
tool: "edit",
|
|
40
|
+
message: "Use the edit tool for in-place file modifications instead of perl.",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
pattern: "^\\s*awk\\s+.*-i\\s+inplace",
|
|
44
|
+
tool: "edit",
|
|
45
|
+
message: "Use the edit tool for in-place file modifications instead of awk.",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
// echo/printf/heredoc writing to a file via > (not >> append, not 2> stderr redirect)
|
|
49
|
+
// Matches a single > not preceded by |, >, or a digit (fd redirect like 2>)
|
|
50
|
+
pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+.*(?<![|>\\d])>(?!>)\\s*\\S",
|
|
51
|
+
tool: "write",
|
|
52
|
+
message: "Use the write tool to create/overwrite files instead of shell redirects.",
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
export interface InterceptionResult {
|
|
57
|
+
block: boolean;
|
|
58
|
+
message?: string;
|
|
59
|
+
suggestedTool?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CompiledInterceptor {
|
|
63
|
+
check: (command: string, availableTools: string[]) => InterceptionResult;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compile rules into an interceptor with pre-built regex objects.
|
|
68
|
+
* Silently skips rules with invalid patterns.
|
|
69
|
+
*
|
|
70
|
+
* Pre-compiling at construction time avoids repeated `new RegExp()` calls
|
|
71
|
+
* on every bash command invocation.
|
|
72
|
+
*/
|
|
73
|
+
export function compileInterceptor(rules: BashInterceptorRule[]): CompiledInterceptor {
|
|
74
|
+
const compiled = rules.flatMap((rule) => {
|
|
75
|
+
try {
|
|
76
|
+
return [{ regex: new RegExp(rule.pattern, rule.flags), rule }];
|
|
77
|
+
} catch {
|
|
78
|
+
return []; // skip invalid regex
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
check(command: string, availableTools: string[]): InterceptionResult {
|
|
84
|
+
const trimmed = command.trim();
|
|
85
|
+
for (const { regex, rule } of compiled) {
|
|
86
|
+
if (regex.test(trimmed) && availableTools.includes(rule.tool)) {
|
|
87
|
+
return {
|
|
88
|
+
block: true,
|
|
89
|
+
message: `Blocked: ${rule.message}\n\nOriginal command: ${command}`,
|
|
90
|
+
suggestedTool: rule.tool,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { block: false };
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check whether a bash command should be intercepted.
|
|
101
|
+
*
|
|
102
|
+
* Compiles rules on each call — prefer `compileInterceptor()` for repeated use.
|
|
103
|
+
*
|
|
104
|
+
* @param command - The shell command to check
|
|
105
|
+
* @param availableTools - Tool names present in the current session
|
|
106
|
+
* @param rules - Override the default rule set (optional)
|
|
107
|
+
*/
|
|
108
|
+
export function checkBashInterception(
|
|
109
|
+
command: string,
|
|
110
|
+
availableTools: string[],
|
|
111
|
+
rules?: BashInterceptorRule[],
|
|
112
|
+
): InterceptionResult {
|
|
113
|
+
const effectiveRules = rules ?? DEFAULT_BASH_INTERCEPTOR_RULES;
|
|
114
|
+
return compileInterceptor(effectiveRules).check(command, availableTools);
|
|
115
|
+
}
|
|
@@ -7,6 +7,7 @@ import type { AgentTool } from "@gsd/pi-agent-core";
|
|
|
7
7
|
import { type Static, Type } from "@sinclair/typebox";
|
|
8
8
|
import { spawn } from "child_process";
|
|
9
9
|
import { getShellConfig, getShellEnv, killProcessTree, sanitizeCommand } from "../../utils/shell.js";
|
|
10
|
+
import { type BashInterceptorRule, compileInterceptor, DEFAULT_BASH_INTERCEPTOR_RULES } from "./bash-interceptor.js";
|
|
10
11
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
|
|
11
12
|
import type { ArtifactManager } from "../artifact-manager.js";
|
|
12
13
|
|
|
@@ -191,6 +192,13 @@ export interface BashToolOptions {
|
|
|
191
192
|
spawnHook?: BashSpawnHook;
|
|
192
193
|
/** Session-scoped artifact storage. When provided, spills to artifact files instead of temp files. */
|
|
193
194
|
artifactManager?: ArtifactManager;
|
|
195
|
+
/** Bash interceptor configuration — blocks commands that duplicate dedicated tools */
|
|
196
|
+
interceptor?: {
|
|
197
|
+
enabled: boolean;
|
|
198
|
+
rules?: BashInterceptorRule[];
|
|
199
|
+
};
|
|
200
|
+
/** Tool names available in the session, used by the interceptor to check if replacement tools exist */
|
|
201
|
+
availableToolNames?: string[] | (() => string[]);
|
|
194
202
|
}
|
|
195
203
|
|
|
196
204
|
export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> {
|
|
@@ -199,6 +207,12 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
|
|
|
199
207
|
const spawnHook = options?.spawnHook;
|
|
200
208
|
const artifactManager = options?.artifactManager;
|
|
201
209
|
|
|
210
|
+
// Pre-compile interceptor rules once at construction time
|
|
211
|
+
const interceptorInstance =
|
|
212
|
+
options?.interceptor?.enabled
|
|
213
|
+
? compileInterceptor(options.interceptor.rules ?? DEFAULT_BASH_INTERCEPTOR_RULES)
|
|
214
|
+
: null;
|
|
215
|
+
|
|
202
216
|
return {
|
|
203
217
|
name: "bash",
|
|
204
218
|
label: "bash",
|
|
@@ -210,6 +224,21 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
|
|
|
210
224
|
signal?: AbortSignal,
|
|
211
225
|
onUpdate?,
|
|
212
226
|
) => {
|
|
227
|
+
// Check bash interceptor — block commands that duplicate dedicated tools
|
|
228
|
+
if (interceptorInstance) {
|
|
229
|
+
const toolNames =
|
|
230
|
+
typeof options!.availableToolNames === "function"
|
|
231
|
+
? options!.availableToolNames()
|
|
232
|
+
: options!.availableToolNames ?? [];
|
|
233
|
+
const interception = interceptorInstance.check(command, toolNames);
|
|
234
|
+
if (interception.block) {
|
|
235
|
+
return {
|
|
236
|
+
content: [{ type: "text" as const, text: interception.message ?? "Command blocked by interceptor" }],
|
|
237
|
+
details: undefined,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
213
242
|
// Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)
|
|
214
243
|
const resolvedCommand = sanitizeCommand(commandPrefix ? `${commandPrefix}\n${command}` : command);
|
|
215
244
|
const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook);
|
|
@@ -8,6 +8,14 @@ export {
|
|
|
8
8
|
bashTool,
|
|
9
9
|
createBashTool,
|
|
10
10
|
} from "./bash.js";
|
|
11
|
+
export {
|
|
12
|
+
type BashInterceptorRule,
|
|
13
|
+
checkBashInterception,
|
|
14
|
+
type CompiledInterceptor,
|
|
15
|
+
compileInterceptor,
|
|
16
|
+
DEFAULT_BASH_INTERCEPTOR_RULES,
|
|
17
|
+
type InterceptionResult,
|
|
18
|
+
} from "./bash-interceptor.js";
|
|
11
19
|
export {
|
|
12
20
|
createEditTool,
|
|
13
21
|
type EditOperations,
|
|
@@ -202,6 +202,7 @@ export {
|
|
|
202
202
|
export { BlobStore, isBlobRef, parseBlobRef, externalizeImageData, resolveImageData } from "./core/blob-store.js";
|
|
203
203
|
export { ArtifactManager } from "./core/artifact-manager.js";
|
|
204
204
|
export {
|
|
205
|
+
type AsyncSettings,
|
|
205
206
|
type CompactionSettings,
|
|
206
207
|
type ImageSettings,
|
|
207
208
|
type PackageSource,
|
|
@@ -220,6 +221,7 @@ export {
|
|
|
220
221
|
} from "./core/skills.js";
|
|
221
222
|
// Tools
|
|
222
223
|
export {
|
|
224
|
+
type BashInterceptorRule,
|
|
223
225
|
type BashOperations,
|
|
224
226
|
type BashSpawnContext,
|
|
225
227
|
type BashSpawnHook,
|
|
@@ -227,6 +229,10 @@ export {
|
|
|
227
229
|
type BashToolInput,
|
|
228
230
|
type BashToolOptions,
|
|
229
231
|
bashTool,
|
|
232
|
+
checkBashInterception,
|
|
233
|
+
type CompiledInterceptor,
|
|
234
|
+
compileInterceptor,
|
|
235
|
+
DEFAULT_BASH_INTERCEPTOR_RULES,
|
|
230
236
|
codingTools,
|
|
231
237
|
DEFAULT_MAX_BYTES,
|
|
232
238
|
DEFAULT_MAX_LINES,
|
|
@@ -175,7 +175,7 @@ export class InteractiveMode {
|
|
|
175
175
|
private pendingTools = new Map<string, ToolExecutionComponent>();
|
|
176
176
|
|
|
177
177
|
// Tool output expansion state
|
|
178
|
-
private toolOutputExpanded =
|
|
178
|
+
private toolOutputExpanded = false;
|
|
179
179
|
|
|
180
180
|
// Thinking block visibility state
|
|
181
181
|
private hideThinkingBlock = false;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* async_bash tool — run a bash command in the background.
|
|
3
|
+
*
|
|
4
|
+
* Registers the command with the AsyncJobManager and returns a job ID
|
|
5
|
+
* immediately. The LLM can continue working and check results later
|
|
6
|
+
* with await_job.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ToolDefinition } from "@gsd/pi-coding-agent";
|
|
10
|
+
import {
|
|
11
|
+
getShellConfig,
|
|
12
|
+
sanitizeCommand,
|
|
13
|
+
DEFAULT_MAX_BYTES,
|
|
14
|
+
DEFAULT_MAX_LINES,
|
|
15
|
+
} from "@gsd/pi-coding-agent";
|
|
16
|
+
import { Type } from "@sinclair/typebox";
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { createWriteStream } from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { randomBytes } from "node:crypto";
|
|
22
|
+
import type { AsyncJobManager } from "./job-manager.js";
|
|
23
|
+
|
|
24
|
+
const schema = Type.Object({
|
|
25
|
+
command: Type.String({ description: "Bash command to execute in the background" }),
|
|
26
|
+
timeout: Type.Optional(
|
|
27
|
+
Type.Number({ description: "Timeout in seconds (optional)" }),
|
|
28
|
+
),
|
|
29
|
+
label: Type.Optional(
|
|
30
|
+
Type.String({ description: "Short label for the job (shown in /jobs). Defaults to a truncated version of the command." }),
|
|
31
|
+
),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function getTempFilePath(): string {
|
|
35
|
+
const id = randomBytes(8).toString("hex");
|
|
36
|
+
return join(tmpdir(), `pi-async-bash-${id}.log`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Kill a process and its children. Uses process group kill on Unix.
|
|
41
|
+
*/
|
|
42
|
+
function killTree(pid: number): void {
|
|
43
|
+
try {
|
|
44
|
+
// Kill the process group (negative PID)
|
|
45
|
+
process.kill(-pid, "SIGTERM");
|
|
46
|
+
} catch {
|
|
47
|
+
try {
|
|
48
|
+
process.kill(pid, "SIGTERM");
|
|
49
|
+
} catch {
|
|
50
|
+
// Already exited
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createAsyncBashTool(
|
|
56
|
+
getManager: () => AsyncJobManager,
|
|
57
|
+
getCwd: () => string,
|
|
58
|
+
): ToolDefinition<typeof schema> {
|
|
59
|
+
return {
|
|
60
|
+
name: "async_bash",
|
|
61
|
+
label: "Background Bash",
|
|
62
|
+
description:
|
|
63
|
+
`Run a bash command in the background. Returns a job ID immediately so you can continue working. ` +
|
|
64
|
+
`Use await_job to get results or cancel_job to stop. Ideal for long-running builds, tests, or installs. ` +
|
|
65
|
+
`Output is truncated to the last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB.`,
|
|
66
|
+
promptSnippet: "Run a bash command in the background, returning a job ID immediately.",
|
|
67
|
+
promptGuidelines: [
|
|
68
|
+
"Use async_bash for commands that take more than a few seconds (builds, tests, installs, large git operations).",
|
|
69
|
+
"After starting async jobs, continue with other work and use await_job when you need the results.",
|
|
70
|
+
"Use cancel_job to stop a running background job.",
|
|
71
|
+
"Check /jobs to see all running and recent background jobs.",
|
|
72
|
+
],
|
|
73
|
+
parameters: schema,
|
|
74
|
+
async execute(_toolCallId, params) {
|
|
75
|
+
const manager = getManager();
|
|
76
|
+
const cwd = getCwd();
|
|
77
|
+
const { command, timeout, label } = params;
|
|
78
|
+
const shortCmd = label ?? (command.length > 60 ? command.slice(0, 57) + "..." : command);
|
|
79
|
+
|
|
80
|
+
const jobId = manager.register("bash", shortCmd, (signal) => {
|
|
81
|
+
return executeBashInBackground(command, cwd, signal, timeout);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
content: [{
|
|
86
|
+
type: "text",
|
|
87
|
+
text: [
|
|
88
|
+
`Background job started: **${jobId}**`,
|
|
89
|
+
`Command: \`${shortCmd}\``,
|
|
90
|
+
"",
|
|
91
|
+
"Use `await_job` to get results when ready, or `cancel_job` to stop.",
|
|
92
|
+
].join("\n"),
|
|
93
|
+
}],
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Execute a bash command, collecting output. Returns the text result.
|
|
101
|
+
*/
|
|
102
|
+
function executeBashInBackground(
|
|
103
|
+
command: string,
|
|
104
|
+
cwd: string,
|
|
105
|
+
signal: AbortSignal,
|
|
106
|
+
timeout?: number,
|
|
107
|
+
): Promise<string> {
|
|
108
|
+
return new Promise<string>((resolve, reject) => {
|
|
109
|
+
const { shell, args } = getShellConfig();
|
|
110
|
+
const resolvedCommand = sanitizeCommand(command);
|
|
111
|
+
|
|
112
|
+
const child = spawn(shell, [...args, resolvedCommand], {
|
|
113
|
+
cwd,
|
|
114
|
+
detached: true,
|
|
115
|
+
env: { ...process.env },
|
|
116
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
let timedOut = false;
|
|
120
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
121
|
+
|
|
122
|
+
if (timeout !== undefined && timeout > 0) {
|
|
123
|
+
timeoutHandle = setTimeout(() => {
|
|
124
|
+
timedOut = true;
|
|
125
|
+
if (child.pid) killTree(child.pid);
|
|
126
|
+
}, timeout * 1000);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const chunks: Buffer[] = [];
|
|
130
|
+
let totalBytes = 0;
|
|
131
|
+
let spillFilePath: string | undefined;
|
|
132
|
+
let spillStream: ReturnType<typeof createWriteStream> | undefined;
|
|
133
|
+
const MAX_BUFFER = DEFAULT_MAX_BYTES * 2;
|
|
134
|
+
|
|
135
|
+
const onData = (data: Buffer) => {
|
|
136
|
+
totalBytes += data.length;
|
|
137
|
+
|
|
138
|
+
if (totalBytes > DEFAULT_MAX_BYTES && !spillFilePath) {
|
|
139
|
+
spillFilePath = getTempFilePath();
|
|
140
|
+
spillStream = createWriteStream(spillFilePath);
|
|
141
|
+
for (const chunk of chunks) spillStream.write(chunk);
|
|
142
|
+
}
|
|
143
|
+
if (spillStream) spillStream.write(data);
|
|
144
|
+
|
|
145
|
+
chunks.push(data);
|
|
146
|
+
let chunksBytes = chunks.reduce((s, c) => s + c.length, 0);
|
|
147
|
+
while (chunksBytes > MAX_BUFFER && chunks.length > 1) {
|
|
148
|
+
const removed = chunks.shift()!;
|
|
149
|
+
chunksBytes -= removed.length;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (child.stdout) child.stdout.on("data", onData);
|
|
154
|
+
if (child.stderr) child.stderr.on("data", onData);
|
|
155
|
+
|
|
156
|
+
const onAbort = () => {
|
|
157
|
+
if (child.pid) killTree(child.pid);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
if (signal.aborted) {
|
|
161
|
+
onAbort();
|
|
162
|
+
} else {
|
|
163
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
child.on("error", (err) => {
|
|
167
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
168
|
+
signal.removeEventListener("abort", onAbort);
|
|
169
|
+
reject(err);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
child.on("close", (code) => {
|
|
173
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
174
|
+
signal.removeEventListener("abort", onAbort);
|
|
175
|
+
if (spillStream) spillStream.end();
|
|
176
|
+
|
|
177
|
+
if (signal.aborted) {
|
|
178
|
+
const output = Buffer.concat(chunks).toString("utf-8");
|
|
179
|
+
resolve(output ? `${output}\n\nCommand aborted` : "Command aborted");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (timedOut) {
|
|
184
|
+
const output = Buffer.concat(chunks).toString("utf-8");
|
|
185
|
+
resolve(output ? `${output}\n\nCommand timed out after ${timeout} seconds` : `Command timed out after ${timeout} seconds`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const fullOutput = Buffer.concat(chunks).toString("utf-8");
|
|
190
|
+
|
|
191
|
+
const lines = fullOutput.split("\n");
|
|
192
|
+
let text: string;
|
|
193
|
+
if (lines.length > DEFAULT_MAX_LINES) {
|
|
194
|
+
text = lines.slice(-DEFAULT_MAX_LINES).join("\n");
|
|
195
|
+
if (spillFilePath) {
|
|
196
|
+
text += `\n\n[Showing last ${DEFAULT_MAX_LINES} of ${lines.length} lines. Full output: ${spillFilePath}]`;
|
|
197
|
+
} else {
|
|
198
|
+
text += `\n\n[Showing last ${DEFAULT_MAX_LINES} of ${lines.length} lines]`;
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
text = fullOutput || "(no output)";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (code !== 0 && code !== null) {
|
|
205
|
+
text += `\n\nCommand exited with code ${code}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
resolve(text);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
}
|