@bahulam/code 0.1.10 → 0.1.12

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 (52) hide show
  1. package/package.json +5 -2
  2. package/src/agents/loader.mjs +26 -4
  3. package/src/auth/bahulam-auth.mjs +2 -13
  4. package/src/auth/tarang-auth.mjs +313 -0
  5. package/src/commands/agent.mjs +7 -7
  6. package/src/commands/plugin-manage.mjs +449 -0
  7. package/src/commands/plugin.mjs +247 -0
  8. package/src/config/cli-args.mjs +16 -0
  9. package/src/config/env.mjs +2 -0
  10. package/src/config/hook-runner.mjs +8 -8
  11. package/src/config/memory-loader.mjs +7 -3
  12. package/src/config/settings-loader.mjs +5 -3
  13. package/src/core/attachments.mjs +2 -2
  14. package/src/core/background-tasks.mjs +186 -0
  15. package/src/core/headless.mjs +59 -3
  16. package/src/core/local-agent.mjs +10 -1
  17. package/src/core/local-store.mjs +10 -10
  18. package/src/core/paths.mjs +10 -96
  19. package/src/core/policy-resolver.mjs +1 -1
  20. package/src/core/project-context-loader.mjs +2 -2
  21. package/src/core/risk-tier.mjs +1 -0
  22. package/src/core/stream-client.mjs +148 -1
  23. package/src/core/system-prompt.mjs +31 -12
  24. package/src/core/tool-executor.mjs +457 -12
  25. package/src/local-service/agent-relay.mjs +139 -12
  26. package/src/local-service/server.mjs +345 -20
  27. package/src/orchestration/approval.mjs +30 -0
  28. package/src/orchestration/completion-triggers.mjs +40 -0
  29. package/src/orchestration/dispatch.mjs +118 -0
  30. package/src/orchestration/events.mjs +19 -0
  31. package/src/orchestration/graph.mjs +126 -0
  32. package/src/orchestration/node-runner.mjs +193 -0
  33. package/src/orchestration/runner.mjs +200 -0
  34. package/src/plugins/executor.mjs +121 -0
  35. package/src/plugins/loader.mjs +123 -123
  36. package/src/plugins/manifest.mjs +291 -0
  37. package/src/plugins/preflight.mjs +227 -0
  38. package/src/plugins/registry.mjs +233 -0
  39. package/src/plugins/state.mjs +290 -0
  40. package/src/terminal/agents.mjs +26 -4
  41. package/src/terminal/init.mjs +2 -2
  42. package/src/terminal/main.mjs +83 -4
  43. package/src/terminal/repl-explore.mjs +1 -1
  44. package/src/terminal/repl-render.mjs +67 -12
  45. package/src/terminal/repl-state.mjs +4 -2
  46. package/src/terminal/repl.mjs +621 -99
  47. package/src/tools/agent.mjs +6 -2
  48. package/src/tools/analyze-image.mjs +1 -1
  49. package/src/tools/project-overview.mjs +7 -7
  50. package/src/tools/registry.mjs +88 -4
  51. package/src/ui/slash-commands.mjs +19 -1
  52. package/src/ui/sub-agent.mjs +14 -8
@@ -0,0 +1,40 @@
1
+ import { backgroundTasks } from '../core/background-tasks.mjs';
2
+ import { dispatch } from './dispatch.mjs';
3
+
4
+ /**
5
+ * Wake-on-finish: a background job that declared on_complete fires a
6
+ * TriggerEvent through dispatch() when it exits — so a finished build can
7
+ * deterministically wake a verifier agent. Chain depth and cycle guards
8
+ * apply like any other trigger; a job started BY that agent whose own
9
+ * on_complete points back would be refused by the chain guard.
10
+ *
11
+ * buildCtx is called lazily at fire time so the dispatch context always
12
+ * reflects the live tool executor / registry.
13
+ */
14
+ export function registerJobCompletionDispatch(buildCtx) {
15
+ return backgroundTasks.onExit((job) => {
16
+ const target = job?.on_complete?.target;
17
+ if (!target) return;
18
+ const instruction = job.on_complete.instruction
19
+ || `Background job ${job.id} (${job.name}) finished: ${job.status}`
20
+ + (job.exit_code != null ? ` (exit ${job.exit_code})` : '')
21
+ + `. Review the output and act on it:\n${String(job.tail || '').slice(-4000)}`;
22
+ Promise.resolve()
23
+ .then(() => dispatch({
24
+ type: 'invoke',
25
+ source: `job:${job.id}`,
26
+ target,
27
+ params: { instruction },
28
+ channel: null,
29
+ initiator: { chain: [`job:${job.id}`] },
30
+ }, buildCtx()))
31
+ .then((outcome) => {
32
+ if (outcome && !outcome.dispatched) {
33
+ process.stderr.write(` on_complete for ${job.id} not dispatched: ${outcome.reason}\n`);
34
+ }
35
+ })
36
+ .catch((err) => {
37
+ process.stderr.write(` on_complete for ${job.id} failed: ${err?.message || err}\n`);
38
+ });
39
+ });
40
+ }
@@ -0,0 +1,118 @@
1
+ import { runGraph } from './runner.mjs';
2
+ import { compileSingleAgentGraph, normalizeGraphSpec } from './graph.mjs';
3
+
4
+ const MAX_CHAIN_DEPTH = 3;
5
+
6
+ /**
7
+ * The single funnel for deterministic runs. Every surface (CLI, /run,
8
+ * headless, hooks, cron, daemon, programmatic) builds a TriggerEvent and
9
+ * calls dispatch(); nothing else calls runGraph() directly.
10
+ *
11
+ * TriggerEvent: {
12
+ * type: 'invoke'|'manual'|'hook'|'prompt_match'|'cron'|'remote',
13
+ * source: string, // e.g. 'cli:agent-run', 'hook:Stop'
14
+ * target: {kind:'agent'|'workflow', slug} | string, // bare names resolved below
15
+ * params: { instruction, ... },
16
+ * channel: 'local'|'server'|null, // null → derived from the target
17
+ * initiator: { chain: string[] } | null,
18
+ * }
19
+ */
20
+ export async function dispatch(event, ctx) {
21
+ const chain = event.initiator?.chain || [];
22
+ if (chain.length >= MAX_CHAIN_DEPTH) {
23
+ return { dispatched: false, reason: `Trigger chain depth limit (${MAX_CHAIN_DEPTH}) reached` };
24
+ }
25
+
26
+ const resolved = resolveTarget(event.target, ctx);
27
+ if (!resolved) {
28
+ return { dispatched: false, reason: `Unknown target '${targetName(event.target)}'` };
29
+ }
30
+ if (chain.includes(resolved.key)) {
31
+ return { dispatched: false, reason: `Trigger cycle detected at '${resolved.key}'` };
32
+ }
33
+
34
+ const channel = event.channel || resolved.channel;
35
+ if (channel === 'server') {
36
+ // Synced server workflows keep their exact existing execution path.
37
+ const result = await ctx.toolExecutor.execute('workflow_run_multi', {
38
+ name: resolved.slug,
39
+ instruction: event.params?.instruction || '',
40
+ });
41
+ return { dispatched: true, channel, result };
42
+ }
43
+
44
+ const graph = resolved.kind === 'agent'
45
+ ? compileSingleAgentGraph(resolved.agent, { instruction: event.params?.instruction || '' })
46
+ : resolved.graph;
47
+
48
+ const runCtx = { ...ctx, resolveAgent: slug => resolveAgentBySlug(slug, ctx) };
49
+ const iterator = runGraph(graph, { instruction: event.params?.instruction || '', params: event.params || {} }, runCtx, {
50
+ substrate: event.substrate,
51
+ signal: event.signal,
52
+ });
53
+
54
+ let result = null;
55
+ while (true) {
56
+ const { value, done } = await iterator.next();
57
+ if (done) { result = value; break; }
58
+ ctx.renderEvent?.(value);
59
+ }
60
+ return { dispatched: true, channel: 'local', result };
61
+ }
62
+
63
+ function targetName(target) {
64
+ return typeof target === 'string' ? target : `${target?.kind}:${target?.slug}`;
65
+ }
66
+
67
+ function resolveAgentBySlug(slug, ctx) {
68
+ return (ctx.listRunnables?.() || []).find(agent => agent.slug === slug) || null;
69
+ }
70
+
71
+ /**
72
+ * Bare-name resolution order (uniform across surfaces, preserves today's
73
+ * /run behavior): project agent → global agent → builtin → allowlisted
74
+ * plugin agent → local workflow → synced server workflow fallback.
75
+ * listRunnables() already returns agents deduped in that precedence.
76
+ */
77
+ function resolveTarget(target, ctx) {
78
+ const raw = typeof target === 'string' ? String(target || '').trim() : '';
79
+ const prefixed = raw.match(/^(agent|workflow):(.+)$/);
80
+ const typed = typeof target === 'object' && target?.kind
81
+ ? target
82
+ : prefixed
83
+ ? { kind: prefixed[1], slug: prefixed[2].trim() }
84
+ : null;
85
+ const name = typed ? typed.slug : raw;
86
+ if (!name) return null;
87
+
88
+ if (typed?.kind === 'agent' && typed.agent) {
89
+ // Caller already resolved the full definition (prompt, readOnly, tools).
90
+ return { kind: 'agent', slug: name, key: `agent:${name}`, agent: typed.agent, channel: 'local' };
91
+ }
92
+ if (!typed || typed.kind === 'agent') {
93
+ const agent = resolveAgentBySlug(name, ctx);
94
+ if (agent) return { kind: 'agent', slug: name, key: `agent:${name}`, agent, channel: 'local' };
95
+ if (typed) return null;
96
+ }
97
+
98
+ const workflows = ctx.listLocalWorkflows?.() || [];
99
+ const workflow = workflows.find(w => w.slug === name || w.name === name);
100
+ if (workflow) {
101
+ // Channel comes from the file: v2 files run locally; v1 files (no
102
+ // apiVersion) keep today's server execution so nothing silently flips.
103
+ const isV2 = /\/2$/.test(String(workflow.api_version || workflow.apiVersion || ''));
104
+ return {
105
+ kind: 'workflow',
106
+ slug: workflow.slug || name,
107
+ key: `workflow:${name}`,
108
+ graph: isV2 ? normalizeGraphSpec(workflow) : null,
109
+ channel: isV2 ? 'local' : 'server',
110
+ };
111
+ }
112
+
113
+ if (!typed || typed.kind === 'workflow') {
114
+ // Name may exist only as a synced server workflow — let the server path try.
115
+ return { kind: 'workflow', slug: name, key: `workflow:${name}`, graph: null, channel: 'server' };
116
+ }
117
+ return null;
118
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Tag every event from a node's execution with graph/node attribution so
3
+ * renderers group them as sub-agent work and history builders exclude
4
+ * them from the primary transcript (same treatment direct agent runs get).
5
+ */
6
+ export async function* namespaceNodeEvents(iterable, { node, runId }) {
7
+ const slug = node.agent_slug || node.id;
8
+ for await (const event of iterable) {
9
+ const data = event.data && typeof event.data === 'object' ? event.data : {};
10
+ yield {
11
+ ...event,
12
+ data: { ...data, internal: true, sub_agent: slug, graph_run_id: runId, node_id: node.id },
13
+ };
14
+ }
15
+ }
16
+
17
+ export function graphEvent(type, runId, data = {}) {
18
+ return { type, data: { graph_run_id: runId, ...data } };
19
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * GraphSpec — the one shape every runnable compiles to.
3
+ *
4
+ * A deterministic sub-agent run is a one-node graph (trigger → agent →
5
+ * output); a multi-agent workflow is the same graph with N agent nodes.
6
+ * Inputs accepted: an inline GraphSpec, a multi-workflow loader payload
7
+ * ({ graph: { nodes, edges } }), or a single agent definition via
8
+ * compileSingleAgentGraph().
9
+ */
10
+
11
+ export function compileSingleAgentGraph(agent, { instruction = '' } = {}) {
12
+ const slug = agent.slug || agent.name;
13
+ return {
14
+ name: slug,
15
+ channel: 'local',
16
+ pattern: 'sequential',
17
+ nodes: [
18
+ { id: 'trigger', type: 'trigger' },
19
+ { id: slug, type: 'agent', agent_slug: slug, agent, prompt: instruction },
20
+ { id: 'output', type: 'output' },
21
+ ],
22
+ edges: [
23
+ { source: 'trigger', target: slug },
24
+ { source: slug, target: 'output' },
25
+ ],
26
+ global_params: {},
27
+ };
28
+ }
29
+
30
+ export function normalizeGraphSpec(input) {
31
+ if (!input || typeof input !== 'object') throw new Error('Graph spec is required');
32
+ // Multi-workflow loader payload: { name, graph: { nodes, edges }, ... }
33
+ const source = input.graph && Array.isArray(input.graph.nodes) ? input.graph : input;
34
+ const nodes = (source.nodes || []).map(node => ({
35
+ id: node.id,
36
+ type: node.type,
37
+ agent_slug: node.agent_slug || node.data?.agent_slug || node.data?.user_agent_slug || null,
38
+ agent: node.agent || null,
39
+ prompt: node.prompt || node.data?.prompt || '',
40
+ model: normalizeModel(node.model ?? node.data?.model),
41
+ tools: Array.isArray(node.tools) ? node.tools : (Array.isArray(node.data?.tools) ? node.data.tools : []),
42
+ config: node.config || node.data?.config || {},
43
+ continue_on_error: Boolean(node.continue_on_error),
44
+ // job nodes: a process instead of an LLM
45
+ command: node.command || node.data?.command || null,
46
+ timeout_s: Number(node.timeout_s ?? node.data?.timeout_s) || null,
47
+ // service nodes: long-lived process (dev server); the node completes
48
+ // at READINESS, the process lives until the graph run ends.
49
+ ready: node.ready || node.data?.ready || null,
50
+ }));
51
+ const edges = (source.edges || []).map(edge => ({ source: edge.source, target: edge.target }));
52
+ return {
53
+ name: input.name || source.name || 'graph',
54
+ channel: input.channel || 'local',
55
+ pattern: input.pattern || input.orchestration_pattern || 'sequential',
56
+ nodes,
57
+ edges,
58
+ global_params: input.global_params || {},
59
+ };
60
+ }
61
+
62
+ function normalizeModel(value) {
63
+ const model = String(value || '').trim();
64
+ return !model || model === 'auto' ? null : model;
65
+ }
66
+
67
+ /**
68
+ * Validate the graph and return executable nodes (type 'agent' or 'job')
69
+ * in execution (topological) order. Throws before any LLM call on:
70
+ * unknown edge endpoints, cycles, unreachable nodes, agent nodes with no
71
+ * definition reference, or job nodes with no command.
72
+ */
73
+ export function validateGraph(spec) {
74
+ const byId = new Map(spec.nodes.map(node => [node.id, node]));
75
+ for (const edge of spec.edges) {
76
+ if (!byId.has(edge.source)) throw new Error(`Edge references unknown node '${edge.source}'`);
77
+ if (!byId.has(edge.target)) throw new Error(`Edge references unknown node '${edge.target}'`);
78
+ }
79
+ const outgoing = new Map(spec.nodes.map(node => [node.id, []]));
80
+ const indegree = new Map(spec.nodes.map(node => [node.id, 0]));
81
+ for (const edge of spec.edges) {
82
+ outgoing.get(edge.source).push(edge.target);
83
+ indegree.set(edge.target, indegree.get(edge.target) + 1);
84
+ }
85
+
86
+ // Kahn topological sort — leftover nodes mean a cycle.
87
+ const queue = spec.nodes.filter(node => indegree.get(node.id) === 0).map(node => node.id);
88
+ const order = [];
89
+ while (queue.length) {
90
+ const id = queue.shift();
91
+ order.push(id);
92
+ for (const next of outgoing.get(id)) {
93
+ indegree.set(next, indegree.get(next) - 1);
94
+ if (indegree.get(next) === 0) queue.push(next);
95
+ }
96
+ }
97
+ if (order.length !== spec.nodes.length) {
98
+ throw new Error(`Graph '${spec.name}' contains a cycle`);
99
+ }
100
+
101
+ const reachable = new Set();
102
+ const stack = spec.nodes.filter(node => node.type === 'trigger').map(node => node.id);
103
+ while (stack.length) {
104
+ const id = stack.pop();
105
+ if (reachable.has(id)) continue;
106
+ reachable.add(id);
107
+ stack.push(...outgoing.get(id));
108
+ }
109
+
110
+ const executableNodes = order
111
+ .map(id => byId.get(id))
112
+ .filter(node => node.type === 'agent' || node.type === 'job' || node.type === 'service');
113
+ if (!executableNodes.length) throw new Error(`Graph '${spec.name}' has no agent, job, or service nodes`);
114
+ for (const node of executableNodes) {
115
+ if (!reachable.has(node.id)) {
116
+ throw new Error(`Node '${node.id}' is not reachable from the trigger`);
117
+ }
118
+ if (node.type === 'agent' && !node.agent && !node.agent_slug) {
119
+ throw new Error(`Agent node '${node.id}' has neither an inline agent nor an agent_slug`);
120
+ }
121
+ if ((node.type === 'job' || node.type === 'service') && !String(node.command || '').trim()) {
122
+ throw new Error(`${node.type === 'job' ? 'Job' : 'Service'} node '${node.id}' has no command`);
123
+ }
124
+ }
125
+ return executableNodes;
126
+ }
@@ -0,0 +1,193 @@
1
+ import { LocalAgent } from '../core/local-agent.mjs';
2
+ import { backgroundTasks } from '../core/background-tasks.mjs';
3
+ import { classifyShell, TIERS } from '../core/risk-tier.mjs';
4
+ import { createScopedToolExecutor } from '../terminal/agents.mjs';
5
+ import { namespaceNodeEvents } from './events.mjs';
6
+
7
+ /**
8
+ * Execute one agent node. Substrates:
9
+ * 'direct' — a per-node LocalAgent instance calling the model API
10
+ * directly. One instance per node makes the node's resolved
11
+ * model deterministic (LocalAgent fixes model at construction).
12
+ * 'session' — an injected adapter (ctx.sessionSubstrate) that runs the
13
+ * node through the logged-in backend flow. The engine stays
14
+ * terminal-agnostic; the REPL provides the adapter.
15
+ *
16
+ * Every tool call flows through a scoped executor that enforces the
17
+ * node's effective allowlist and tags calls internal/subAgent, so hooks,
18
+ * plugin-tool gating, and attribution behave exactly like direct agent
19
+ * runs today.
20
+ */
21
+ export async function* runNode(node, agent, instruction, ctx, options = {}) {
22
+ if (node.type === 'job') {
23
+ yield* namespaceNodeEvents(runJobNode(node, ctx, options), { node, runId: options.runId });
24
+ return;
25
+ }
26
+ if (node.type === 'service') {
27
+ yield* namespaceNodeEvents(runServiceNode(node, ctx, options), { node, runId: options.runId });
28
+ return;
29
+ }
30
+ const effectiveAgent = withEffectiveTools(node, agent);
31
+ const scopedExecutor = createScopedToolExecutor(ctx.toolExecutor, effectiveAgent, {
32
+ projectRoot: ctx.cwd || process.cwd(),
33
+ });
34
+ const substrate = selectSubstrate(ctx, options);
35
+
36
+ if (substrate === 'session') {
37
+ yield* namespaceNodeEvents(
38
+ ctx.sessionSubstrate(effectiveAgent, node, instruction, { scopedExecutor }),
39
+ { node, runId: options.runId },
40
+ );
41
+ return;
42
+ }
43
+
44
+ const model = node.model || effectiveAgent.model || ctx.defaultModel || null;
45
+ const { apiKey = null, openRouterKey = null } = ctx.credentials || {};
46
+ if (!apiKey && !openRouterKey) {
47
+ throw new Error(
48
+ `Cannot run node '${node.id}' locally: no model API key. ` +
49
+ 'Set ANTHROPIC_API_KEY or OPENROUTER_API_KEY, or log in and use the session substrate.',
50
+ );
51
+ }
52
+
53
+ // Plugin tools the agent declares need their schemas in the model's
54
+ // tool list (built-ins are hardcoded in LocalAgent; plugin schemas are
55
+ // dynamic). Execution still flows through the scoped executor.
56
+ const declaredTools = new Set(
57
+ (Array.isArray(effectiveAgent.tools) ? effectiveAgent.tools : []).map(String),
58
+ );
59
+ const extraToolSchemas = (ctx.toolExecutor?.listPluginToolSchemas?.() || [])
60
+ .filter(schema => declaredTools.has(schema.name));
61
+
62
+ const localAgent = new LocalAgent({
63
+ apiKey,
64
+ openRouterKey,
65
+ model,
66
+ toolExecutor: scopedExecutor,
67
+ cwd: ctx.cwd || process.cwd(),
68
+ systemPromptOverride: effectiveAgent.prompt || effectiveAgent.system_prompt || null,
69
+ maxTurns: effectiveAgent.max_iterations || null,
70
+ extraToolSchemas,
71
+ });
72
+ if (options.signal) options.signal.addEventListener('abort', () => localAgent.cancel?.(), { once: true });
73
+
74
+ yield* namespaceNodeEvents(localAgent.execute(instruction, {}), { node, runId: options.runId });
75
+ }
76
+
77
+ // A job node runs a process instead of an LLM. It blocks its graph edge
78
+ // until the process exits (graph-level parallelism provides concurrency),
79
+ // and its handoff is the exit code plus the output tail. Commands are
80
+ // risk-classified up front — dangerous patterns fail the node
81
+ // deterministically rather than prompting mid-graph.
82
+ async function* runJobNode(node, ctx, options = {}) {
83
+ const command = String(node.command || '').trim();
84
+ const tier = classifyShell(command);
85
+ if (tier === TIERS.SHELL_DANGEROUS) {
86
+ throw new Error(`Job '${node.id}' blocked: command matches a high-risk pattern (${command.slice(0, 80)})`);
87
+ }
88
+
89
+ const started = backgroundTasks.start({
90
+ command,
91
+ cwd: ctx.cwd || process.cwd(),
92
+ timeoutMs: node.timeout_s ? node.timeout_s * 1000 : undefined,
93
+ name: node.id,
94
+ });
95
+ yield { type: 'status', data: { message: `job ${node.id}: ${command.slice(0, 80)} (${started.id})` } };
96
+ if (options.signal) {
97
+ options.signal.addEventListener('abort', () => backgroundTasks.kill(started.id), { once: true });
98
+ }
99
+
100
+ const done = await backgroundTasks.wait(started.id);
101
+ const ok = done.status === 'completed';
102
+ const summaryLine = `job ${node.id} ${done.status} (exit ${done.exit_code ?? 'n/a'}, ${done.duration_s}s)`;
103
+ const tail = String(done.tail || '').trim();
104
+ if (!ok) {
105
+ throw new Error(`${summaryLine}${tail ? `\n${tail.slice(-2000)}` : ''}`);
106
+ }
107
+ yield {
108
+ type: 'complete',
109
+ data: {
110
+ final_response: `${summaryLine}${tail ? `\n${tail}` : ''}`,
111
+ job_id: done.id,
112
+ exit_code: done.exit_code,
113
+ log_path: done.log_path,
114
+ },
115
+ };
116
+ }
117
+
118
+ // A service node starts a long-lived process (dev server) and completes
119
+ // at READINESS, not exit — downstream nodes run against the live service.
120
+ // The process is registered in options.serviceJobs so the graph runner
121
+ // kills it when the run ends. Readiness: ready.port (TCP connect),
122
+ // ready.log_pattern (regex on output tail), or ready.delay_s (default 2s).
123
+ async function* runServiceNode(node, ctx, options = {}) {
124
+ const command = String(node.command || '').trim();
125
+ const tier = classifyShell(command);
126
+ if (tier === TIERS.SHELL_DANGEROUS) {
127
+ throw new Error(`Service '${node.id}' blocked: command matches a high-risk pattern (${command.slice(0, 80)})`);
128
+ }
129
+
130
+ const started = backgroundTasks.start({
131
+ command,
132
+ cwd: ctx.cwd || process.cwd(),
133
+ timeoutMs: 0, // services live until the graph run ends
134
+ name: node.id,
135
+ });
136
+ if (Array.isArray(options.serviceJobs)) options.serviceJobs.push(started.id);
137
+ yield { type: 'status', data: { message: `service ${node.id}: ${command.slice(0, 80)} (${started.id})` } };
138
+
139
+ const ready = node.ready || {};
140
+ const timeoutMs = (Number(ready.timeout_s) || 60) * 1000;
141
+ const deadline = Date.now() + timeoutMs;
142
+ const poll = async () => {
143
+ const job = backgroundTasks.describe(started.id);
144
+ if (!job || job.status !== 'running') {
145
+ throw new Error(`service ${node.id} exited before becoming ready (${job?.status}, exit ${job?.exit_code})\n${String(job?.tail || '').slice(-1000)}`);
146
+ }
147
+ if (ready.port) return await portOpen(Number(ready.port));
148
+ if (ready.log_pattern) return new RegExp(ready.log_pattern).test(job.tail || '');
149
+ return true; // no probe declared → ready after the initial delay
150
+ };
151
+
152
+ await new Promise(resolve => setTimeout(resolve, (Number(ready.delay_s) || 2) * 1000));
153
+ while (!(await poll())) {
154
+ if (Date.now() > deadline) {
155
+ backgroundTasks.kill(started.id);
156
+ throw new Error(`service ${node.id} not ready within ${timeoutMs / 1000}s`);
157
+ }
158
+ await new Promise(resolve => setTimeout(resolve, 1000));
159
+ }
160
+
161
+ yield {
162
+ type: 'complete',
163
+ data: {
164
+ final_response: `service ${node.id} ready (${started.id}${ready.port ? `, port ${ready.port}` : ''}). It stays up for the rest of this run.`,
165
+ job_id: started.id,
166
+ },
167
+ };
168
+ }
169
+
170
+ async function portOpen(port) {
171
+ const { Socket } = await import('node:net');
172
+ return new Promise(resolve => {
173
+ const socket = new Socket();
174
+ const done = (ok) => { socket.destroy(); resolve(ok); };
175
+ socket.setTimeout(750, () => done(false));
176
+ socket.once('error', () => done(false));
177
+ socket.connect(port, '127.0.0.1', () => done(true));
178
+ });
179
+ }
180
+
181
+ function selectSubstrate(ctx, options) {
182
+ const requested = options.substrate || 'auto';
183
+ if (requested === 'session' || requested === 'direct') return requested;
184
+ return typeof ctx.sessionSubstrate === 'function' && ctx.auth?.token ? 'session' : 'direct';
185
+ }
186
+
187
+ // node.tools, when present, narrows the agent's own allowlist (intersection).
188
+ function withEffectiveTools(node, agent) {
189
+ const agentTools = Array.isArray(agent.tools) ? agent.tools : [];
190
+ if (!Array.isArray(node.tools) || !node.tools.length) return agent;
191
+ const narrowed = new Set(node.tools);
192
+ return { ...agent, tools: agentTools.filter(tool => narrowed.has(tool)) };
193
+ }