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.
- package/bin/cli.js +234 -48
- package/dist-bloby/assets/{bloby-DSNB0g4w.js → bloby-es6cZJzs.js} +6 -6
- package/dist-bloby/assets/globals-DBqwNiJV.css +2 -0
- package/dist-bloby/assets/{globals-B3cTbITX.js → globals-DN3F0CQE.js} +1 -1
- package/dist-bloby/assets/{highlighted-body-OFNGDK62-BLforpkr.js → highlighted-body-OFNGDK62-8PiOHw9p.js} +1 -1
- package/dist-bloby/assets/mermaid-GHXKKRXX-BJWX8urU.js +1 -0
- package/dist-bloby/assets/{onboard-Dn2Ws_G2.js → onboard-BKgy17OU.js} +1 -1
- package/dist-bloby/bloby.html +3 -3
- package/dist-bloby/onboard.html +3 -3
- package/package.json +3 -4
- package/scripts/install +156 -41
- package/scripts/install.ps1 +146 -29
- package/scripts/install.sh +156 -41
- package/shared/config.ts +37 -2
- package/shared/relay.ts +3 -1
- package/supervisor/channels/manager.ts +84 -44
- package/supervisor/channels/telegram.ts +57 -16
- package/supervisor/channels/types.ts +4 -1
- package/supervisor/channels/whatsapp.ts +57 -10
- package/supervisor/chat/OnboardWizard.tsx +0 -15
- package/supervisor/chat/src/components/Chat/AudioBubble.tsx +1 -1
- package/supervisor/chat/src/components/Chat/AuthedImage.tsx +16 -3
- package/supervisor/chat/src/components/Chat/BlobyImageCard.tsx +2 -2
- package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +25 -8
- package/supervisor/chat/src/components/Chat/InputBar.tsx +62 -7
- package/supervisor/chat/src/components/Chat/MessageBubble.tsx +37 -18
- package/supervisor/chat/src/components/Chat/MessageList.tsx +3 -3
- package/supervisor/chat/src/hooks/useChat.ts +52 -0
- package/supervisor/chat/src/lib/authedFile.ts +24 -12
- package/supervisor/file-saver.ts +92 -19
- package/supervisor/harnesses/attachment-policy.ts +111 -0
- package/supervisor/harnesses/claude.ts +62 -15
- package/supervisor/harnesses/codex.ts +69 -43
- package/supervisor/harnesses/pi/index.ts +367 -112
- package/supervisor/harnesses/pi/providers/humanize-error.ts +27 -2
- package/supervisor/harnesses/pi/providers/retry.ts +31 -0
- package/supervisor/harnesses/pi/providers/stream-anthropic.ts +31 -3
- package/supervisor/harnesses/pi/providers/stream-google.ts +26 -3
- package/supervisor/harnesses/pi/providers/stream-openai-completions.ts +32 -9
- package/supervisor/harnesses/pi/providers/types.ts +29 -1
- package/supervisor/harnesses/pi/session.ts +143 -3
- package/supervisor/harnesses/pi/test-completion.ts +56 -0
- package/supervisor/harnesses/pi/tools/bash.ts +198 -22
- package/supervisor/harnesses/pi/tools/glob.ts +79 -0
- package/supervisor/harnesses/pi/tools/grep.ts +0 -0
- package/supervisor/harnesses/pi/tools/registry.ts +18 -6
- package/supervisor/harnesses/pi/tools/todo-write.ts +45 -0
- package/supervisor/harnesses/pi/tools/web-fetch.ts +129 -0
- package/supervisor/index.ts +93 -18
- package/supervisor/widget.js +19 -5
- package/worker/db.ts +2 -0
- package/worker/index.ts +18 -1
- package/worker/prompts/bloby-system-prompt-codex.txt +1 -1
- package/worker/prompts/bloby-system-prompt-pi.txt +6 -24
- package/worker/prompts/bloby-system-prompt.txt +1 -1
- package/workspace/client/src/components/Dashboard/DashboardPage.tsx +4 -117
- package/workspace/client/src/components/Dashboard/deleteme_placeholders.tsx +194 -0
- package/workspace/client/src/components/Layout/Sidebar.tsx +52 -30
- package/workspace/client/src/components/deleteme_onboarding/WorkspaceTour.tsx +25 -15
- package/workspace/client/src/components/deleteme_onboarding/tour-theme.css +24 -0
- package/workspace/skills/mac/SKILL.md +13 -4
- package/dist-bloby/assets/globals-DyeW509Y.css +0 -2
- package/dist-bloby/assets/mermaid-GHXKKRXX-C1H_fSCU.js +0 -1
- package/supervisor/public/headphones_spritesheet.webp +0 -0
- 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
|
-
*
|
|
5
|
-
*
|
|
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 =
|
|
11
|
-
const HARD_TIMEOUT_MS =
|
|
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.
|
|
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
|
|
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 —
|
|
43
|
-
//
|
|
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
|
-
|
|
201
|
+
killTreeWithGrace(child);
|
|
83
202
|
}, timeout);
|
|
84
203
|
|
|
85
204
|
const onAbort = () => {
|
|
86
|
-
|
|
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
|
+
};
|
|
Binary file
|
|
@@ -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[] = [
|
|
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?: {
|
|
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
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
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(/ /g, ' ')
|
|
59
|
+
.replace(/&/g, '&')
|
|
60
|
+
.replace(/</g, '<')
|
|
61
|
+
.replace(/>/g, '>')
|
|
62
|
+
.replace(/"/g, '"')
|
|
63
|
+
.replace(/'/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
|
+
};
|