@bahulam/code 0.1.23 → 0.1.25
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/commands/install.mjs +88 -6
- package/src/commands/plugin-manage.mjs +146 -5
- package/src/config/cli-args.mjs +23 -3
- package/src/config/model-catalog.mjs +9 -0
- package/src/config/model-defaults.mjs +16 -0
- package/src/core/approval.mjs +8 -1
- package/src/core/attachments.mjs +4 -0
- package/src/core/backend-url.mjs +16 -0
- package/src/core/context-reduction.mjs +207 -0
- package/src/core/headless.mjs +85 -13
- package/src/core/local-agent.mjs +352 -44
- package/src/core/mode-selector.mjs +5 -4
- package/src/core/model-selection.mjs +72 -0
- package/src/core/request-retry.mjs +106 -0
- package/src/core/tool-error.mjs +155 -0
- package/src/core/tool-executor.mjs +8 -3
- package/src/core/usage-normalization.mjs +32 -0
- package/src/local-service/agent-relay.mjs +286 -7
- package/src/local-service/server.mjs +37 -0
- package/src/orchestration/node-runner.mjs +7 -3
- package/src/permissions/command-classifier.mjs +35 -0
- package/src/plugins/executor.mjs +4 -4
- package/src/plugins/lifecycle.mjs +337 -0
- package/src/plugins/manifest.mjs +37 -0
- package/src/plugins/pi-compat/requirements.mjs +30 -17
- package/src/plugins/preflight.mjs +37 -0
- package/src/terminal/main.mjs +10 -1
- package/src/terminal/paste-input.mjs +52 -0
- package/src/terminal/repl.mjs +169 -36
- package/src/tools/bash.mjs +15 -2
- package/src/tools/registry.mjs +1 -16
- package/src/core/agent-loop.mjs +0 -503
- package/src/core/context-manager.mjs +0 -198
- package/src/tools/agent.mjs +0 -142
|
@@ -110,6 +110,13 @@ export class LocalAgentRelay {
|
|
|
110
110
|
this.flushingFollowups = false;
|
|
111
111
|
this.cancellationRequested = false;
|
|
112
112
|
this.cancellationEventEmitted = false;
|
|
113
|
+
// Background-job nudge bookkeeping. See _onBackgroundJobFinished.
|
|
114
|
+
this._bgUnsubscribe = null;
|
|
115
|
+
this._bgNotifiedJobIds = new Set();
|
|
116
|
+
// Trace: per-call_id start-time cache for computing duration on tool_result.
|
|
117
|
+
// Keyed by call_id | request_id. Cleaned up on tool_result.
|
|
118
|
+
this._pendingToolCalls = new Map();
|
|
119
|
+
this._traceSeq = 0;
|
|
113
120
|
}
|
|
114
121
|
|
|
115
122
|
async listHistorySessions() {
|
|
@@ -126,6 +133,24 @@ export class LocalAgentRelay {
|
|
|
126
133
|
return this._historySnapshot();
|
|
127
134
|
}
|
|
128
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Return full trace entries (unelided args/output/errors) for
|
|
138
|
+
* /api/trace/export. One entry per tool call. Pass includeTurns=true
|
|
139
|
+
* to also include user/assistant turns so exports can be correlated.
|
|
140
|
+
*/
|
|
141
|
+
fullTrace({ includeTurns = false } = {}) {
|
|
142
|
+
const trace = fullTraceEntries(this.displayHistory);
|
|
143
|
+
if (!includeTurns) return trace;
|
|
144
|
+
const turns = this.displayHistory
|
|
145
|
+
.filter(e => e?.role === 'user' || e?.role === 'assistant')
|
|
146
|
+
.map(e => ({
|
|
147
|
+
role: e.role,
|
|
148
|
+
timestamp: e.timestamp || null,
|
|
149
|
+
content: typeof e.content === 'string' ? e.content : JSON.stringify(e.content ?? ''),
|
|
150
|
+
}));
|
|
151
|
+
return { turns, trace };
|
|
152
|
+
}
|
|
153
|
+
|
|
129
154
|
async startNewHistory() {
|
|
130
155
|
if (this.running) {
|
|
131
156
|
const err = new Error('A local agent turn is already running for this workspace');
|
|
@@ -137,6 +162,8 @@ export class LocalAgentRelay {
|
|
|
137
162
|
this.turnCount = 0;
|
|
138
163
|
this.displayHistory = [];
|
|
139
164
|
this.agentHistory = [];
|
|
165
|
+
this._pendingToolCalls.clear();
|
|
166
|
+
this._traceSeq = 0;
|
|
140
167
|
this.jsonlWriter = null;
|
|
141
168
|
if (this.client) {
|
|
142
169
|
this.client.sessionId = null;
|
|
@@ -288,6 +315,7 @@ export class LocalAgentRelay {
|
|
|
288
315
|
const data = event.data || {};
|
|
289
316
|
turnHistory.addToolUse(data);
|
|
290
317
|
writer.accumulateToolCall(data.call_id || data.request_id, data.tool || data.name, data.args || data.input);
|
|
318
|
+
this._traceRecordCall(data);
|
|
291
319
|
}
|
|
292
320
|
|
|
293
321
|
if (event.type === 'tool_done' || event.type === 'tool_result') {
|
|
@@ -299,6 +327,7 @@ export class LocalAgentRelay {
|
|
|
299
327
|
data.success === false || data.is_error,
|
|
300
328
|
data,
|
|
301
329
|
);
|
|
330
|
+
this._traceRecordResult(data);
|
|
302
331
|
}
|
|
303
332
|
|
|
304
333
|
if (event.type === 'complete') {
|
|
@@ -471,10 +500,41 @@ export class LocalAgentRelay {
|
|
|
471
500
|
err.code = 'BAD_REQUEST';
|
|
472
501
|
throw err;
|
|
473
502
|
}
|
|
503
|
+
// Promote-on-idle: if no turn is currently running, treat the
|
|
504
|
+
// follow-up as a fresh instruction and start a new turn. Fixes the
|
|
505
|
+
// "cancelled + typed continue → task ended, task wont resume" gap
|
|
506
|
+
// where the last turn was cancelled (or the last tool call was a
|
|
507
|
+
// fire-and-forget background job) and the client still routes input
|
|
508
|
+
// through the follow-up channel.
|
|
474
509
|
if (!this.running || !this.client) {
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
510
|
+
const promotedId = `promoted-followup-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
511
|
+
// Best-effort acknowledgement so panels can show "picked up where
|
|
512
|
+
// you left off" instead of a silent restart.
|
|
513
|
+
try {
|
|
514
|
+
this.emit('agent_followup_promoted', {
|
|
515
|
+
intervention_id: promotedId,
|
|
516
|
+
instruction: text.slice(0, 500),
|
|
517
|
+
reason: this.running ? 'client-not-initialized' : 'no-running-turn',
|
|
518
|
+
});
|
|
519
|
+
} catch { /* SSE failure must never block promotion */ }
|
|
520
|
+
// Fire and await runTurn; caller's HTTP handler expects a JSON
|
|
521
|
+
// reply, and we want to signal the promoted status regardless of
|
|
522
|
+
// how long the new turn takes to spin up.
|
|
523
|
+
const started = this.runTurn({ prompt: text }).catch((err) => {
|
|
524
|
+
try {
|
|
525
|
+
this.emit('agent_error', { turn_id: null, message: `promoted follow-up failed: ${err.message || String(err)}` });
|
|
526
|
+
} catch { /* ok */ }
|
|
527
|
+
});
|
|
528
|
+
// Do not block: return acknowledgement synchronously so the
|
|
529
|
+
// client can render immediately; the new turn's events stream
|
|
530
|
+
// over SSE as they normally do.
|
|
531
|
+
void started;
|
|
532
|
+
return {
|
|
533
|
+
ok: true,
|
|
534
|
+
status: 'promoted_to_new_turn',
|
|
535
|
+
intervention_id: promotedId,
|
|
536
|
+
task_id: null,
|
|
537
|
+
};
|
|
478
538
|
}
|
|
479
539
|
|
|
480
540
|
const item = {
|
|
@@ -532,6 +592,83 @@ export class LocalAgentRelay {
|
|
|
532
592
|
}
|
|
533
593
|
}
|
|
534
594
|
|
|
595
|
+
/**
|
|
596
|
+
* Background-task nudge — called by backgroundTasks.onExit when any
|
|
597
|
+
* shell-spawned job finishes. Filters:
|
|
598
|
+
*
|
|
599
|
+
* - Skips jobs that declared an explicit `on_complete` target
|
|
600
|
+
* (the caller opted into a specific trigger; nudging would be a
|
|
601
|
+
* double dispatch).
|
|
602
|
+
* - Skips user-killed jobs (killing signals "I'm done with it").
|
|
603
|
+
* - Skips jobs whose cwd is outside this workspace (defensive —
|
|
604
|
+
* lets the singleton be shared across processes without cross-talk).
|
|
605
|
+
* - Idempotent: each job id nudges at most once.
|
|
606
|
+
*
|
|
607
|
+
* Routing:
|
|
608
|
+
* - If a turn is running: append to pendingFollowups so the current
|
|
609
|
+
* agent turn picks it up naturally at the next event tick.
|
|
610
|
+
* - If idle: promote to a fresh runTurn — same path as the
|
|
611
|
+
* followup-promote branch, so client behavior is uniform.
|
|
612
|
+
*/
|
|
613
|
+
_onBackgroundJobFinished(jobDesc) {
|
|
614
|
+
if (!jobDesc || !jobDesc.id) return;
|
|
615
|
+
if (this._bgNotifiedJobIds.has(jobDesc.id)) return;
|
|
616
|
+
if (jobDesc.on_complete) return;
|
|
617
|
+
if (jobDesc.status === 'killed') return;
|
|
618
|
+
const ownRoot = this.session?.root_path || '';
|
|
619
|
+
const jobCwd = jobDesc.cwd || '';
|
|
620
|
+
if (ownRoot && jobCwd && !jobCwd.startsWith(ownRoot)) return;
|
|
621
|
+
this._bgNotifiedJobIds.add(jobDesc.id);
|
|
622
|
+
|
|
623
|
+
const instruction = this._buildBackgroundJobNudge(jobDesc);
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
this.emit('agent_background_job_finished', {
|
|
627
|
+
job_id: jobDesc.id,
|
|
628
|
+
status: jobDesc.status,
|
|
629
|
+
exit_code: jobDesc.exit_code,
|
|
630
|
+
duration_s: jobDesc.duration_s,
|
|
631
|
+
command: (jobDesc.command || '').slice(0, 240),
|
|
632
|
+
will_nudge: true,
|
|
633
|
+
});
|
|
634
|
+
} catch { /* SSE failure must never block the nudge */ }
|
|
635
|
+
|
|
636
|
+
const idempotencyKey = `bg-nudge-${jobDesc.id}`;
|
|
637
|
+
if (this.running && this.client) {
|
|
638
|
+
this.pendingFollowups.push({
|
|
639
|
+
instruction,
|
|
640
|
+
role: 'user',
|
|
641
|
+
messageType: 'background_job_nudge',
|
|
642
|
+
priority: 'normal',
|
|
643
|
+
idempotencyKey,
|
|
644
|
+
});
|
|
645
|
+
// If the runtime is already draining events, flush now; else the
|
|
646
|
+
// next tool_result cycle will do it.
|
|
647
|
+
this._flushQueuedFollowups().catch(() => { /* best-effort */ });
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
// Idle — promote a fresh turn.
|
|
651
|
+
this.runTurn({ prompt: instruction }).catch((err) => {
|
|
652
|
+
try { this.emit('agent_error', { turn_id: null, message: `bg nudge turn failed: ${err.message || String(err)}` }); }
|
|
653
|
+
catch { /* ok */ }
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
_buildBackgroundJobNudge(job) {
|
|
658
|
+
const tailLines = String(job.tail || '').split('\n').slice(-20).join('\n');
|
|
659
|
+
const parts = [
|
|
660
|
+
`Background job \`${job.id}\` finished (status=${job.status}, exit=${job.exit_code}, duration ${job.duration_s}s).`,
|
|
661
|
+
`Command: ${job.command}`,
|
|
662
|
+
];
|
|
663
|
+
if (tailLines) {
|
|
664
|
+
parts.push('Recent output:\n```\n' + tailLines + '\n```');
|
|
665
|
+
} else {
|
|
666
|
+
parts.push('(no output captured)');
|
|
667
|
+
}
|
|
668
|
+
parts.push('Continue from here.');
|
|
669
|
+
return parts.join('\n\n');
|
|
670
|
+
}
|
|
671
|
+
|
|
535
672
|
async _sendFollowupNow(item) {
|
|
536
673
|
const result = await this.client.sendIntervention(item.instruction, {
|
|
537
674
|
idempotencyKey: item.idempotencyKey,
|
|
@@ -600,6 +737,21 @@ export class LocalAgentRelay {
|
|
|
600
737
|
process.chdir(this.session.root_path);
|
|
601
738
|
}
|
|
602
739
|
|
|
740
|
+
// Background-task nudge: subscribe once so a `shell {run_in_background:true}`
|
|
741
|
+
// that finishes AFTER the user cancelled the turn (or after the turn
|
|
742
|
+
// that started it completed) doesn't die in silence. See
|
|
743
|
+
// _onBackgroundJobFinished for the routing rules.
|
|
744
|
+
if (!this._bgUnsubscribe) {
|
|
745
|
+
const { backgroundTasks } = await import('../core/background-tasks.mjs');
|
|
746
|
+
this._bgUnsubscribe = backgroundTasks.onExit((jobDesc) => {
|
|
747
|
+
try { this._onBackgroundJobFinished(jobDesc); }
|
|
748
|
+
catch (err) {
|
|
749
|
+
try { this.emit('agent_error', { turn_id: null, message: `bg nudge handler failed: ${err.message || String(err)}` }); }
|
|
750
|
+
catch { /* ok */ }
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
603
755
|
const { PluginRegistry } = await import('../plugins/registry.mjs');
|
|
604
756
|
const activePlugins = activePluginNamesFromSession(this.session);
|
|
605
757
|
const pluginDirs = pluginScanDirsFromSession(this.session);
|
|
@@ -755,6 +907,13 @@ export class LocalAgentRelay {
|
|
|
755
907
|
_makeWorkspaceSessionSubstrate(pluginRegistry) {
|
|
756
908
|
return (agent, node, instruction, { scopedExecutor } = {}) => (async function* (relay) {
|
|
757
909
|
const execContext = await relay._buildExecContext(instruction);
|
|
910
|
+
// Per-agent model override from YAML — same pattern as agents.mjs:330.
|
|
911
|
+
// If the agent definition declares a `model` field, it wins over
|
|
912
|
+
// the session default for this sub-agent's turn.
|
|
913
|
+
if (agent.model) execContext.model_override = agent.model;
|
|
914
|
+
if (agent.models && typeof agent.models === 'object' && Object.keys(agent.models).length) {
|
|
915
|
+
execContext.model_overrides = { ...(execContext.model_overrides || {}), ...agent.models };
|
|
916
|
+
}
|
|
758
917
|
const slug = agent.slug || agent.command || agent.name || node?.agent_slug || node?.id || 'agent';
|
|
759
918
|
execContext.sub_agent = {
|
|
760
919
|
slug,
|
|
@@ -819,6 +978,68 @@ export class LocalAgentRelay {
|
|
|
819
978
|
return lines.join('\n');
|
|
820
979
|
}
|
|
821
980
|
|
|
981
|
+
// ── Trace ────────────────────────────────────────────────────────
|
|
982
|
+
// Two entry points fire for every tool: _traceRecordCall on
|
|
983
|
+
// tool_call / tool_request, _traceRecordResult on tool_result /
|
|
984
|
+
// tool_done. Together they populate displayHistory with role:'tool'
|
|
985
|
+
// rows carrying { id, ts, tool, plugin, args, status, output, error?,
|
|
986
|
+
// duration_ms, sub_agent?, call_id, parent_id?, kind }. The compact
|
|
987
|
+
// view is derived from these; the full data is written verbatim to
|
|
988
|
+
// the transcript writer for /api/trace/export.
|
|
989
|
+
|
|
990
|
+
_traceRecordCall(data) {
|
|
991
|
+
const callId = data.call_id || data.request_id || data.tool_id || `call_${++this._traceSeq}`;
|
|
992
|
+
const startTs = Date.now();
|
|
993
|
+
this._pendingToolCalls.set(callId, {
|
|
994
|
+
startTs,
|
|
995
|
+
tool: data.tool || data.name || '',
|
|
996
|
+
plugin: data.plugin || data._plugin || null,
|
|
997
|
+
args: data.args || data.input || {},
|
|
998
|
+
sub_agent: data.sub_agent || null,
|
|
999
|
+
parent_id: data.parent_id || data.parent_call_id || null,
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
_traceRecordResult(data) {
|
|
1004
|
+
const callId = data.call_id || data._callId || data.request_id || data.id || data.tool_use_id;
|
|
1005
|
+
const pending = callId ? this._pendingToolCalls.get(callId) : null;
|
|
1006
|
+
if (callId) this._pendingToolCalls.delete(callId);
|
|
1007
|
+
|
|
1008
|
+
const startTs = pending?.startTs || Date.now();
|
|
1009
|
+
const endTs = Date.now();
|
|
1010
|
+
const durationMs = data.duration_ms || (endTs - startTs);
|
|
1011
|
+
const isError = data.success === false || data.is_error === true || Boolean(data.error);
|
|
1012
|
+
|
|
1013
|
+
// Prefer the structured error envelope from normalizeToolResult if present.
|
|
1014
|
+
const errEnvelope = data.error && typeof data.error === 'object' && data.error.code
|
|
1015
|
+
? {
|
|
1016
|
+
code: String(data.error.code || 'UNKNOWN'),
|
|
1017
|
+
message: String(data.error.message || data.output || ''),
|
|
1018
|
+
...(data.error.hint ? { hint: String(data.error.hint) } : {}),
|
|
1019
|
+
...(process.env.DEBUG && data.error.stack ? { stack: String(data.error.stack) } : {}),
|
|
1020
|
+
}
|
|
1021
|
+
: (isError
|
|
1022
|
+
? { code: 'UNKNOWN', message: typeof data.output === 'string' ? data.output : (data.message || 'Tool call failed.') }
|
|
1023
|
+
: null);
|
|
1024
|
+
|
|
1025
|
+
this.displayHistory.push({
|
|
1026
|
+
role: 'tool',
|
|
1027
|
+
kind: isError ? 'error' : 'result',
|
|
1028
|
+
tool: pending?.tool || data.tool || data.name || '',
|
|
1029
|
+
plugin: pending?.plugin || data.plugin || data._plugin || null,
|
|
1030
|
+
call_id: callId || null,
|
|
1031
|
+
parent_id: pending?.parent_id || null,
|
|
1032
|
+
sub_agent: pending?.sub_agent || data.sub_agent || null,
|
|
1033
|
+
args: pending?.args || data.args || {},
|
|
1034
|
+
output: data.output ?? data.result ?? data.message ?? '',
|
|
1035
|
+
error: errEnvelope,
|
|
1036
|
+
status: isError ? 'error' : 'ok',
|
|
1037
|
+
duration_ms: durationMs,
|
|
1038
|
+
timestamp: new Date(endTs).toISOString(),
|
|
1039
|
+
order: this.displayHistory.length,
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
|
|
822
1043
|
_historySnapshot() {
|
|
823
1044
|
return {
|
|
824
1045
|
ok: true,
|
|
@@ -1033,15 +1254,73 @@ function browserMessages(history = []) {
|
|
|
1033
1254
|
}));
|
|
1034
1255
|
}
|
|
1035
1256
|
|
|
1036
|
-
|
|
1257
|
+
// Compact trace summary for the panel — full data lives on the
|
|
1258
|
+
// history entry itself (available via /api/trace/export).
|
|
1259
|
+
//
|
|
1260
|
+
// Handles two entry shapes:
|
|
1261
|
+
// 1. Live entries pushed by _traceRecordResult (single row per round-trip,
|
|
1262
|
+
// with kind: 'result'|'error', args, output, error, duration_ms).
|
|
1263
|
+
// 2. Resume entries built by local-store.buildResumeHistory (separate
|
|
1264
|
+
// rows per tool_call and tool_result, with kind: 'call'|'result',
|
|
1265
|
+
// only `content` populated).
|
|
1266
|
+
//
|
|
1267
|
+
// The `type` field preserves the historical `history_tool_<kind>` shape
|
|
1268
|
+
// so consumers can distinguish call vs. result vs. error rows without
|
|
1269
|
+
// knowing which pipeline produced them.
|
|
1270
|
+
export function browserTraceItems(history = []) {
|
|
1271
|
+
return history
|
|
1272
|
+
.filter((entry) => entry?.role === 'tool')
|
|
1273
|
+
.map((entry) => {
|
|
1274
|
+
const kind = entry.kind || (entry.status === 'error' ? 'error' : 'result');
|
|
1275
|
+
const isLive = entry.call_id != null || entry.args !== undefined || entry.error !== undefined;
|
|
1276
|
+
const base = {
|
|
1277
|
+
type: `history_tool_${kind}`,
|
|
1278
|
+
timestamp: entry.timestamp || null,
|
|
1279
|
+
tool: entry.tool || null,
|
|
1280
|
+
kind,
|
|
1281
|
+
};
|
|
1282
|
+
if (!isLive) {
|
|
1283
|
+
// Resume entry — surface content as-is for backward compat.
|
|
1284
|
+
return { ...base, content: typeof entry.content === 'string' ? entry.content : JSON.stringify(entry.content || '') };
|
|
1285
|
+
}
|
|
1286
|
+
return {
|
|
1287
|
+
...base,
|
|
1288
|
+
id: entry.call_id || null,
|
|
1289
|
+
parent_id: entry.parent_id || null,
|
|
1290
|
+
plugin: entry.plugin || null,
|
|
1291
|
+
status: entry.status || (kind === 'error' ? 'error' : 'ok'),
|
|
1292
|
+
duration_ms: entry.duration_ms ?? null,
|
|
1293
|
+
args_summary: elide(typeof entry.args === 'string' ? entry.args : JSON.stringify(entry.args ?? {}), 320),
|
|
1294
|
+
output_summary: elide(typeof entry.output === 'string' ? entry.output : JSON.stringify(entry.output ?? ''), 320),
|
|
1295
|
+
error: entry.error
|
|
1296
|
+
? { code: entry.error.code, message: elide(entry.error.message, 220), hint: entry.error.hint || null }
|
|
1297
|
+
: null,
|
|
1298
|
+
sub_agent: entry.sub_agent || null,
|
|
1299
|
+
};
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function elide(text, max = 320) {
|
|
1304
|
+
const s = typeof text === 'string' ? text : JSON.stringify(text ?? '');
|
|
1305
|
+
return s.length > max ? `${s.slice(0, max)}…` : s;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// Full trace entries (unelided) for /api/trace/export.
|
|
1309
|
+
export function fullTraceEntries(history = []) {
|
|
1037
1310
|
return history
|
|
1038
1311
|
.filter((entry) => entry?.role === 'tool')
|
|
1039
1312
|
.map((entry) => ({
|
|
1040
|
-
|
|
1313
|
+
id: entry.call_id || null,
|
|
1314
|
+
parent_id: entry.parent_id || null,
|
|
1041
1315
|
timestamp: entry.timestamp || null,
|
|
1042
1316
|
tool: entry.tool || null,
|
|
1043
|
-
|
|
1044
|
-
|
|
1317
|
+
plugin: entry.plugin || null,
|
|
1318
|
+
status: entry.status || (entry.kind === 'error' ? 'error' : 'ok'),
|
|
1319
|
+
duration_ms: entry.duration_ms ?? null,
|
|
1320
|
+
args: entry.args ?? null,
|
|
1321
|
+
output: entry.output ?? null,
|
|
1322
|
+
error: entry.error || null,
|
|
1323
|
+
sub_agent: entry.sub_agent || null,
|
|
1045
1324
|
}));
|
|
1046
1325
|
}
|
|
1047
1326
|
|
|
@@ -288,6 +288,19 @@ async function routeRequest({ req, res, sessionId, token, events, sseClients, em
|
|
|
288
288
|
return;
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
// Vendor asset: Bahulam plugin design system (CSS/JS shared across views).
|
|
292
|
+
// Served from assets/bahulam-plugin/ so every plugin workspace view can
|
|
293
|
+
// import a common design language without inlining or server-side packing.
|
|
294
|
+
if (req.method === 'GET' && url.pathname.startsWith('/vendor/bahulam-plugin/')) {
|
|
295
|
+
sendPackageAsset({
|
|
296
|
+
res,
|
|
297
|
+
root: fileURLToPath(new URL('../../assets/bahulam-plugin/', import.meta.url)),
|
|
298
|
+
pathname: url.pathname,
|
|
299
|
+
prefix: '/vendor/bahulam-plugin/',
|
|
300
|
+
});
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
291
304
|
if (req.method === 'GET' && url.pathname === '/assets/bahulam-mark.png') {
|
|
292
305
|
sendBrandMark(res);
|
|
293
306
|
return;
|
|
@@ -312,6 +325,30 @@ async function routeRequest({ req, res, sessionId, token, events, sseClients, em
|
|
|
312
325
|
return;
|
|
313
326
|
}
|
|
314
327
|
|
|
328
|
+
// Trace export — one JSONL line per tool call with FULL args / output /
|
|
329
|
+
// error / stack (no eliding). Pass ?turns=1 to also stream user +
|
|
330
|
+
// assistant messages (as separate lines with role="turn") for
|
|
331
|
+
// correlation.
|
|
332
|
+
if (req.method === 'GET' && url.pathname === '/api/trace/export') {
|
|
333
|
+
const relay = getAgentRelay(session);
|
|
334
|
+
const includeTurns = url.searchParams.get('turns') === '1' || url.searchParams.get('turns') === 'true';
|
|
335
|
+
const data = relay.fullTrace({ includeTurns });
|
|
336
|
+
const filename = `trace-${session.id || 'session'}-${Date.now()}.jsonl`;
|
|
337
|
+
res.writeHead(200, {
|
|
338
|
+
'Content-Type': 'application/x-ndjson; charset=utf-8',
|
|
339
|
+
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
340
|
+
'Cache-Control': 'no-store',
|
|
341
|
+
});
|
|
342
|
+
if (includeTurns) {
|
|
343
|
+
for (const t of data.turns || []) res.write(JSON.stringify({ role: 'turn', ...t }) + '\n');
|
|
344
|
+
for (const e of data.trace || []) res.write(JSON.stringify({ role: 'tool', ...e }) + '\n');
|
|
345
|
+
} else {
|
|
346
|
+
for (const e of data || []) res.write(JSON.stringify(e) + '\n');
|
|
347
|
+
}
|
|
348
|
+
res.end();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
315
352
|
if (req.method === 'GET' && url.pathname === '/api/chat/sessions') {
|
|
316
353
|
const relay = getAgentRelay(session);
|
|
317
354
|
const historySessions = await relay.listHistorySessions();
|
|
@@ -43,7 +43,8 @@ export async function* runNode(node, agent, instruction, ctx, options = {}) {
|
|
|
43
43
|
|
|
44
44
|
const model = node.model || effectiveAgent.model || ctx.defaultModel || null;
|
|
45
45
|
const { apiKey = null, openRouterKey = null } = ctx.credentials || {};
|
|
46
|
-
|
|
46
|
+
const useGateway = ctx.modelTransport === 'gateway' && ctx.gatewayToken;
|
|
47
|
+
if (!useGateway && !apiKey && !openRouterKey) {
|
|
47
48
|
throw new Error(
|
|
48
49
|
`Cannot run node '${node.id}' locally: no model API key. ` +
|
|
49
50
|
'Set ANTHROPIC_API_KEY or OPENROUTER_API_KEY, or log in and use the session substrate.',
|
|
@@ -60,8 +61,11 @@ export async function* runNode(node, agent, instruction, ctx, options = {}) {
|
|
|
60
61
|
.filter(schema => declaredTools.has(schema.name));
|
|
61
62
|
|
|
62
63
|
const localAgent = new LocalAgent({
|
|
63
|
-
apiKey,
|
|
64
|
-
openRouterKey,
|
|
64
|
+
apiKey: useGateway ? null : apiKey,
|
|
65
|
+
openRouterKey: useGateway ? null : openRouterKey,
|
|
66
|
+
gatewayUrl: useGateway ? ctx.gatewayUrl : null,
|
|
67
|
+
gatewayToken: useGateway ? ctx.gatewayToken : null,
|
|
68
|
+
sessionId: ctx.sessionId || null,
|
|
65
69
|
model,
|
|
66
70
|
toolExecutor: scopedExecutor,
|
|
67
71
|
cwd: ctx.cwd || process.cwd(),
|
|
@@ -238,6 +238,24 @@ const EXACT_SAFE = new Set([
|
|
|
238
238
|
'python --version', 'python3 --version',
|
|
239
239
|
]);
|
|
240
240
|
|
|
241
|
+
/** Tools commonly invoked with -version/--version to probe the installed version.
|
|
242
|
+
* These are pure reads (no file access, no network) and should never require
|
|
243
|
+
* project-scoping — they probe the local system's SDK/toolchain. */
|
|
244
|
+
const VERSION_PROBE_TOOLS = new Set([
|
|
245
|
+
'java', 'javac', 'python', 'python3', 'node', 'ruby', 'go', 'rustc',
|
|
246
|
+
'deno', 'bun', 'php', 'perl', 'gcc', 'clang', 'make', 'cmake', 'mvn',
|
|
247
|
+
'gradle', 'pip', 'npm', 'yarn', 'pnpm', 'cargo', 'swift', 'kotlin',
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
/** Absolute paths to system probes that are safe to invoke for read-only
|
|
251
|
+
* version/path queries. These live outside any project root and should not
|
|
252
|
+
* trigger project-scope errors. */
|
|
253
|
+
const SAFE_ABS_PROBE_PATHS = new Set([
|
|
254
|
+
'/usr/libexec/java_home',
|
|
255
|
+
'/usr/bin/xcode-select',
|
|
256
|
+
'/usr/bin/which',
|
|
257
|
+
]);
|
|
258
|
+
|
|
241
259
|
/**
|
|
242
260
|
* Commands with allowed flags — allowlist approach.
|
|
243
261
|
* Key: command name (or "git diff" for multi-word).
|
|
@@ -682,6 +700,23 @@ function classifySingleCommand(command) {
|
|
|
682
700
|
return { classification: 'safe', reason: 'Echo without expansion' };
|
|
683
701
|
}
|
|
684
702
|
|
|
703
|
+
// ── Version probe: <tool> -version / --version (pure OS read, no project needed) ──
|
|
704
|
+
if (VERSION_PROBE_TOOLS.has(baseCmd)) {
|
|
705
|
+
const token = tokenize(trimmed);
|
|
706
|
+
const flag = token[1] || '';
|
|
707
|
+
if (/^--?v(ersion)?$/.test(flag)) {
|
|
708
|
+
return { classification: 'safe', reason: `Version probe: ${baseCmd}` };
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ── Known system-probe absolute paths (e.g. /usr/libexec/java_home -V) ──
|
|
713
|
+
if (SAFE_ABS_PROBE_PATHS.has(baseCmd)) {
|
|
714
|
+
const rest = trimmed.slice(baseCmd.length).trim();
|
|
715
|
+
if (!rest || /^--?[a-z]/i.test(rest)) {
|
|
716
|
+
return { classification: 'safe', reason: `System probe: ${baseCmd}` };
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
685
720
|
// ── Default: contained (unknown command, not explicitly blocked) ──
|
|
686
721
|
return { classification: 'contained', reason: `Unknown command, defaulting to contained: ${baseCmd}` };
|
|
687
722
|
}
|
package/src/plugins/executor.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import { makePluginState } from './state.mjs';
|
|
11
|
+
import { normalizeToolResult } from '../core/tool-error.mjs';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Load a plugin tool handler by resolving its path relative to the plugin directory.
|
|
@@ -105,13 +106,12 @@ export async function createPluginToolExecutor(manifest, opts = {}) {
|
|
|
105
106
|
get state() { return getState(); },
|
|
106
107
|
pluginName,
|
|
107
108
|
};
|
|
109
|
+
const traceId = options?._trace_id || null;
|
|
108
110
|
try {
|
|
109
111
|
const result = await entry.handler.call(args || {}, handlerOpts);
|
|
110
|
-
return result
|
|
111
|
-
? { success: true, output: result?.output ?? result, _tool: name, _plugin: pluginName }
|
|
112
|
-
: { success: false, output: result?.output ?? String(result), _tool: name, _plugin: pluginName };
|
|
112
|
+
return normalizeToolResult({ tool: name, plugin: pluginName, traceId }, result, null);
|
|
113
113
|
} catch (err) {
|
|
114
|
-
return {
|
|
114
|
+
return normalizeToolResult({ tool: name, plugin: pluginName, traceId }, null, err);
|
|
115
115
|
}
|
|
116
116
|
},
|
|
117
117
|
list: () => [...handlers.keys()],
|