Files
platform/src/servers/tool-loader-source.ts
T
pastilhasandClaude Opus 5 e13128846b offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.

  api/router.ts   the thin auth-gated proxy, now at /api/offscale
  sidecar/        18 files, the whole headscale contract and its admin keys
  db/             schema + queries, offscale_servers
  web/            26 files as panels and a layout — no screen, per the rule

removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.

the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.

AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.

install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.

verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.

757 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:15:38 +00:00

240 lines
7.1 KiB
TypeScript

import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
import { Type, type TSchema } from '@sinclair/typebox';
import { readdirSync, existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
type ToolParamType = 'string' | 'number' | 'boolean' | 'enum' | 'object';
type ToolParam = {
type: ToolParamType;
description: string;
values?: string[];
default?: unknown;
optional?: boolean;
};
type ToolMeta = {
name: string;
label: string;
description: string;
language: 'typescript' | 'bash' | 'python';
inputs: Record<string, ToolParam>;
targets?: string;
};
function parseFrontmatter(content: string): { meta: Partial<ToolMeta>; body: string } {
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return { meta: {}, body: content };
const yamlBlock = match[1]!;
const body = match[2]!;
const meta: Record<string, unknown> = {};
const lines = yamlBlock.split('\n');
let currentKey: string | null = null;
let currentObj: Record<string, unknown> | null = null;
let currentSubKey: string | null = null;
let currentSubObj: Record<string, unknown> | null = null;
for (const line of lines) {
const topMatch = line.match(/^(\w[\w-]*):\s*(.*)$/);
if (topMatch && !line.startsWith(' ')) {
if (currentSubObj && currentSubKey && currentObj) {
currentObj[currentSubKey] = currentSubObj;
currentSubObj = null;
currentSubKey = null;
}
if (currentObj && currentKey) {
meta[currentKey] = currentObj;
currentObj = null;
currentKey = null;
}
const [, key, value] = topMatch;
if (!value || value.trim() === '') {
currentKey = key!;
currentObj = {};
} else {
meta[key!] = value.trim();
}
continue;
}
const midMatch = line.match(/^ (\w[\w-]*):\s*(.*)$/);
if (midMatch && currentObj !== null) {
if (currentSubObj && currentSubKey) {
currentObj[currentSubKey] = currentSubObj;
currentSubObj = null;
currentSubKey = null;
}
const [, key, value] = midMatch;
if (!value || value.trim() === '') {
currentSubKey = key!;
currentSubObj = {};
} else {
currentObj[key!] = value.trim();
}
continue;
}
const deepMatch = line.match(/^ (\w[\w-]*):\s*(.*)$/);
if (deepMatch && currentSubObj !== null) {
const [, key, value] = deepMatch;
currentSubObj[key!] = value!.trim();
continue;
}
const arrayMatch = line.match(/^ - (.+)$/);
if (arrayMatch && currentSubObj !== null) {
const key = Object.keys(currentSubObj).at(-1);
if (key) {
const arr = currentSubObj[key];
if (Array.isArray(arr)) {
arr.push(arrayMatch[1]!.trim());
} else {
currentSubObj[key] = [arrayMatch[1]!.trim()];
}
}
}
}
if (currentSubObj && currentSubKey && currentObj) {
currentObj[currentSubKey] = currentSubObj;
}
if (currentObj && currentKey) {
meta[currentKey] = currentObj;
}
return { meta: meta as Partial<ToolMeta>, body };
}
function buildSchema(inputs: Record<string, ToolParam>): TSchema {
const props: Record<string, TSchema> = {};
for (const [paramName, param] of Object.entries(inputs)) {
let schema: TSchema;
switch (param.type) {
case 'enum': {
const raw = param.values;
const values = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(',').map((v) => v.trim()) : [];
schema = Type.Union(
values.map((v) => Type.Literal(v)),
{ description: param.description },
);
break;
}
case 'number':
schema = Type.Number({ description: param.description });
break;
case 'boolean':
schema = Type.Boolean({ description: param.description });
break;
case 'object':
schema = Type.Record(Type.String(), Type.Unknown(), { description: param.description });
break;
default:
schema = Type.String({ description: param.description });
}
props[paramName] = param.optional ? Type.Optional(schema) : schema;
}
return Type.Object(props);
}
function discoverTools(dir: string): Array<{ toolDir: string; entryFile: string; meta: ToolMeta }> {
if (!existsSync(dir)) return [];
const discovered: Array<{ toolDir: string; entryFile: string; meta: ToolMeta }> = [];
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const toolDir = join(dir, entry.name);
const toolMdPath = join(toolDir, 'TOOL.md');
if (!existsSync(toolMdPath)) continue;
const indexTs = join(toolDir, 'index.ts');
const indexJs = join(toolDir, 'index.js');
const entryFile = existsSync(indexTs) ? indexTs : existsSync(indexJs) ? indexJs : null;
if (!entryFile) {
console.warn(`[tool-loader] Skipping ${entry.name}: no index.ts or index.js found`);
continue;
}
const content = readFileSync(toolMdPath, 'utf-8');
const { meta } = parseFrontmatter(content);
if (!meta.name || !meta.description) {
console.warn(`[tool-loader] Skipping ${entry.name}: missing name or description in TOOL.md`);
continue;
}
if (((meta.targets as string) ?? 'all') === 'claude') continue;
discovered.push({ toolDir, entryFile, meta: meta as ToolMeta });
}
return discovered;
}
export default function (pi: ExtensionAPI) {
const rawDirs = process.env.PI_TOOLS_DIRS ?? '';
const toolDirs = rawDirs.split(':').filter(Boolean);
if (toolDirs.length === 0) {
console.warn('[tool-loader] PI_TOOLS_DIRS not set — no custom tools will be loaded');
return;
}
const seen = new Set<string>();
for (const dir of toolDirs) {
const tools = discoverTools(dir);
for (const { entryFile, meta } of tools) {
if (seen.has(meta.name)) continue;
seen.add(meta.name);
const schema = buildSchema(meta.inputs ?? {});
const capturedEntry = entryFile;
pi.registerTool({
name: meta.name,
label: meta.label ?? meta.name,
description: meta.description,
parameters: schema,
async execute(toolCallId, params, signal, onUpdate, ctx) {
let executeFn: Function | undefined;
try {
const mod = await import(capturedEntry);
executeFn = mod.execute ?? mod.default?.execute;
} catch (err) {
return {
content: [{ type: 'text', text: `[tool-loader] Failed to load ${meta.name}: ${String(err)}` }],
details: { error: String(err) },
isError: true,
};
}
if (typeof executeFn !== 'function') {
return {
content: [
{ type: 'text', text: `[tool-loader] ${meta.name}/index.ts must export an "execute" function` },
],
details: {},
isError: true,
};
}
return executeFn(toolCallId, params, signal, onUpdate, ctx);
},
});
console.log(`[tool-loader] Registered tool: ${meta.name} (${capturedEntry})`);
}
}
}