@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.
@@ -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 err = new Error('No running agent turn to follow up');
476
- err.code = 'CONFLICT';
477
- throw err;
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
- function browserTraceItems(history = []) {
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
- type: `history_tool_${entry.kind || 'event'}`,
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
- kind: entry.kind || null,
1044
- content: typeof entry.content === 'string' ? entry.content : JSON.stringify(entry.content || ''),
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
- if (!apiKey && !openRouterKey) {
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
  }
@@ -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?.success !== false
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 { success: false, output: `Plugin tool error (${name}): ${err.message}`, _tool: name, _plugin: pluginName };
114
+ return normalizeToolResult({ tool: name, plugin: pluginName, traceId }, null, err);
115
115
  }
116
116
  },
117
117
  list: () => [...handlers.keys()],