bloby-bot 0.70.12 → 0.71.0

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 (65) hide show
  1. package/bin/cli.js +234 -48
  2. package/dist-bloby/assets/{bloby-DSNB0g4w.js → bloby-es6cZJzs.js} +6 -6
  3. package/dist-bloby/assets/globals-DBqwNiJV.css +2 -0
  4. package/dist-bloby/assets/{globals-B3cTbITX.js → globals-DN3F0CQE.js} +1 -1
  5. package/dist-bloby/assets/{highlighted-body-OFNGDK62-BLforpkr.js → highlighted-body-OFNGDK62-8PiOHw9p.js} +1 -1
  6. package/dist-bloby/assets/mermaid-GHXKKRXX-BJWX8urU.js +1 -0
  7. package/dist-bloby/assets/{onboard-Dn2Ws_G2.js → onboard-BKgy17OU.js} +1 -1
  8. package/dist-bloby/bloby.html +3 -3
  9. package/dist-bloby/onboard.html +3 -3
  10. package/package.json +3 -4
  11. package/scripts/install +156 -41
  12. package/scripts/install.ps1 +146 -29
  13. package/scripts/install.sh +156 -41
  14. package/shared/config.ts +37 -2
  15. package/shared/relay.ts +3 -1
  16. package/supervisor/channels/manager.ts +84 -44
  17. package/supervisor/channels/telegram.ts +57 -16
  18. package/supervisor/channels/types.ts +4 -1
  19. package/supervisor/channels/whatsapp.ts +57 -10
  20. package/supervisor/chat/OnboardWizard.tsx +0 -15
  21. package/supervisor/chat/src/components/Chat/AudioBubble.tsx +1 -1
  22. package/supervisor/chat/src/components/Chat/AuthedImage.tsx +16 -3
  23. package/supervisor/chat/src/components/Chat/BlobyImageCard.tsx +2 -2
  24. package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +25 -8
  25. package/supervisor/chat/src/components/Chat/InputBar.tsx +62 -7
  26. package/supervisor/chat/src/components/Chat/MessageBubble.tsx +37 -18
  27. package/supervisor/chat/src/components/Chat/MessageList.tsx +3 -3
  28. package/supervisor/chat/src/hooks/useChat.ts +52 -0
  29. package/supervisor/chat/src/lib/authedFile.ts +24 -12
  30. package/supervisor/file-saver.ts +92 -19
  31. package/supervisor/harnesses/attachment-policy.ts +111 -0
  32. package/supervisor/harnesses/claude.ts +62 -15
  33. package/supervisor/harnesses/codex.ts +69 -43
  34. package/supervisor/harnesses/pi/index.ts +367 -112
  35. package/supervisor/harnesses/pi/providers/humanize-error.ts +27 -2
  36. package/supervisor/harnesses/pi/providers/retry.ts +31 -0
  37. package/supervisor/harnesses/pi/providers/stream-anthropic.ts +31 -3
  38. package/supervisor/harnesses/pi/providers/stream-google.ts +26 -3
  39. package/supervisor/harnesses/pi/providers/stream-openai-completions.ts +32 -9
  40. package/supervisor/harnesses/pi/providers/types.ts +29 -1
  41. package/supervisor/harnesses/pi/session.ts +143 -3
  42. package/supervisor/harnesses/pi/test-completion.ts +56 -0
  43. package/supervisor/harnesses/pi/tools/bash.ts +198 -22
  44. package/supervisor/harnesses/pi/tools/glob.ts +79 -0
  45. package/supervisor/harnesses/pi/tools/grep.ts +0 -0
  46. package/supervisor/harnesses/pi/tools/registry.ts +18 -6
  47. package/supervisor/harnesses/pi/tools/todo-write.ts +45 -0
  48. package/supervisor/harnesses/pi/tools/web-fetch.ts +129 -0
  49. package/supervisor/index.ts +93 -18
  50. package/supervisor/widget.js +19 -5
  51. package/worker/db.ts +2 -0
  52. package/worker/index.ts +18 -1
  53. package/worker/prompts/bloby-system-prompt-codex.txt +1 -1
  54. package/worker/prompts/bloby-system-prompt-pi.txt +6 -24
  55. package/worker/prompts/bloby-system-prompt.txt +1 -1
  56. package/workspace/client/src/components/Dashboard/DashboardPage.tsx +4 -117
  57. package/workspace/client/src/components/Dashboard/deleteme_placeholders.tsx +194 -0
  58. package/workspace/client/src/components/Layout/Sidebar.tsx +52 -30
  59. package/workspace/client/src/components/deleteme_onboarding/WorkspaceTour.tsx +25 -15
  60. package/workspace/client/src/components/deleteme_onboarding/tour-theme.css +24 -0
  61. package/workspace/skills/mac/SKILL.md +13 -4
  62. package/dist-bloby/assets/globals-DyeW509Y.css +0 -2
  63. package/dist-bloby/assets/mermaid-GHXKKRXX-C1H_fSCU.js +0 -1
  64. package/supervisor/public/headphones_spritesheet.webp +0 -0
  65. package/supervisor/public/spritesheet.webp +0 -0
@@ -11,6 +11,8 @@
11
11
  * - google-gemini → POST {baseUrl}/models/{modelId}:generateContent
12
12
  */
13
13
  import { getPiSubProvider, type PiApiFlavor } from './sub-providers.js';
14
+ import { streamProvider } from './providers/stream.js';
15
+ import { toolDefsForProvider } from './tools/registry.js';
14
16
 
15
17
  export interface PiTestCompletionInput {
16
18
  subProvider: string;
@@ -88,6 +90,60 @@ export async function runPiTestCompletion(input: PiTestCompletionInput): Promise
88
90
  }
89
91
  }
90
92
 
93
+ /**
94
+ * Streaming + tools probe (audit C-4). The non-streaming, tool-less test above
95
+ * validates a contract no real turn uses — free-form model ids (Ollama, LM
96
+ * Studio, custom, OpenRouter) could pass it and then fail the first actual
97
+ * message, which streams SSE with the full tool schema attached. This probe
98
+ * exercises the REAL wire shape in one cheap request: success = any
99
+ * text/tool-call event arrives before an error does.
100
+ */
101
+ export async function runPiStreamProbe(input: PiTestCompletionInput): Promise<PiTestCompletionResult> {
102
+ const provider = getPiSubProvider(input.subProvider);
103
+ if (!provider) return { ok: false, error: `Unknown sub-provider: ${input.subProvider}` };
104
+ const baseUrl = pickBaseUrl(input);
105
+ if (!baseUrl) return { ok: false, error: 'Missing base URL' };
106
+ const modelId = pickModelId(input);
107
+ if (!modelId) return { ok: false, error: 'Missing model ID' };
108
+
109
+ const ctl = new AbortController();
110
+ const timer = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS);
111
+ try {
112
+ const stream = streamProvider(provider.flavor, {
113
+ modelId,
114
+ baseUrl,
115
+ apiKey: input.apiKey?.trim() || '',
116
+ systemPrompt: 'You are a connectivity probe. Reply with the single word OK.',
117
+ messages: [{ role: 'user', content: [{ type: 'text', text: input.prompt || 'Reply with the single word OK.' }] }],
118
+ // withTask: the live conversation's schema is the superset every real
119
+ // turn sends — probe with the same shape (review PI-D-4).
120
+ tools: toolDefsForProvider({ withTask: true }),
121
+ // Generous: reasoning models burn output budget on hidden thinking first.
122
+ maxOutputTokens: 2048,
123
+ maxTokensField: provider.maxTokensField,
124
+ includeStreamUsage: provider.noStreamUsage ? false : undefined,
125
+ signal: ctl.signal,
126
+ });
127
+ for await (const evt of stream) {
128
+ if (evt.type === 'text_delta' || evt.type === 'tool_use') {
129
+ return { ok: true, text: 'stream OK', modelId, subProvider: provider.id };
130
+ }
131
+ if (evt.type === 'error') {
132
+ return { ok: false, error: evt.error, modelId, subProvider: provider.id };
133
+ }
134
+ }
135
+ if (ctl.signal.aborted) {
136
+ return { ok: false, error: `Stream probe timed out after ${REQUEST_TIMEOUT_MS / 1000}s.`, modelId, subProvider: provider.id };
137
+ }
138
+ return { ok: false, error: 'The stream ended without producing any output.', modelId, subProvider: provider.id };
139
+ } catch (err: any) {
140
+ const msg = err?.name === 'AbortError' ? `Stream probe timed out after ${REQUEST_TIMEOUT_MS / 1000}s.` : err?.message || String(err);
141
+ return { ok: false, error: msg, modelId, subProvider: provider.id };
142
+ } finally {
143
+ clearTimeout(timer);
144
+ }
145
+ }
146
+
91
147
  interface DispatchArgs {
92
148
  baseUrl: string;
93
149
  modelId: string;
@@ -1,26 +1,100 @@
1
1
  /**
2
- * Bash tool — runs a shell command in the workspace.
2
+ * Bash tool — runs a shell command in the workspace (+ background jobs).
3
3
  *
4
- * Stays small on purpose: combined stdout+stderr, hard timeout, kills the
5
- * process on session abort. No interactive subshells, no background jobs.
4
+ * Claude-SDK-parity surface (audit C-9): 120s default / 10min max timeout,
5
+ * SIGTERM-with-grace then SIGKILL on the whole process group (detached spawn
6
+ * a SIGKILLed direct child can leave orphans holding the stdio pipes, and
7
+ * 'close' then never fires), settle-on-exit drain guard so the tool promise
8
+ * can never hang a turn, and `run_in_background` with the companion
9
+ * BashOutput / KillShell tools for long builds and dev servers.
6
10
  */
7
- import { spawn } from 'child_process';
11
+ import { spawn, type ChildProcess } from 'child_process';
8
12
  import type { PiTool } from './types.js';
9
13
 
10
- const DEFAULT_TIMEOUT_MS = 60_000;
11
- const HARD_TIMEOUT_MS = 5 * 60_000;
14
+ const DEFAULT_TIMEOUT_MS = 120_000;
15
+ const HARD_TIMEOUT_MS = 10 * 60_000;
12
16
  const OUTPUT_CAP_BYTES = 200 * 1024; // 200 KB; matches Claude SDK's behavior
17
+ const TERM_GRACE_MS = 3_000;
18
+ /** Safety ceiling for background jobs (claude shells die with the subprocess;
19
+ * pi jobs die with the session abort signal — this is the belt-and-braces). */
20
+ const BACKGROUND_MAX_MS = 30 * 60_000;
21
+
22
+ function killTreeWithGrace(child: ChildProcess): void {
23
+ const signalGroup = (sig: NodeJS.Signals) => {
24
+ try {
25
+ if (child.pid) process.kill(-child.pid, sig);
26
+ else child.kill(sig);
27
+ } catch {
28
+ try { child.kill(sig); } catch {}
29
+ }
30
+ };
31
+ signalGroup('SIGTERM');
32
+ const escalate = setTimeout(() => signalGroup('SIGKILL'), TERM_GRACE_MS);
33
+ escalate.unref?.();
34
+ child.once('exit', () => clearTimeout(escalate));
35
+ }
36
+
37
+ /* ── Background job table ── */
38
+
39
+ interface BashJob {
40
+ id: string;
41
+ command: string;
42
+ child: ChildProcess;
43
+ out: string;
44
+ truncated: boolean;
45
+ /** How much of `out` the model has already seen via BashOutput. */
46
+ readOffset: number;
47
+ exited: boolean;
48
+ /** stdio fully drained ('close' fired) — late pipe data can arrive after 'exit'. */
49
+ closed: boolean;
50
+ exitCode: number | null;
51
+ exitSignal: NodeJS.Signals | null;
52
+ killed: boolean;
53
+ startedAt: number;
54
+ }
55
+
56
+ /** Finished jobs linger as readable tombstones for a few minutes (repeat polls
57
+ * keep working), then a reap timer frees them — bounds the table without the
58
+ * delete-on-first-read trap that lost late pipe output (review PI-D-3). */
59
+ const JOB_TOMBSTONE_TTL_MS = 5 * 60_000;
60
+
61
+ const jobs = new Map<string, BashJob>();
62
+ let jobCounter = 0;
63
+
64
+ function appendCapped(job: BashJob, chunk: Buffer): void {
65
+ if (job.truncated) return;
66
+ const remaining = OUTPUT_CAP_BYTES - Buffer.byteLength(job.out, 'utf-8');
67
+ if (remaining <= 0) { job.truncated = true; return; }
68
+ const text = chunk.toString('utf-8');
69
+ if (Buffer.byteLength(text, 'utf-8') > remaining) {
70
+ job.out += text.slice(0, remaining);
71
+ job.truncated = true;
72
+ } else {
73
+ job.out += text;
74
+ }
75
+ }
76
+
77
+ function jobStatusLine(job: BashJob): string {
78
+ if (!job.exited) return `[running for ${Math.round((Date.now() - job.startedAt) / 1000)}s]`;
79
+ if (job.killed) return '[killed]';
80
+ return `[exited with code ${job.exitCode}${job.exitSignal ? ` (signal ${job.exitSignal})` : ''}]`;
81
+ }
82
+
83
+ /* ── Tools ── */
13
84
 
14
85
  export const bashTool: PiTool = {
15
86
  name: 'Bash',
16
87
  description:
17
- 'Run a shell command in the workspace and return its combined stdout+stderr. Use this for non-interactive commands only — no editors, no long-running servers.',
88
+ 'Run a shell command in the workspace and return its combined stdout+stderr. ' +
89
+ 'For long-running commands (builds, installs, dev servers) set run_in_background: true ' +
90
+ 'and poll with BashOutput; stop them with KillShell.',
18
91
  inputSchema: {
19
92
  type: 'object',
20
93
  properties: {
21
94
  command: { type: 'string', description: 'The shell command to execute.' },
22
95
  description: { type: 'string', description: 'A short description (5–10 words) of what the command does.' },
23
- timeout: { type: 'integer', description: 'Timeout in milliseconds (default 60 000, max 300 000).' },
96
+ timeout: { type: 'integer', description: 'Timeout in milliseconds (default 120 000, max 600 000). Ignored for background jobs.' },
97
+ run_in_background: { type: 'boolean', description: 'Start the command as a background job and return immediately with a job id.' },
24
98
  },
25
99
  required: ['command'],
26
100
  },
@@ -29,6 +103,61 @@ export const bashTool: PiTool = {
29
103
  const command = typeof input?.command === 'string' ? input.command : '';
30
104
  if (!command.trim()) return { output: 'command is required.', isError: true };
31
105
 
106
+ if (input?.run_in_background) {
107
+ const id = `bash_${++jobCounter}`;
108
+ let child: ChildProcess;
109
+ try {
110
+ child = spawn('bash', ['-lc', command], {
111
+ cwd: ctx.cwd,
112
+ env: process.env,
113
+ stdio: ['ignore', 'pipe', 'pipe'],
114
+ detached: true,
115
+ });
116
+ } catch (err: any) {
117
+ return { output: `Failed to spawn command: ${err?.message || err}`, isError: true };
118
+ }
119
+ const job: BashJob = {
120
+ id, command, child,
121
+ out: '', truncated: false, readOffset: 0,
122
+ exited: false, closed: false, exitCode: null, exitSignal: null, killed: false,
123
+ startedAt: Date.now(),
124
+ };
125
+ jobs.set(id, job);
126
+ child.stdout?.on('data', (c: Buffer) => appendCapped(job, c));
127
+ child.stderr?.on('data', (c: Buffer) => appendCapped(job, c));
128
+ const onAbort = () => { if (!job.exited) { job.killed = true; killTreeWithGrace(child); } };
129
+ const reap = () => {
130
+ const t = setTimeout(() => jobs.delete(id), JOB_TOMBSTONE_TTL_MS);
131
+ t.unref?.();
132
+ };
133
+ child.on('error', () => {
134
+ job.exited = true;
135
+ job.closed = true;
136
+ job.exitCode = -1;
137
+ ctx.signal?.removeEventListener('abort', onAbort);
138
+ reap();
139
+ });
140
+ child.on('exit', (code, signal) => {
141
+ job.exited = true;
142
+ job.exitCode = code;
143
+ job.exitSignal = signal;
144
+ // Listener cleanup on natural exit — without it every finished job
145
+ // pins its ChildProcess + output buffer on the session's AbortSignal
146
+ // for the conversation's whole life (review D-TOOLS-5).
147
+ ctx.signal?.removeEventListener('abort', onAbort);
148
+ reap();
149
+ });
150
+ child.on('close', () => { job.closed = true; });
151
+ // Jobs die with the session (claude parity: background shells die with
152
+ // the SDK subprocess) and have a hard safety ceiling.
153
+ const ceiling = setTimeout(() => { if (!job.exited) { job.killed = true; killTreeWithGrace(child); } }, BACKGROUND_MAX_MS);
154
+ ceiling.unref?.();
155
+ ctx.signal?.addEventListener('abort', onAbort, { once: true });
156
+ return {
157
+ output: `Started background job ${id}. Poll it with BashOutput {"bash_id": "${id}"}; stop it with KillShell {"shell_id": "${id}"}.`,
158
+ };
159
+ }
160
+
32
161
  const requestedTimeout = Number(input?.timeout) || DEFAULT_TIMEOUT_MS;
33
162
  const timeout = Math.min(HARD_TIMEOUT_MS, Math.max(1000, requestedTimeout));
34
163
 
@@ -39,9 +168,8 @@ export const bashTool: PiTool = {
39
168
  let settled = false;
40
169
 
41
170
  // detached:true gives the child its own process group so kills reach
42
- // grandchildren too — a SIGKILLed direct child can leave orphans holding
43
- // the stdio pipes, and 'close' (which waits for the pipes) then never
44
- // fires, hanging the tool promise and wedging the whole turn.
171
+ // grandchildren too — orphans holding the stdio pipes would otherwise
172
+ // keep 'close' from ever firing and hang the tool promise.
45
173
  const child = spawn('bash', ['-lc', command], {
46
174
  cwd: ctx.cwd,
47
175
  env: process.env,
@@ -49,15 +177,6 @@ export const bashTool: PiTool = {
49
177
  detached: true,
50
178
  });
51
179
 
52
- const killTree = () => {
53
- try {
54
- if (child.pid) process.kill(-child.pid, 'SIGKILL');
55
- else child.kill('SIGKILL');
56
- } catch {
57
- try { child.kill('SIGKILL'); } catch {}
58
- }
59
- };
60
-
61
180
  const append = (chunk: Buffer) => {
62
181
  if (truncated) return;
63
182
  const remaining = OUTPUT_CAP_BYTES - Buffer.byteLength(out, 'utf-8');
@@ -79,11 +198,11 @@ export const bashTool: PiTool = {
79
198
 
80
199
  const timer = setTimeout(() => {
81
200
  timedOut = true;
82
- killTree();
201
+ killTreeWithGrace(child);
83
202
  }, timeout);
84
203
 
85
204
  const onAbort = () => {
86
- killTree();
205
+ killTreeWithGrace(child);
87
206
  };
88
207
  ctx.signal?.addEventListener('abort', onAbort);
89
208
 
@@ -130,3 +249,60 @@ export const bashTool: PiTool = {
130
249
  });
131
250
  },
132
251
  };
252
+
253
+ export const bashOutputTool: PiTool = {
254
+ name: 'BashOutput',
255
+ description: 'Read NEW output from a background Bash job since the last read, plus its status.',
256
+ inputSchema: {
257
+ type: 'object',
258
+ properties: {
259
+ bash_id: { type: 'string', description: 'The job id returned by Bash with run_in_background.' },
260
+ },
261
+ required: ['bash_id'],
262
+ },
263
+ async run(input) {
264
+ const id = typeof input?.bash_id === 'string' ? input.bash_id : (typeof input?.shell_id === 'string' ? input.shell_id : '');
265
+ const job = jobs.get(id);
266
+ if (!job) {
267
+ const known = Array.from(jobs.keys()).join(', ') || 'none';
268
+ return { output: `No background job "${id}". Known jobs: ${known}.`, isError: true };
269
+ }
270
+ const fresh = job.out.slice(job.readOffset);
271
+ job.readOffset = job.out.length;
272
+ const tail = job.truncated ? '\n[output capped at 200 KB]' : '';
273
+ // Deliberately NO delete here: 'exit' can fire before the stdio pipes
274
+ // drain, so an eager delete lost the output tail and made a follow-up
275
+ // poll error out (review PI-D-3). The reap timer frees finished jobs.
276
+ return {
277
+ output: `${jobStatusLine(job)}\n${fresh || '(no new output)'}${tail}`,
278
+ isError: job.exited && !job.killed && job.exitCode !== 0,
279
+ };
280
+ },
281
+ };
282
+
283
+ export const killShellTool: PiTool = {
284
+ name: 'KillShell',
285
+ description: 'Stop a background Bash job started with run_in_background.',
286
+ inputSchema: {
287
+ type: 'object',
288
+ properties: {
289
+ shell_id: { type: 'string', description: 'The job id to stop.' },
290
+ },
291
+ required: ['shell_id'],
292
+ },
293
+ async run(input) {
294
+ const id = typeof input?.shell_id === 'string' ? input.shell_id : (typeof input?.bash_id === 'string' ? input.bash_id : '');
295
+ const job = jobs.get(id);
296
+ if (!job) {
297
+ const known = Array.from(jobs.keys()).join(', ') || 'none';
298
+ return { output: `No background job "${id}". Known jobs: ${known}.`, isError: true };
299
+ }
300
+ if (job.exited) {
301
+ jobs.delete(id);
302
+ return { output: `Job ${id} had already finished ${jobStatusLine(job)}.` };
303
+ }
304
+ job.killed = true;
305
+ killTreeWithGrace(job.child);
306
+ return { output: `Killing job ${id} (SIGTERM, then SIGKILL after ${TERM_GRACE_MS / 1000}s).` };
307
+ },
308
+ };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Glob tool — find files by name pattern, newest first (audit D5-4).
3
+ * Mirrors the Claude SDK Glob's contract; shares the walk + pattern
4
+ * translation with Grep.
5
+ */
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import type { PiTool } from './types.js';
9
+ import { safeResolve, displayPath } from './path-safety.js';
10
+ import { globToRegExp } from './grep.js';
11
+
12
+ const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', 'dist-bloby', 'build', '.cache', '.next', 'coverage', '.venv', '__pycache__']);
13
+ const MAX_RESULTS = 100;
14
+ const MAX_FILES_SCANNED = 20_000;
15
+
16
+ export const globTool: PiTool = {
17
+ name: 'Glob',
18
+ description:
19
+ 'Find files by name pattern (e.g. "**/*.ts", "src/**/config.*"). Returns matching paths sorted newest-first.',
20
+ inputSchema: {
21
+ type: 'object',
22
+ properties: {
23
+ pattern: { type: 'string', description: 'Glob pattern to match file paths against.' },
24
+ path: { type: 'string', description: 'Directory to search in (default: workspace root).' },
25
+ },
26
+ required: ['pattern'],
27
+ },
28
+
29
+ async run(input, ctx) {
30
+ const pattern = typeof input?.pattern === 'string' ? input.pattern.trim() : '';
31
+ if (!pattern) return { output: 'pattern is required.', isError: true };
32
+
33
+ let root: string;
34
+ try {
35
+ root = safeResolve(ctx.cwd, typeof input?.path === 'string' && input.path.trim() ? input.path : '.');
36
+ } catch (err: any) {
37
+ return { output: err.message, isError: true };
38
+ }
39
+
40
+ const re = globToRegExp(pattern);
41
+ const found: { p: string; mtime: number }[] = [];
42
+ const stack = [root];
43
+ let scanned = 0;
44
+ let truncatedScan = false;
45
+
46
+ while (stack.length > 0) {
47
+ if (ctx.signal?.aborted) break;
48
+ const dir = stack.pop()!;
49
+ let entries: fs.Dirent[];
50
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
51
+ for (const e of entries) {
52
+ const full = path.join(dir, e.name);
53
+ if (e.isDirectory()) {
54
+ if (!SKIP_DIRS.has(e.name)) stack.push(full);
55
+ continue;
56
+ }
57
+ if (!e.isFile()) continue;
58
+ if (++scanned > MAX_FILES_SCANNED) { truncatedScan = true; break; }
59
+ const rel = path.relative(root, full).split(path.sep).join('/');
60
+ if (re.test(rel) || re.test(e.name)) {
61
+ let mtime = 0;
62
+ try { mtime = fs.statSync(full).mtimeMs; } catch {}
63
+ found.push({ p: displayPath(ctx.cwd, full), mtime });
64
+ }
65
+ }
66
+ if (truncatedScan) break;
67
+ }
68
+
69
+ if (found.length === 0) {
70
+ return { output: 'No files matched.' + (truncatedScan ? ` [Scan stopped at ${MAX_FILES_SCANNED} files — narrow the path]` : '') };
71
+ }
72
+ found.sort((a, b) => b.mtime - a.mtime);
73
+ const shown = found.slice(0, MAX_RESULTS);
74
+ const tail = found.length > MAX_RESULTS || truncatedScan
75
+ ? `\n\n[${found.length} matches — showing the ${shown.length} most recent]`
76
+ : '';
77
+ return { output: shown.map((f) => f.p).join('\n') + tail };
78
+ },
79
+ };
@@ -11,10 +11,19 @@ import type { PiToolDef } from '../providers/types.js';
11
11
  import { readTool } from './read.js';
12
12
  import { writeTool } from './write.js';
13
13
  import { editTool } from './edit.js';
14
- import { bashTool } from './bash.js';
14
+ import { bashTool, bashOutputTool, killShellTool } from './bash.js';
15
+ import { grepTool } from './grep.js';
16
+ import { globTool } from './glob.js';
17
+ import { todoWriteTool } from './todo-write.js';
18
+ import { webFetchTool } from './web-fetch.js';
15
19
  import { taskTool, taskToolDef } from './task.js';
16
20
 
17
- export const PI_TOOLS: PiTool[] = [readTool, writeTool, editTool, bashTool, taskTool];
21
+ export const PI_TOOLS: PiTool[] = [
22
+ readTool, writeTool, editTool,
23
+ bashTool, bashOutputTool, killShellTool,
24
+ grepTool, globTool, todoWriteTool, webFetchTool,
25
+ taskTool,
26
+ ];
18
27
 
19
28
  const TOOL_BY_NAME = new Map<string, PiTool>();
20
29
  for (const t of PI_TOOLS) {
@@ -33,13 +42,16 @@ export function findTool(name: string): PiTool | undefined {
33
42
  return TOOL_BY_NAME.get(name) || TOOL_BY_NAME.get(name.toLowerCase());
34
43
  }
35
44
 
36
- export function toolDefsForProvider(opts?: { forSubagent?: boolean }): PiToolDef[] {
45
+ export function toolDefsForProvider(opts?: { withTask?: boolean }): PiToolDef[] {
37
46
  const defs: PiToolDef[] = [];
38
47
  for (const t of PI_TOOLS) {
39
48
  if (t.name === 'Task') {
40
- // Children cannot spawn grandchildren (Claude SDK parity) a child that
41
- // hallucinates a Task call still fails gracefully (ctx.tasks is unset).
42
- if (opts?.forSubagent) continue;
49
+ // Task is opt-in: only live PARENT conversations have a task host.
50
+ // Sub-agent children can't spawn grandchildren (Claude SDK parity) and
51
+ // one-shots (pulse/cron/customer/agent-API) have no host either — a
52
+ // session that hallucinates a Task call still fails gracefully
53
+ // (ctx.tasks is unset).
54
+ if (!opts?.withTask) continue;
43
55
  // Rebuilt fresh so agent-roster/prompt edits apply per session start.
44
56
  defs.push(taskToolDef());
45
57
  continue;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * TodoWrite tool — visible task planning (audit D5-5).
3
+ *
4
+ * The tool itself is a no-op acknowledgement: its value is the bot:tool event
5
+ * the session already emits for every tool_use — on the dashboard that commits
6
+ * the streamed text as its own bubble (the plan "rhythm" claude users see),
7
+ * and on WhatsApp/Telegram it flushes the pending chunk as an intermediate
8
+ * progress message. Claude's TodoWrite works the same way from the harness's
9
+ * point of view. Deliberately NOT in FILE_TOOL_NAMES — updating a plan must
10
+ * never trigger a backend restart.
11
+ */
12
+ import type { PiTool } from './types.js';
13
+
14
+ export const todoWriteTool: PiTool = {
15
+ name: 'TodoWrite',
16
+ description:
17
+ 'Maintain a visible todo list for multi-step tasks. Call it when you start a task (all items pending, ' +
18
+ 'first in_progress) and again after each step completes. Exactly one item should be in_progress at a time.',
19
+ inputSchema: {
20
+ type: 'object',
21
+ properties: {
22
+ todos: {
23
+ type: 'array',
24
+ description: 'The complete, updated todo list (replaces the previous one).',
25
+ items: {
26
+ type: 'object',
27
+ properties: {
28
+ content: { type: 'string', description: 'Imperative description, e.g. "Add the API route".' },
29
+ status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
30
+ activeForm: { type: 'string', description: 'Present-continuous label shown while in_progress.' },
31
+ },
32
+ required: ['content', 'status'],
33
+ },
34
+ },
35
+ },
36
+ required: ['todos'],
37
+ },
38
+
39
+ async run(input) {
40
+ const todos = Array.isArray(input?.todos) ? input.todos : [];
41
+ if (todos.length === 0) return { output: 'Todo list cleared.' };
42
+ const done = todos.filter((t: any) => t?.status === 'completed').length;
43
+ return { output: `Todo list updated (${done}/${todos.length} completed).` };
44
+ },
45
+ };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * WebFetch tool — fetch a URL and return readable text (audit D5-9).
3
+ *
4
+ * Hand-rolled, no deps: global fetch with a hard timeout, http(s)-only,
5
+ * naive HTML→text strip, capped output. There is deliberately NO WebSearch
6
+ * counterpart — claude's runs on Anthropic's server-side search; a hand-rolled
7
+ * one would need a scrape target or API key, so the pi prompt tells the model
8
+ * to ask for URLs instead.
9
+ */
10
+ import type { PiTool } from './types.js';
11
+
12
+ const FETCH_TIMEOUT_MS = 30_000;
13
+ const MAX_OUTPUT_CHARS = 50_000;
14
+ /** Raw-byte read budget — HTML needs headroom over the text cap (markup is
15
+ * stripped), but the body must never be fully buffered: a multi-GB URL would
16
+ * otherwise spike the single supervisor process (review D-TOOLS-6). */
17
+ const MAX_BODY_BYTES = 2 * 1024 * 1024;
18
+ const MAX_REDIRECTS_NOTE = 'follow'; // fetch follows redirects by default
19
+
20
+ /** Stream-read up to MAX_BODY_BYTES, then cancel the rest of the body. */
21
+ async function readBodyCapped(res: Response): Promise<{ text: string; bodyTruncated: boolean }> {
22
+ if (!res.body) return { text: '', bodyTruncated: false };
23
+ const reader = res.body.getReader();
24
+ const decoder = new TextDecoder();
25
+ let text = '';
26
+ let bytes = 0;
27
+ let bodyTruncated = false;
28
+ try {
29
+ while (true) {
30
+ const { value, done } = await reader.read();
31
+ if (done) break;
32
+ if (value) {
33
+ bytes += value.byteLength;
34
+ text += decoder.decode(value, { stream: true });
35
+ if (bytes >= MAX_BODY_BYTES) {
36
+ bodyTruncated = true;
37
+ try { await reader.cancel(); } catch {}
38
+ break;
39
+ }
40
+ }
41
+ }
42
+ } finally {
43
+ try { reader.releaseLock(); } catch {}
44
+ }
45
+ text += decoder.decode();
46
+ return { text, bodyTruncated };
47
+ }
48
+
49
+ function htmlToText(html: string): string {
50
+ return html
51
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
52
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
53
+ .replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
54
+ .replace(/<!--[\s\S]*?-->/g, ' ')
55
+ .replace(/<br\s*\/?>/gi, '\n')
56
+ .replace(/<\/(p|div|h[1-6]|li|tr|section|article|header|footer)>/gi, '\n')
57
+ .replace(/<[^>]+>/g, ' ')
58
+ .replace(/&nbsp;/g, ' ')
59
+ .replace(/&amp;/g, '&')
60
+ .replace(/&lt;/g, '<')
61
+ .replace(/&gt;/g, '>')
62
+ .replace(/&quot;/g, '"')
63
+ .replace(/&#39;/g, "'")
64
+ .replace(/[ \t]+/g, ' ')
65
+ .replace(/\n\s*\n\s*\n+/g, '\n\n')
66
+ .trim();
67
+ }
68
+
69
+ export const webFetchTool: PiTool = {
70
+ name: 'WebFetch',
71
+ description:
72
+ 'Fetch a web page or API endpoint over HTTP(S) and return its readable text content. ' +
73
+ 'HTML is stripped to text; JSON and plain text are returned as-is (capped).',
74
+ inputSchema: {
75
+ type: 'object',
76
+ properties: {
77
+ url: { type: 'string', description: 'Absolute http:// or https:// URL to fetch.' },
78
+ },
79
+ required: ['url'],
80
+ },
81
+
82
+ async run(input, ctx) {
83
+ const raw = typeof input?.url === 'string' ? input.url.trim() : '';
84
+ let url: URL;
85
+ try {
86
+ url = new URL(raw);
87
+ } catch {
88
+ return { output: `Invalid URL: ${raw || '(empty)'}`, isError: true };
89
+ }
90
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
91
+ return { output: `Only http(s) URLs are supported (got ${url.protocol}).`, isError: true };
92
+ }
93
+
94
+ const ctl = new AbortController();
95
+ const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
96
+ const onOuterAbort = () => ctl.abort();
97
+ ctx.signal?.addEventListener('abort', onOuterAbort, { once: true });
98
+ try {
99
+ const res = await fetch(url, {
100
+ signal: ctl.signal,
101
+ redirect: MAX_REDIRECTS_NOTE,
102
+ headers: { 'user-agent': 'Bloby/1.0 (+https://bloby.bot)', accept: 'text/html,application/json,text/plain,*/*' },
103
+ });
104
+ const declared = Number(res.headers.get('content-length') || 0);
105
+ if (declared > 50 * 1024 * 1024) {
106
+ try { await res.body?.cancel(); } catch {}
107
+ return { output: `Response too large to fetch (${Math.round(declared / 1024 / 1024)} MB). Use Bash (curl) to download it to disk instead.`, isError: true };
108
+ }
109
+ const { text: body, bodyTruncated } = await readBodyCapped(res);
110
+ const contentType = res.headers.get('content-type') || '';
111
+ const text = /text\/html|application\/xhtml/i.test(contentType) ? htmlToText(body) : body.trim();
112
+ const capped = text.length > MAX_OUTPUT_CHARS || bodyTruncated
113
+ ? `${text.slice(0, MAX_OUTPUT_CHARS)}\n\n[Truncated${bodyTruncated ? ` — read stopped at ${MAX_BODY_BYTES / 1024 / 1024} MB` : ` at ${MAX_OUTPUT_CHARS} characters`}]`
114
+ : text;
115
+ if (!res.ok) {
116
+ return { output: `HTTP ${res.status} ${res.statusText} from ${url.host}\n\n${capped.slice(0, 2000)}`, isError: true };
117
+ }
118
+ return { output: capped || '(empty response body)' };
119
+ } catch (err: any) {
120
+ const msg = err?.name === 'AbortError'
121
+ ? (ctx.signal?.aborted ? 'Fetch aborted (session ended).' : `Fetch timed out after ${FETCH_TIMEOUT_MS / 1000}s.`)
122
+ : `Fetch failed: ${err?.message || err}`;
123
+ return { output: msg, isError: true };
124
+ } finally {
125
+ clearTimeout(timer);
126
+ ctx.signal?.removeEventListener('abort', onOuterAbort);
127
+ }
128
+ },
129
+ };