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.
Files changed (44) hide show
  1. package/bin/cli.js +11 -3
  2. package/dist-bloby/assets/{bloby-DSNB0g4w.js → bloby-CU9KhQdP.js} +4 -4
  3. package/dist-bloby/assets/globals-DlPtwiZL.css +2 -0
  4. package/dist-bloby/assets/{globals-B3cTbITX.js → globals-mGpojCOe.js} +1 -1
  5. package/dist-bloby/assets/{highlighted-body-OFNGDK62-BLforpkr.js → highlighted-body-OFNGDK62-D0Tm_wgU.js} +1 -1
  6. package/dist-bloby/assets/mermaid-GHXKKRXX-B95J3s3s.js +1 -0
  7. package/dist-bloby/assets/{onboard-Dn2Ws_G2.js → onboard-GfjHF9nm.js} +1 -1
  8. package/dist-bloby/bloby.html +3 -3
  9. package/dist-bloby/onboard.html +3 -3
  10. package/package.json +2 -2
  11. package/scripts/install +15 -7
  12. package/scripts/install.ps1 +35 -14
  13. package/scripts/install.sh +15 -7
  14. package/shared/relay.ts +3 -1
  15. package/supervisor/channels/manager.ts +16 -11
  16. package/supervisor/chat/OnboardWizard.tsx +0 -15
  17. package/supervisor/harnesses/pi/index.ts +320 -100
  18. package/supervisor/harnesses/pi/providers/humanize-error.ts +2 -2
  19. package/supervisor/harnesses/pi/providers/retry.ts +31 -0
  20. package/supervisor/harnesses/pi/providers/stream-anthropic.ts +23 -3
  21. package/supervisor/harnesses/pi/providers/stream-google.ts +21 -3
  22. package/supervisor/harnesses/pi/providers/stream-openai-completions.ts +17 -3
  23. package/supervisor/harnesses/pi/providers/types.ts +11 -0
  24. package/supervisor/harnesses/pi/session.ts +116 -3
  25. package/supervisor/harnesses/pi/test-completion.ts +56 -0
  26. package/supervisor/harnesses/pi/tools/bash.ts +198 -22
  27. package/supervisor/harnesses/pi/tools/glob.ts +79 -0
  28. package/supervisor/harnesses/pi/tools/grep.ts +0 -0
  29. package/supervisor/harnesses/pi/tools/registry.ts +18 -6
  30. package/supervisor/harnesses/pi/tools/todo-write.ts +45 -0
  31. package/supervisor/harnesses/pi/tools/web-fetch.ts +129 -0
  32. package/supervisor/index.ts +36 -2
  33. package/worker/index.ts +18 -1
  34. package/worker/prompts/bloby-system-prompt-codex.txt +1 -1
  35. package/worker/prompts/bloby-system-prompt-pi.txt +6 -24
  36. package/worker/prompts/bloby-system-prompt.txt +1 -1
  37. package/workspace/client/src/components/Dashboard/DashboardPage.tsx +4 -117
  38. package/workspace/client/src/components/Dashboard/deleteme_placeholders.tsx +194 -0
  39. package/workspace/client/src/components/Layout/Sidebar.tsx +52 -30
  40. package/workspace/client/src/components/deleteme_onboarding/WorkspaceTour.tsx +25 -15
  41. package/workspace/client/src/components/deleteme_onboarding/tour-theme.css +24 -0
  42. package/workspace/skills/mac/SKILL.md +13 -4
  43. package/dist-bloby/assets/globals-DyeW509Y.css +0 -2
  44. 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
- * 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
+ };
@@ -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
- const alive = await isTunnelAlive(tunnelUrl!, config.port);
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('Tunnel dead, restarting...');
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