@hncode/hncode 0.6.6 → 0.7.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/package.json +1 -1
- package/src/ptc-worker.js +120 -0
- package/src/ptc.js +141 -0
- package/src/registry-browser.js +90 -7
- package/src/tools/index.js +2 -0
- package/src/tools/ptc.js +91 -0
- package/src/tui.js +14 -5
package/package.json
CHANGED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The PTC executor's WORKER SIDE — what actually runs the model's code.
|
|
2
|
+
//
|
|
3
|
+
// This file is loaded as a `worker_threads` entry, never imported by the main process. Everything it can reach is
|
|
4
|
+
// what `parentPort` hands it and the globals below: no `require`, no `process` access beyond what is passed in,
|
|
5
|
+
// and no filesystem. The model's code is compiled inside a `vm` context whose only interesting binding is
|
|
6
|
+
// `callTool`, which messages the main thread and waits.
|
|
7
|
+
//
|
|
8
|
+
// WHY A WORKER AND NOT vm ALONE: `vm` is not a security boundary — code in a context can still reach the host
|
|
9
|
+
// through a prototype it was handed. The thread is what makes a runaway loop killable: `worker.terminate()` stops
|
|
10
|
+
// it without the main process ever yielding, which a `while(true)` in `vm.runInContext` would not.
|
|
11
|
+
//
|
|
12
|
+
// The code runs as an ASYNC body, so `await callTool(...)` works exactly as it reads.
|
|
13
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
14
|
+
import vm from 'node:vm';
|
|
15
|
+
|
|
16
|
+
const TOOL_NAMES = Array.isArray(workerData && workerData.toolNames) ? workerData.toolNames : [];
|
|
17
|
+
const TIMEOUT_MS = Number(workerData && workerData.timeoutMs) > 0 ? workerData.timeoutMs : 120000;
|
|
18
|
+
|
|
19
|
+
let seq = 0;
|
|
20
|
+
const pending = new Map();
|
|
21
|
+
|
|
22
|
+
/** Ask the main thread to run a tool, and wait for its answer. */
|
|
23
|
+
function callTool(name, args) {
|
|
24
|
+
const id = ++seq;
|
|
25
|
+
parentPort.postMessage({ type: 'tool', id, name, args: args == null ? {} : args });
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
pending.set(id, { resolve, reject });
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
parentPort.on('message', (m) => {
|
|
32
|
+
if (!m || m.type !== 'tool_result') return;
|
|
33
|
+
const p = pending.get(m.id);
|
|
34
|
+
if (!p) return;
|
|
35
|
+
pending.delete(m.id);
|
|
36
|
+
if (m.ok) p.resolve(m.result);
|
|
37
|
+
else p.reject(new Error(String(m.error || 'tool failed')));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ---- the sandbox ---------------------------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/** Collect printed lines. `print` is what the model reaches for. */
|
|
43
|
+
const out = [];
|
|
44
|
+
const write = (...parts) => {
|
|
45
|
+
const line = parts.map((p) => {
|
|
46
|
+
if (typeof p === 'string') return p;
|
|
47
|
+
try { return JSON.stringify(p); } catch { return String(p); }
|
|
48
|
+
}).join(' ');
|
|
49
|
+
// A single line cap keeps a runaway from filling memory before the byte cap is checked.
|
|
50
|
+
out.push(line.length > 8000 ? line.slice(0, 8000) + '…' : line);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const sandbox = {
|
|
54
|
+
callTool,
|
|
55
|
+
print: write,
|
|
56
|
+
console: { log: write, error: write, warn: write, info: write },
|
|
57
|
+
JSON,
|
|
58
|
+
Math,
|
|
59
|
+
Date,
|
|
60
|
+
Promise,
|
|
61
|
+
Array,
|
|
62
|
+
Object,
|
|
63
|
+
String,
|
|
64
|
+
Number,
|
|
65
|
+
Boolean,
|
|
66
|
+
Map,
|
|
67
|
+
Set,
|
|
68
|
+
RegExp,
|
|
69
|
+
Error,
|
|
70
|
+
TypeError,
|
|
71
|
+
RangeError,
|
|
72
|
+
isNaN,
|
|
73
|
+
isFinite,
|
|
74
|
+
parseInt,
|
|
75
|
+
parseFloat,
|
|
76
|
+
// `sleep` is the one timer worth exposing: pacing between calls is a real need, and `setTimeout` would let the
|
|
77
|
+
// code keep the thread alive past the point where the caller wants an answer.
|
|
78
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, Math.max(0, Math.min(Number(ms) || 0, 10000)))),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const context = vm.createContext(sandbox, {
|
|
82
|
+
// No `codeGeneration` from strings inside the sandbox: a `new Function` there would be a second, unreviewed way
|
|
83
|
+
// in — and the body the model writes is already compiled below.
|
|
84
|
+
codeGeneration: { strings: false, wasm: false },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
async function run(code) {
|
|
88
|
+
let script;
|
|
89
|
+
try {
|
|
90
|
+
script = new vm.Script(`(async () => {\n${code}\n})()`, { filename: 'ptc-sandbox.js' });
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return { ok: false, error: `Syntax error: ${e.message}`, output: '' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
parentPort.postMessage({ type: 'timeout', ms: TIMEOUT_MS });
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}, TIMEOUT_MS);
|
|
99
|
+
if (timer.unref) timer.unref();
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const value = await script.runInContext(context, { timeout: TIMEOUT_MS });
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
// The returned value is what `return` produced, if anything; `print` already captured stdout.
|
|
105
|
+
let returned;
|
|
106
|
+
if (value !== undefined) {
|
|
107
|
+
try { returned = typeof value === 'string' ? value : JSON.stringify(value); } catch { returned = String(value); }
|
|
108
|
+
}
|
|
109
|
+
return { ok: true, output: out.join('\n'), returned };
|
|
110
|
+
} catch (e) {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
return { ok: false, error: `${e && e.name ? e.name + ': ' : ''}${e && e.message || e}`, output: out.join('\n') };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
parentPort.on('message', async (m) => {
|
|
117
|
+
if (!m || m.type !== 'run') return;
|
|
118
|
+
const result = await run(String(m.code || ''));
|
|
119
|
+
parentPort.postMessage({ type: 'done', result });
|
|
120
|
+
});
|
package/src/ptc.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// The PTC executor's MAIN side.
|
|
2
|
+
//
|
|
3
|
+
// Owns a worker, forwards the tool calls it makes to the real tool layer, and enforces the limits the sandbox
|
|
4
|
+
// cannot enforce about itself: output size, tool count, and a wall-clock deadline that survives a wedged await.
|
|
5
|
+
//
|
|
6
|
+
// WHY THE TOOL CALLS COME BACK HERE AT ALL: the tools need `ctx` — the workspace, the approval hook, the session
|
|
7
|
+
// — none of which may exist inside the sandbox. The worker sends `{type:'tool', name, args}`, this side runs the
|
|
8
|
+
// real tool through `getTool`, and the result crosses back as data. The model's code never holds a function that
|
|
9
|
+
// can reach the filesystem; it holds a promise that the host fulfils.
|
|
10
|
+
import { Worker } from 'node:worker_threads';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
/** Tools whose results are safe and useful to hand to code, and whose invocation needs no UI. */
|
|
17
|
+
const CALLABLE = new Set([
|
|
18
|
+
'Read', 'FileLines', 'Glob', 'Grep', 'Git', 'FetchURL', 'WebSearch',
|
|
19
|
+
'TodoList', 'TaskList', 'TaskOutput', 'Memory', 'ListTeammates', 'TeamTaskList',
|
|
20
|
+
'ActivateSkill', 'Goal', 'Diagnostics',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_LIMITS = {
|
|
24
|
+
// A code body that orchestrates twenty calls is already unusual; five hundred is a loop that has gone wrong.
|
|
25
|
+
maxToolCalls: 500,
|
|
26
|
+
// What comes back is the whole point of PTC — a cap keeps a runaway `print` from eating the context it saved.
|
|
27
|
+
maxOutputBytes: 32000,
|
|
28
|
+
timeoutMs: 120000,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const CALLABLE_TOOLS = [...CALLABLE];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run `code` in a sandbox, letting it call the tools in `CALLABLE`.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} code An async function BODY. `await callTool(name, args)` is available.
|
|
37
|
+
* @param {object} ctx The tool context, passed straight through to each tool.
|
|
38
|
+
* @param {object} [limits] Overrides for `DEFAULT_LIMITS`.
|
|
39
|
+
* @returns {Promise<{ok: boolean, output: string, returned?: string, error?: string, calls: string[], truncated?: boolean}>}
|
|
40
|
+
*/
|
|
41
|
+
export function runProgrammatic(code, ctx, limits = {}) {
|
|
42
|
+
const lim = { ...DEFAULT_LIMITS, ...limits };
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
let settled = false;
|
|
45
|
+
const calls = [];
|
|
46
|
+
const lines = [];
|
|
47
|
+
let bytes = 0;
|
|
48
|
+
let truncated = false;
|
|
49
|
+
|
|
50
|
+
const finish = (result) => {
|
|
51
|
+
if (settled) return;
|
|
52
|
+
settled = true;
|
|
53
|
+
clearTimeout(wall);
|
|
54
|
+
try { worker.terminate(); } catch { /* already gone */ }
|
|
55
|
+
resolve(result);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const worker = new Worker(path.join(here, 'ptc-worker.js'), {
|
|
59
|
+
workerData: { toolNames: [...CALLABLE], timeoutMs: lim.timeoutMs },
|
|
60
|
+
// The sandbox needs no stdio and no shared memory.
|
|
61
|
+
stdout: true,
|
|
62
|
+
stderr: true,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// The wall clock is the OUTER guarantee: if the worker is stuck in a `while(true)` the inner vm timeout can
|
|
66
|
+
// still fire, but if the main thread is waiting on a tool that never answers, only this one saves us.
|
|
67
|
+
const wall = setTimeout(() => {
|
|
68
|
+
finish({ ok: false, error: `timed out after ${lim.timeoutMs}ms`, output: lines.join('\n'), calls });
|
|
69
|
+
}, lim.timeoutMs + 5000);
|
|
70
|
+
|
|
71
|
+
worker.on('message', async (m) => {
|
|
72
|
+
if (!m) return;
|
|
73
|
+
|
|
74
|
+
if (m.type === 'tool') {
|
|
75
|
+
if (calls.length >= lim.maxToolCalls) {
|
|
76
|
+
worker.postMessage({ type: 'tool_result', id: m.id, ok: false, error: `tool-call limit reached (${lim.maxToolCalls})` });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const name = String(m.name || '');
|
|
80
|
+
if (!CALLABLE.has(name)) {
|
|
81
|
+
worker.postMessage({ type: 'tool_result', id: m.id, ok: false, error: `${name} is not callable from code` });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Imported here, not at the top: tools/index.js imports the PTC TOOL, which imports this module — a static
|
|
85
|
+
// import would close that cycle and read `spec` before it exists.
|
|
86
|
+
const { getTool } = await import('./tools/index.js');
|
|
87
|
+
const tool = getTool(name);
|
|
88
|
+
if (!tool || typeof tool.execute !== 'function') {
|
|
89
|
+
worker.postMessage({ type: 'tool_result', id: m.id, ok: false, error: `no such tool: ${name}` });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
calls.push(name);
|
|
93
|
+
try {
|
|
94
|
+
const raw = await tool.execute(m.args || {}, ctx);
|
|
95
|
+
// A media result is structured blocks, not text; code cannot do anything useful with it.
|
|
96
|
+
const value = (raw && typeof raw === 'object' && Array.isArray(raw.media))
|
|
97
|
+
? '[media result omitted]'
|
|
98
|
+
: (typeof raw === 'string' ? raw : JSON.stringify(raw));
|
|
99
|
+
worker.postMessage({ type: 'tool_result', id: m.id, ok: true, result: value });
|
|
100
|
+
} catch (e) {
|
|
101
|
+
worker.postMessage({ type: 'tool_result', id: m.id, ok: false, error: String((e && e.message) || e) });
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (m.type === 'timeout') {
|
|
107
|
+
finish({ ok: false, error: `timed out after ${lim.timeoutMs}ms`, output: lines.join('\n'), calls });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (m.type === 'done') {
|
|
112
|
+
const r = m.result || {};
|
|
113
|
+
let output = String(r.output == null ? '' : r.output);
|
|
114
|
+
// Cap by BYTES, not characters: the model pays for tokens, and a megabyte of ASCII is the worst case.
|
|
115
|
+
if (Buffer.byteLength(output, 'utf8') > lim.maxOutputBytes) {
|
|
116
|
+
output = output.slice(0, lim.maxOutputBytes);
|
|
117
|
+
truncated = true;
|
|
118
|
+
}
|
|
119
|
+
finish({
|
|
120
|
+
ok: !!r.ok,
|
|
121
|
+
output,
|
|
122
|
+
returned: r.returned,
|
|
123
|
+
error: r.error,
|
|
124
|
+
calls,
|
|
125
|
+
truncated: truncated || undefined,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
worker.on('error', (e) => {
|
|
131
|
+
finish({ ok: false, error: String((e && e.message) || e), output: lines.join('\n'), calls });
|
|
132
|
+
});
|
|
133
|
+
worker.on('exit', (code) => {
|
|
134
|
+
// An exit without a `done` is the worker dying: report it rather than hanging the caller for ever.
|
|
135
|
+
if (!settled) finish({ ok: false, error: `sandbox exited with code ${code}`, output: lines.join('\n'), calls });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
worker.postMessage({ type: 'run', code: String(code || '') });
|
|
139
|
+
void bytes;
|
|
140
|
+
});
|
|
141
|
+
}
|
package/src/registry-browser.js
CHANGED
|
@@ -171,6 +171,23 @@ function statusColor(tone) {
|
|
|
171
171
|
return C.blue;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* The rows a tab SHOWS, after the search filter.
|
|
176
|
+
*
|
|
177
|
+
* The renderer and the action handler must agree on this or the list lies: with a query typed,
|
|
178
|
+
* `selectedIndex` counts entries in the FILTERED list, so looking it up in the unfiltered one acts on whatever
|
|
179
|
+
* happened to sit at that position instead of the row under the cursor.
|
|
180
|
+
*
|
|
181
|
+
* The tab COUNTS stay on the unfiltered list — a filter must never make a tab look empty.
|
|
182
|
+
*/
|
|
183
|
+
export function visibleRowsForTab(state, tabId) {
|
|
184
|
+
const rows = rowsForTab(state, tabId);
|
|
185
|
+
const query = String(state && state.filter || '').trim().toLowerCase();
|
|
186
|
+
if (!query) return rows;
|
|
187
|
+
return rows.filter((r) => [r.label, r.description, r.name]
|
|
188
|
+
.map((v) => String(v == null ? '' : v)).join(' ').toLowerCase().includes(query));
|
|
189
|
+
}
|
|
190
|
+
|
|
174
191
|
export function renderRegistryBrowser(state, cols, rows) {
|
|
175
192
|
if (cols < MIN_WIDTH || rows < MIN_HEIGHT) {
|
|
176
193
|
const lines = [fitExactly(C.red + 'Terminal too small (need >= ' + MIN_WIDTH + ' x ' + MIN_HEIGHT + ')' + C.reset, cols)];
|
|
@@ -181,7 +198,9 @@ export function renderRegistryBrowser(state, cols, rows) {
|
|
|
181
198
|
const tabs = state.kind === 'skills' ? SKILL_TABS : PLUGIN_TABS;
|
|
182
199
|
const tabIdx = Math.max(0, tabs.findIndex((t) => t.id === state.tab));
|
|
183
200
|
const tab = tabs[tabIdx] || tabs[0];
|
|
184
|
-
|
|
201
|
+
// The header row and the empty-state message both need to know whether a filter is on.
|
|
202
|
+
const query = String(state.filter || '').trim().toLowerCase();
|
|
203
|
+
const all = visibleRowsForTab(state, tab.id);
|
|
185
204
|
const sel = Math.max(0, Math.min(all.length - 1, state.selectedIndex || 0));
|
|
186
205
|
|
|
187
206
|
const out = [];
|
|
@@ -197,9 +216,16 @@ export function renderRegistryBrowser(state, cols, rows) {
|
|
|
197
216
|
// The key hints stay visible while the fetch runs: the panel is usable during it
|
|
198
217
|
// (the local tabs are already loaded), so replacing the hints with a status message
|
|
199
218
|
// would hide the keys exactly when the user is most likely to press them.
|
|
200
|
-
const hint = ' Tab switch \u00b7 \u2191\u2193 select \u00b7 Enter install/remove \u00b7 Esc close';
|
|
219
|
+
const hint = ' / search \u00b7 Tab switch \u00b7 \u2191\u2193 select \u00b7 Enter install/remove \u00b7 Esc close';
|
|
201
220
|
out.push(fitExactly(C.gray + hint + (state.busy ? C.gray + ` ${state.busy}` : '') + C.reset, cols));
|
|
202
221
|
out.push('');
|
|
222
|
+
// The search field, drawn whenever it is open or has text, so a filtered list always explains itself.
|
|
223
|
+
if (state.filtering || query) {
|
|
224
|
+
const shown = String(state.filter || '');
|
|
225
|
+
const caret = state.filtering ? '\u2588' : '';
|
|
226
|
+
out.push(C.gray + ' / ' + C.reset + C.white + shown + C.reset + C.cyan + caret + C.reset
|
|
227
|
+
+ C.gray + (query ? ` ${all.length} match${all.length === 1 ? '' : 'es'}` : ' type to filter') + C.reset);
|
|
228
|
+
}
|
|
203
229
|
|
|
204
230
|
// Tab strip, with the count of each tab's rows.
|
|
205
231
|
{
|
|
@@ -240,22 +266,52 @@ export function renderRegistryBrowser(state, cols, rows) {
|
|
|
240
266
|
: ' Nothing here.';
|
|
241
267
|
out.push(C.gray + msg + C.reset);
|
|
242
268
|
}
|
|
243
|
-
|
|
269
|
+
// How many LINES each row occupies: its label plus its wrapped description.
|
|
270
|
+
const descW = Math.max(8, cols - 6);
|
|
271
|
+
const rowH = all.map((r) => 1 + wrapText(r.description, descW).length);
|
|
272
|
+
const visibleLines = Math.max(1, bodyRows);
|
|
273
|
+
|
|
274
|
+
// The first row to draw. `state.listTop` persists across frames — recomputing it from scratch each time would
|
|
275
|
+
// snap the window back to the top on every key, and the cursor would appear to leave the list.
|
|
276
|
+
let first = Math.max(0, Math.min(state.listTop || 0, Math.max(0, all.length - 1)));
|
|
277
|
+
// Scroll down while the selection does not fit; scroll up if it sits above the window.
|
|
278
|
+
if (sel < first) first = sel;
|
|
279
|
+
for (;;) {
|
|
280
|
+
let used = 0;
|
|
281
|
+
for (let i = first; i <= sel && i < all.length; i++) used += rowH[i];
|
|
282
|
+
if (used <= visibleLines || first >= sel) break;
|
|
283
|
+
first++;
|
|
284
|
+
}
|
|
285
|
+
state.listTop = first;
|
|
286
|
+
|
|
287
|
+
// Draw from `first`, stopping when the window is full. A row is never split: a label whose description fell
|
|
288
|
+
// off the bottom reads as a row that has none.
|
|
289
|
+
let drawn = 0;
|
|
290
|
+
for (let i = first; i < all.length; i++) {
|
|
291
|
+
if (drawn > 0 && drawn + rowH[i] > visibleLines) break;
|
|
292
|
+
drawn += rowH[i];
|
|
244
293
|
const r = all[i];
|
|
245
294
|
const isSel = i === sel;
|
|
246
295
|
const ptr = isSel ? C.cyan + '\u276f ' + C.reset : ' ';
|
|
247
296
|
const nameStyle = isSel ? (C.cyan + C.bold) : C.white;
|
|
248
297
|
const badge = r.status ? ' ' + statusColor(r.statusTone) + r.status + C.reset : '';
|
|
249
298
|
const dis = r.disabled ? C.gray : '';
|
|
250
|
-
const labelRow = out.length;
|
|
299
|
+
const labelRow = out.length;
|
|
251
300
|
out.push(fitExactly(ptr + nameStyle + dis + r.label + C.reset + badge, cols));
|
|
252
|
-
// The whole label row is a click target: clicking selects the item (and a
|
|
253
|
-
//
|
|
301
|
+
// The whole label row is a click target: clicking selects the item (and a second click / the footer
|
|
302
|
+
// activates it), mirroring the tasks browser.
|
|
254
303
|
hits.push({ row: labelRow, col0: 0, col1: cols - 1, kind: 'regRow', index: i });
|
|
255
|
-
for (const line of wrapText(r.description,
|
|
304
|
+
for (const line of wrapText(r.description, descW)) {
|
|
256
305
|
out.push(C.gray + ' ' + line + C.reset);
|
|
257
306
|
}
|
|
258
307
|
}
|
|
308
|
+
// Say when the list continues: a clipped list that looks complete is how a skill goes missing.
|
|
309
|
+
let after = first;
|
|
310
|
+
let acc = 0;
|
|
311
|
+
while (after < all.length && acc < drawn) { acc += rowH[after]; after++; }
|
|
312
|
+
const hiddenBelow = all.length - after;
|
|
313
|
+
if (first > 0) out.push(C.gray + ` \u2191 \u2191 \u2191 ${first} above` + C.reset);
|
|
314
|
+
if (hiddenBelow > 0) out.push(C.gray + ` \u2193 \u2193 \u2193 ${hiddenBelow} below` + C.reset);
|
|
259
315
|
// A fetch failure has to show even when the LOCAL half has rows, or the Available
|
|
260
316
|
// tab silently looks empty and the user cannot tell why.
|
|
261
317
|
if (all.length && state.data && state.data.remoteError && tab.id === 'available') {
|
|
@@ -291,9 +347,36 @@ export function renderRegistryBrowser(state, cols, rows) {
|
|
|
291
347
|
* 'close' | 'switchTab' | 'select' | 'install' | 'remove' | 'reload' | null
|
|
292
348
|
*/
|
|
293
349
|
export function handleRegistryBrowserKey(state, t) {
|
|
350
|
+
|
|
294
351
|
const tabs = state.kind === 'skills' ? SKILL_TABS : PLUGIN_TABS;
|
|
295
352
|
const all = rowsForTab(state, state.tab);
|
|
296
353
|
const ch = t.ch;
|
|
354
|
+
// ---- filter input -------------------------------------------------------------------------
|
|
355
|
+
//
|
|
356
|
+
// While `state.filtering` is on, every printable key goes into the query instead of acting on the list. Esc
|
|
357
|
+
// leaves the field without clearing the filter, so a half-typed word is not lost by a stray key; a second Esc
|
|
358
|
+
// clears it. Enter accepts the filter and returns the keyboard to the list.
|
|
359
|
+
if (state.filtering) {
|
|
360
|
+
if (t.key === 'escape') { state.filtering = false; return 'select'; }
|
|
361
|
+
if (t.key === 'enter') { state.filtering = false; return 'select'; }
|
|
362
|
+
if (t.key === 'backspace') {
|
|
363
|
+
state.filter = String(state.filter || '').slice(0, -1);
|
|
364
|
+
state.selectedIndex = 0;
|
|
365
|
+
state.listTop = 0;
|
|
366
|
+
return 'select';
|
|
367
|
+
}
|
|
368
|
+
if (t.key === 'up' || t.key === 'down') { state.filtering = false; } // hand the arrows back to the list
|
|
369
|
+
else if (ch && ch.length === 1 && ch >= ' ') {
|
|
370
|
+
state.filter = String(state.filter || '') + ch;
|
|
371
|
+
state.selectedIndex = 0;
|
|
372
|
+
state.listTop = 0;
|
|
373
|
+
return 'select';
|
|
374
|
+
} else { return null; }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// `/` opens the field; a set filter stays visible in the header and Esc clears it from the list too.
|
|
378
|
+
if (ch === '/') { state.filtering = true; return 'select'; }
|
|
379
|
+
if (t.key === 'escape' && state.filter) { state.filter = ''; state.selectedIndex = 0; state.listTop = 0; return 'select'; }
|
|
297
380
|
|
|
298
381
|
if (t.key === 'escape' || ch === 'q' || ch === 'Q') return 'close';
|
|
299
382
|
if (t.key === 'tab') {
|
package/src/tools/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import { spec as diagnosticsSpec } from './diagnostics.js';
|
|
|
24
24
|
import * as memoryMod from './memory.js';
|
|
25
25
|
import * as goalMod from './goal.js';
|
|
26
26
|
import * as activateSkillMod from './activate-skill.js';
|
|
27
|
+
import * as ptcMod from './ptc.js';
|
|
27
28
|
import { pluginTools, pluginToolGeneration } from '../plugin.js';
|
|
28
29
|
|
|
29
30
|
|
|
@@ -59,6 +60,7 @@ const builtinTools = [
|
|
|
59
60
|
memoryMod.spec,
|
|
60
61
|
goalMod.spec,
|
|
61
62
|
activateSkillMod.spec,
|
|
63
|
+
ptcMod.spec,
|
|
62
64
|
...TEAM_TOOL_SPECS,
|
|
63
65
|
];
|
|
64
66
|
|
package/src/tools/ptc.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProgrammaticToolCall — let the model orchestrate tools with code instead of one round trip each.
|
|
3
|
+
*
|
|
4
|
+
* THE PROBLEM IT SOLVES: a task like "which of these forty files reference the removed symbol" is forty calls,
|
|
5
|
+
* each with its own inference pass, and every one of those results then sits in the context whether or not it
|
|
6
|
+
* mattered. The reading is mechanical; only the conclusion is worth the tokens.
|
|
7
|
+
*
|
|
8
|
+
* With this tool the model writes the LOOP, and only what it prints comes back. The sandbox holds the forty
|
|
9
|
+
* results, the filtering and the joining; the conversation holds the six files that matched.
|
|
10
|
+
*
|
|
11
|
+
* WHY A SEPARATE TOOL AND NOT `Bash`: a shell would give the model the whole machine and no structure. Here every
|
|
12
|
+
* call goes back through the tool layer — the same approval rules, the same workspace guard, the same tools —
|
|
13
|
+
* and the code can only reach the ones on the allowlist. See src/ptc.js for that list, and ptc-worker.js for the
|
|
14
|
+
* sandbox itself.
|
|
15
|
+
*/
|
|
16
|
+
import { runProgrammatic, CALLABLE_TOOLS } from '../ptc.js';
|
|
17
|
+
|
|
18
|
+
const DESCRIPTION = [
|
|
19
|
+
'Orchestrate tools with JavaScript instead of calling them one at a time.',
|
|
20
|
+
'',
|
|
21
|
+
'WHEN TO USE IT: work that is a LOOP over tool results, or that needs intermediate data the model should not',
|
|
22
|
+
'have to read. Reading twenty files to find the three that mention a symbol; checking every changed file for a',
|
|
23
|
+
'pattern; collecting values from many calls and printing only the ones that matter. The intermediate results',
|
|
24
|
+
'stay inside the sandbox and never enter the conversation — that is the point.',
|
|
25
|
+
'',
|
|
26
|
+
'WHEN NOT TO USE IT: one or two calls, or when you need to READ a result and decide what to do next. Then an',
|
|
27
|
+
'ordinary tool call is shorter and clearer.',
|
|
28
|
+
'',
|
|
29
|
+
'HOW TO WRITE IT: the body of an async function. `await callTool(name, args)` runs a tool and resolves to its',
|
|
30
|
+
'text result. `print(...)` writes a line to the output; `console.log` is the same. Whatever you `return` is',
|
|
31
|
+
'appended. Nothing reaches the conversation except what you print or return.',
|
|
32
|
+
'',
|
|
33
|
+
'Example:',
|
|
34
|
+
'```',
|
|
35
|
+
"const hits = [];",
|
|
36
|
+
"for (const f of (await callTool('Glob', { pattern: 'src/**/*.js' })).split('\\n').filter(Boolean)) {",
|
|
37
|
+
" const text = await callTool('Grep', { pattern: 'oldSymbol', path: f });",
|
|
38
|
+
" if (text && !/No matches/.test(text)) hits.push(f);",
|
|
39
|
+
'}',
|
|
40
|
+
'print(`${hits.length} files still reference it`);',
|
|
41
|
+
'print(hits.join(\'\\n\'));',
|
|
42
|
+
'```',
|
|
43
|
+
'',
|
|
44
|
+
`CALLABLE TOOLS: ${CALLABLE_TOOLS.join(', ')}.`,
|
|
45
|
+
'Anything else is refused — a shell or a write would escape the sandbox\'s purpose.',
|
|
46
|
+
].join('\n');
|
|
47
|
+
|
|
48
|
+
export const spec = {
|
|
49
|
+
name: 'ProgrammaticToolCall',
|
|
50
|
+
description: DESCRIPTION,
|
|
51
|
+
parameters: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
code: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
description: [
|
|
57
|
+
'The body of an async function. `await callTool(name, args)` calls a tool; `print(x)` emits a line.',
|
|
58
|
+
'Only what is printed or returned reaches the conversation.',
|
|
59
|
+
].join(' '),
|
|
60
|
+
},
|
|
61
|
+
purpose: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
description: 'One line naming what the code is for, shown in the transcript.',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
required: ['code'],
|
|
67
|
+
},
|
|
68
|
+
async execute(args, ctx) {
|
|
69
|
+
const code = String((args && args.code) || '');
|
|
70
|
+
if (!code.trim()) return 'Error: `code` is empty.';
|
|
71
|
+
|
|
72
|
+
const r = await runProgrammatic(code, ctx);
|
|
73
|
+
const parts = [];
|
|
74
|
+
|
|
75
|
+
if (r.output) parts.push(r.output);
|
|
76
|
+
if (r.returned != null && String(r.returned).trim()) parts.push(String(r.returned));
|
|
77
|
+
if (r.truncated) parts.push('[output truncated]');
|
|
78
|
+
|
|
79
|
+
if (!r.ok) {
|
|
80
|
+
// The error goes LAST, after whatever the code managed to print: a partial result plus the reason it
|
|
81
|
+
// stopped is more useful than the reason alone.
|
|
82
|
+
parts.push(`Error: ${r.error}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// How many tools ran is worth stating — it is the saving this feature exists for, and a model that sees 0 is
|
|
86
|
+
// being told its code never reached a tool.
|
|
87
|
+
if (r.calls.length) parts.push(`(${r.calls.length} tool call${r.calls.length === 1 ? '' : 's'})`);
|
|
88
|
+
|
|
89
|
+
return parts.join('\n') || '(no output)';
|
|
90
|
+
},
|
|
91
|
+
};
|
package/src/tui.js
CHANGED
|
@@ -7125,7 +7125,8 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
7125
7125
|
const nameField = col(label, selected ? (C.cyan + C.bold) : C.gray);
|
|
7126
7126
|
const hint = cmd.argumentHint ? col(' ' + cmd.argumentHint, C.gray) : '';
|
|
7127
7127
|
const pad = Math.max(2, 18 - visualCol(label) - visualCol(hint));
|
|
7128
|
-
|
|
7128
|
+
// `description` for a skill completion, `desc` for a command: both spellings exist, so accept either.
|
|
7129
|
+
const desc = col(cmd.desc || cmd.description || '', C.gray);
|
|
7129
7130
|
addHit(lines.length, 2, mInner, { kind: 'menuItem', index: off + j });
|
|
7130
7131
|
lines.push(menuRow(mark + nameField + hint + ' '.repeat(pad) + desc));
|
|
7131
7132
|
});
|
|
@@ -12779,7 +12780,15 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
12779
12780
|
if (!url) { appErr('No git remote named "origin" — cannot build a PR URL.'); return; }
|
|
12780
12781
|
const branch = gitmod.currentBranch(cwd);
|
|
12781
12782
|
sayPanel(`Open a pull request for ${branch} → ${base}:\n ${url}`);
|
|
12782
|
-
|
|
12783
|
+
// ACTUALLY COPY IT, and report what happened.
|
|
12784
|
+
//
|
|
12785
|
+
// The notice used to claim the link was copied while the clipboard was left alone, so the paste that followed
|
|
12786
|
+
// produced whatever was copied last. `copyText` also signals failure by RETURNING false rather than throwing,
|
|
12787
|
+
// so its result is what decides the message — a try/catch around it announced success either way.
|
|
12788
|
+
let copied = false;
|
|
12789
|
+
try { copied = copyText(url); } catch { copied = false; }
|
|
12790
|
+
if (copied) app('PR link copied to your clipboard');
|
|
12791
|
+
else app('Could not reach the clipboard \u2014 the link is on screen above.');
|
|
12783
12792
|
return;
|
|
12784
12793
|
}
|
|
12785
12794
|
case 'cache': {
|
|
@@ -14131,7 +14140,7 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
14131
14140
|
// Tools that act ONLY inside the session: the roster, the mailbox, the task list, the memories.
|
|
14132
14141
|
// `isInsideWorkspace` answers `false` for any tool it does not know, so without this they all asked for
|
|
14133
14142
|
// approval in yolo — the one mode whose point is not asking.
|
|
14134
|
-
const safeTools = ['Read', 'Grep', 'Glob', 'FetchURL', 'WebSearch', 'TaskOutput', 'TaskList', 'TaskStop', 'TaskWait', 'TodoList', 'Goal', 'FileLines', 'AgentList', 'AgentMessage', 'AgentInterrupt', 'AgentClose', 'TaskInput', 'SendMessage', 'TeamTaskCreate', 'TeamTaskUpdate', 'TeamTaskList', 'ListTeammates', 'Memory', 'AskUserQuestion'];
|
|
14143
|
+
const safeTools = ['Read', 'Grep', 'Glob', 'FetchURL', 'WebSearch', 'TaskOutput', 'TaskList', 'TaskStop', 'TaskWait', 'TodoList', 'Goal', 'ActivateSkill', 'ProgrammaticToolCall', 'FileLines', 'AgentList', 'AgentMessage', 'AgentInterrupt', 'AgentClose', 'TaskInput', 'SendMessage', 'TeamTaskCreate', 'TeamTaskUpdate', 'TeamTaskList', 'ListTeammates', 'Memory', 'AskUserQuestion'];
|
|
14135
14144
|
if (rule !== 'ask' && safeTools.includes(toolName)) return true;
|
|
14136
14145
|
// A read-only SHELL COMMAND (ls/git status/rg/cat …) is as safe as a
|
|
14137
14146
|
// read-only TOOL, so it runs without a prompt in Ask mode too — the user
|
|
@@ -16554,9 +16563,9 @@ export async function startTUI(opts) {
|
|
|
16554
16563
|
async function registryAct() {
|
|
16555
16564
|
const b = state.registryBrowser;
|
|
16556
16565
|
if (!b || !b.data) return;
|
|
16557
|
-
const {
|
|
16566
|
+
const { visibleRowsForTab } = await import('./registry-browser.js');
|
|
16558
16567
|
const remote = await import('./remote.js');
|
|
16559
|
-
const rows =
|
|
16568
|
+
const rows = visibleRowsForTab(b, b.tab);
|
|
16560
16569
|
const row = rows[Math.min(b.selectedIndex || 0, Math.max(0, rows.length - 1))];
|
|
16561
16570
|
if (!row || row.disabled || row.action === 'none') return;
|
|
16562
16571
|
const name = row.name;
|