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.
Files changed (136) hide show
  1. package/README.md +2 -0
  2. package/dist/cli.js +7 -0
  3. package/dist/loader.js +1 -0
  4. package/dist/onboarding.js +104 -59
  5. package/dist/update-cmd.d.ts +1 -0
  6. package/dist/update-cmd.js +40 -0
  7. package/node_modules/@gsd/native/dist/hasher/index.d.ts +32 -0
  8. package/node_modules/@gsd/native/dist/hasher/index.js +37 -0
  9. package/node_modules/@gsd/native/dist/native.d.ts +4 -1
  10. package/node_modules/@gsd/native/dist/native.js +39 -9
  11. package/node_modules/@gsd/native/dist/xxhash/index.d.ts +14 -0
  12. package/node_modules/@gsd/native/dist/xxhash/index.js +17 -0
  13. package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.d.ts +6 -0
  14. package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  15. package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.js +58 -9
  16. package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  17. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.d.ts +72 -12
  18. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.d.ts.map +1 -1
  19. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.js +254 -43
  20. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.js.map +1 -1
  21. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.d.ts +2 -0
  22. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.d.ts.map +1 -0
  23. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.js +159 -0
  24. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.js.map +1 -0
  25. package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.d.ts +4 -2
  26. package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
  27. package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.js +6 -4
  28. package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.js.map +1 -1
  29. package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.d.ts +15 -0
  30. package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  31. package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.js +12 -0
  32. package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  33. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts +40 -0
  34. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts.map +1 -0
  35. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.js +92 -0
  36. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.js.map +1 -0
  37. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts +2 -0
  38. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts.map +1 -0
  39. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.js +156 -0
  40. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.js.map +1 -0
  41. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.d.ts +8 -0
  42. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
  43. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.js +18 -0
  44. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  45. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.d.ts +1 -0
  46. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
  47. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.js +1 -0
  48. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.js.map +1 -1
  49. package/node_modules/@gsd/pi-coding-agent/dist/index.d.ts +2 -2
  50. package/node_modules/@gsd/pi-coding-agent/dist/index.d.ts.map +1 -1
  51. package/node_modules/@gsd/pi-coding-agent/dist/index.js +1 -1
  52. package/node_modules/@gsd/pi-coding-agent/dist/index.js.map +1 -1
  53. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  54. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  55. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  56. package/node_modules/@gsd/pi-coding-agent/src/core/agent-session.ts +65 -9
  57. package/node_modules/@gsd/pi-coding-agent/src/core/auth-storage.test.ts +194 -0
  58. package/node_modules/@gsd/pi-coding-agent/src/core/auth-storage.ts +283 -53
  59. package/node_modules/@gsd/pi-coding-agent/src/core/model-registry.ts +6 -4
  60. package/node_modules/@gsd/pi-coding-agent/src/core/settings-manager.ts +29 -0
  61. package/node_modules/@gsd/pi-coding-agent/src/core/tools/bash-interceptor.test.ts +198 -0
  62. package/node_modules/@gsd/pi-coding-agent/src/core/tools/bash-interceptor.ts +115 -0
  63. package/node_modules/@gsd/pi-coding-agent/src/core/tools/bash.ts +29 -0
  64. package/node_modules/@gsd/pi-coding-agent/src/core/tools/index.ts +8 -0
  65. package/node_modules/@gsd/pi-coding-agent/src/index.ts +6 -0
  66. package/node_modules/@gsd/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  67. package/package.json +8 -2
  68. package/packages/native/dist/hasher/index.d.ts +32 -0
  69. package/packages/native/dist/hasher/index.js +37 -0
  70. package/packages/native/dist/native.d.ts +4 -1
  71. package/packages/native/dist/native.js +39 -9
  72. package/packages/native/dist/xxhash/index.d.ts +14 -0
  73. package/packages/native/dist/xxhash/index.js +17 -0
  74. package/packages/native/src/native.ts +39 -9
  75. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +6 -0
  76. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  77. package/packages/pi-coding-agent/dist/core/agent-session.js +58 -9
  78. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  79. package/packages/pi-coding-agent/dist/core/auth-storage.d.ts +72 -12
  80. package/packages/pi-coding-agent/dist/core/auth-storage.d.ts.map +1 -1
  81. package/packages/pi-coding-agent/dist/core/auth-storage.js +254 -43
  82. package/packages/pi-coding-agent/dist/core/auth-storage.js.map +1 -1
  83. package/packages/pi-coding-agent/dist/core/auth-storage.test.d.ts +2 -0
  84. package/packages/pi-coding-agent/dist/core/auth-storage.test.d.ts.map +1 -0
  85. package/packages/pi-coding-agent/dist/core/auth-storage.test.js +159 -0
  86. package/packages/pi-coding-agent/dist/core/auth-storage.test.js.map +1 -0
  87. package/packages/pi-coding-agent/dist/core/model-registry.d.ts +4 -2
  88. package/packages/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
  89. package/packages/pi-coding-agent/dist/core/model-registry.js +6 -4
  90. package/packages/pi-coding-agent/dist/core/model-registry.js.map +1 -1
  91. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +15 -0
  92. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  93. package/packages/pi-coding-agent/dist/core/settings-manager.js +12 -0
  94. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  95. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts +40 -0
  96. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts.map +1 -0
  97. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.js +92 -0
  98. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.js.map +1 -0
  99. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts +2 -0
  100. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts.map +1 -0
  101. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.js +156 -0
  102. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.js.map +1 -0
  103. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts +8 -0
  104. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
  105. package/packages/pi-coding-agent/dist/core/tools/bash.js +18 -0
  106. package/packages/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  107. package/packages/pi-coding-agent/dist/core/tools/index.d.ts +1 -0
  108. package/packages/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
  109. package/packages/pi-coding-agent/dist/core/tools/index.js +1 -0
  110. package/packages/pi-coding-agent/dist/core/tools/index.js.map +1 -1
  111. package/packages/pi-coding-agent/dist/index.d.ts +2 -2
  112. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  113. package/packages/pi-coding-agent/dist/index.js +1 -1
  114. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  115. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  116. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  117. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  118. package/packages/pi-coding-agent/src/core/agent-session.ts +65 -9
  119. package/packages/pi-coding-agent/src/core/auth-storage.test.ts +194 -0
  120. package/packages/pi-coding-agent/src/core/auth-storage.ts +283 -53
  121. package/packages/pi-coding-agent/src/core/model-registry.ts +6 -4
  122. package/packages/pi-coding-agent/src/core/settings-manager.ts +29 -0
  123. package/packages/pi-coding-agent/src/core/tools/bash-interceptor.test.ts +198 -0
  124. package/packages/pi-coding-agent/src/core/tools/bash-interceptor.ts +115 -0
  125. package/packages/pi-coding-agent/src/core/tools/bash.ts +29 -0
  126. package/packages/pi-coding-agent/src/core/tools/index.ts +8 -0
  127. package/packages/pi-coding-agent/src/index.ts +6 -0
  128. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  129. package/src/resources/extensions/async-jobs/async-bash-tool.ts +211 -0
  130. package/src/resources/extensions/async-jobs/await-tool.ts +101 -0
  131. package/src/resources/extensions/async-jobs/cancel-job-tool.ts +34 -0
  132. package/src/resources/extensions/async-jobs/index.ts +133 -0
  133. package/src/resources/extensions/async-jobs/job-manager.ts +250 -0
  134. package/src/resources/extensions/gsd/git-service.ts +13 -3
  135. package/src/resources/extensions/gsd/prompts/system.md +5 -2
  136. 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 = true;
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
+ }