bloby-bot 0.70.12 → 0.70.13
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 +11 -3
- package/dist-bloby/assets/{bloby-DSNB0g4w.js → bloby-CU9KhQdP.js} +4 -4
- package/dist-bloby/assets/globals-DlPtwiZL.css +2 -0
- package/dist-bloby/assets/{globals-B3cTbITX.js → globals-mGpojCOe.js} +1 -1
- package/dist-bloby/assets/{highlighted-body-OFNGDK62-BLforpkr.js → highlighted-body-OFNGDK62-D0Tm_wgU.js} +1 -1
- package/dist-bloby/assets/mermaid-GHXKKRXX-B95J3s3s.js +1 -0
- package/dist-bloby/assets/{onboard-Dn2Ws_G2.js → onboard-GfjHF9nm.js} +1 -1
- package/dist-bloby/bloby.html +3 -3
- package/dist-bloby/onboard.html +3 -3
- package/package.json +2 -2
- package/scripts/install +15 -7
- package/scripts/install.ps1 +35 -14
- package/scripts/install.sh +15 -7
- package/shared/relay.ts +3 -1
- package/supervisor/channels/manager.ts +16 -11
- package/supervisor/chat/OnboardWizard.tsx +0 -15
- package/supervisor/harnesses/pi/index.ts +320 -100
- package/supervisor/harnesses/pi/providers/humanize-error.ts +2 -2
- package/supervisor/harnesses/pi/providers/retry.ts +31 -0
- package/supervisor/harnesses/pi/providers/stream-anthropic.ts +23 -3
- package/supervisor/harnesses/pi/providers/stream-google.ts +21 -3
- package/supervisor/harnesses/pi/providers/stream-openai-completions.ts +17 -3
- package/supervisor/harnesses/pi/providers/types.ts +11 -0
- package/supervisor/harnesses/pi/session.ts +116 -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 +36 -2
- 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
|
@@ -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
|
+
};
|
package/supervisor/index.ts
CHANGED
|
@@ -4311,6 +4311,23 @@ ${alreadyLinked ? '' : `
|
|
|
4311
4311
|
log.warn('Local server readiness probe timed out');
|
|
4312
4312
|
}
|
|
4313
4313
|
|
|
4314
|
+
// Register the (stable) named-tunnel URL with the relay and start heartbeats —
|
|
4315
|
+
// mirrors the quick branch. Without this, a bot that holds a relay handle but runs
|
|
4316
|
+
// a named tunnel is never marked online: its <user>.bloby.bot handle goes stale and
|
|
4317
|
+
// 503s on both page loads and WS, even though the named domain itself works.
|
|
4318
|
+
if (config.relay?.token) {
|
|
4319
|
+
try {
|
|
4320
|
+
await updateTunnelUrl(config.relay.token, tunnelUrl);
|
|
4321
|
+
startHeartbeat(config.relay.token, tunnelUrl);
|
|
4322
|
+
if (config.relay.url) {
|
|
4323
|
+
log.ok(`Relay: ${config.relay.url}`);
|
|
4324
|
+
console.log(`__RELAY_URL__=${config.relay.url}`);
|
|
4325
|
+
}
|
|
4326
|
+
} catch (err) {
|
|
4327
|
+
log.warn(`Relay: ${err instanceof Error ? err.message : err}`);
|
|
4328
|
+
}
|
|
4329
|
+
}
|
|
4330
|
+
|
|
4314
4331
|
console.log('__READY__');
|
|
4315
4332
|
} catch (err) {
|
|
4316
4333
|
log.warn(`Named tunnel: ${err instanceof Error ? err.message : err}`);
|
|
@@ -4331,14 +4348,31 @@ ${alreadyLinked ? '' : `
|
|
|
4331
4348
|
lastTick = now; // Update immediately so concurrent ticks don't see a stale gap
|
|
4332
4349
|
|
|
4333
4350
|
if (wakeGap || periodicCheck) {
|
|
4334
|
-
|
|
4351
|
+
// A wake-gap (a tick delayed >60s) means the host was suspended — laptop/Mac slept.
|
|
4352
|
+
// For a QUICK tunnel, cloudflared almost always survives suspension as a live process
|
|
4353
|
+
// while its *.trycloudflare.com edge hostname has already been reclaimed. isTunnelAlive()
|
|
4354
|
+
// only checks process liveness + localhost health (never the public URL), so it returns
|
|
4355
|
+
// a false positive and the bot would stay unreachable with no self-heal. So on a wake-gap
|
|
4356
|
+
// we force a fresh quick-tunnel rotation instead of trusting the local probe. Named
|
|
4357
|
+
// tunnels keep a stable URL and auto-reconnect, so they take the normal liveness path;
|
|
4358
|
+
// the routine periodicCheck also stays on isTunnelAlive (don't churn the URL every 5min).
|
|
4359
|
+
const forceQuickRotation = wakeGap && config.tunnel.mode === 'quick';
|
|
4360
|
+
const alive = forceQuickRotation ? false : await isTunnelAlive(tunnelUrl!, config.port);
|
|
4335
4361
|
if (!alive) {
|
|
4336
|
-
log.warn(
|
|
4362
|
+
log.warn(forceQuickRotation
|
|
4363
|
+
? 'Wake detected — rotating quick tunnel (edge hostname likely stale)...'
|
|
4364
|
+
: 'Tunnel dead, restarting...');
|
|
4337
4365
|
try {
|
|
4338
4366
|
if (config.tunnel.mode === 'named') {
|
|
4339
4367
|
// Named tunnel: restart process, URL doesn't change
|
|
4340
4368
|
await restartNamedTunnel(config.tunnel.configPath!, config.tunnel.name!);
|
|
4341
4369
|
log.ok(`Named tunnel restored: ${tunnelUrl}`);
|
|
4370
|
+
// Re-assert online with the relay after the restart (URL is stable; the
|
|
4371
|
+
// heartbeat keeps running, but this immediately clears any stale offline flag).
|
|
4372
|
+
const latestCfg = loadConfig();
|
|
4373
|
+
if (latestCfg.relay?.token) {
|
|
4374
|
+
await updateTunnelUrl(latestCfg.relay.token, tunnelUrl!);
|
|
4375
|
+
}
|
|
4342
4376
|
} else {
|
|
4343
4377
|
// Quick tunnel: restart and get new URL
|
|
4344
4378
|
const newUrl = await restartTunnel(config.port);
|
package/worker/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { startClaudeOAuth, exchangeClaudeCode, getClaudeAuthStatus, readClaudeAc
|
|
|
17
17
|
import { checkAvailability, registerHandle, claimReservedHandle, releaseHandle, updateTunnelUrl, startHeartbeat, stopHeartbeat } from '../shared/relay.js';
|
|
18
18
|
import { ensureFileDirs } from '../supervisor/file-saver.js';
|
|
19
19
|
import { readPiAuth, writePiAuth, clearPiAuth, getPiAuthStatus } from '../supervisor/harnesses/pi/auth-storage.js';
|
|
20
|
-
import { runPiTestCompletion } from '../supervisor/harnesses/pi/test-completion.js';
|
|
20
|
+
import { runPiTestCompletion, runPiStreamProbe } from '../supervisor/harnesses/pi/test-completion.js';
|
|
21
21
|
import { PI_SUB_PROVIDERS, getPiSubProvider } from '../supervisor/harnesses/pi/sub-providers.js';
|
|
22
22
|
|
|
23
23
|
// ── Password hashing (scrypt) ──
|
|
@@ -284,6 +284,23 @@ app.post('/api/auth/pi/test', async (req, res) => {
|
|
|
284
284
|
}
|
|
285
285
|
const prompt = 'Reply with the single word OK so we can confirm this LLM endpoint is reachable.';
|
|
286
286
|
const result = await runPiTestCompletion({ subProvider, apiKey, baseUrl, modelId, prompt });
|
|
287
|
+
if (!result.ok) {
|
|
288
|
+
res.json(result);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
// Second tier (audit C-4): real turns stream SSE with the full tool schema —
|
|
292
|
+
// free-form model ids (Ollama/LM Studio/custom/OpenRouter) can pass the basic
|
|
293
|
+
// call and then fail the first actual message. Probe the real wire shape so
|
|
294
|
+
// the wizard's green check means chat will actually work.
|
|
295
|
+
const probe = await runPiStreamProbe({ subProvider, apiKey, baseUrl, modelId, prompt });
|
|
296
|
+
if (!probe.ok) {
|
|
297
|
+
res.json({
|
|
298
|
+
...probe,
|
|
299
|
+
ok: false,
|
|
300
|
+
error: `The endpoint responds, but streaming with tools failed (live chat would break): ${probe.error}`,
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
287
304
|
res.json(result);
|
|
288
305
|
});
|
|
289
306
|
|