@bahulam/code 0.1.1 → 0.1.3

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 (53) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +39 -0
  3. package/package.json +8 -9
  4. package/pulse/lib/tool-categories.ts +13 -0
  5. package/src/commands/device.mjs +121 -0
  6. package/src/commands/pair.mjs +190 -0
  7. package/src/commands/remote.mjs +110 -0
  8. package/src/config/env.mjs +2 -2
  9. package/src/core/event-log.mjs +393 -0
  10. package/src/core/headless.mjs +198 -0
  11. package/src/core/loop.mjs +276 -0
  12. package/src/core/memory-disk.mjs +210 -0
  13. package/src/core/paths.mjs +36 -0
  14. package/src/core/stream-client.mjs +28 -9
  15. package/src/core/tool-executor.mjs +64 -16
  16. package/src/daemon/approval-store.mjs +253 -0
  17. package/src/daemon/attach-client.mjs +361 -0
  18. package/src/daemon/daemonize.mjs +151 -0
  19. package/src/daemon/event-tap.mjs +197 -0
  20. package/src/daemon/input-lock.mjs +191 -0
  21. package/src/daemon/relay-client.mjs +258 -0
  22. package/src/daemon/session-core.mjs +179 -0
  23. package/src/daemon/session-list.mjs +26 -0
  24. package/src/daemon/session-publisher.mjs +78 -0
  25. package/src/daemon/socket-server.mjs +329 -0
  26. package/src/daemon/stop-daemon.mjs +18 -0
  27. package/src/permissions/checker.mjs +6 -6
  28. package/src/permissions/prompt.mjs +8 -7
  29. package/src/skills/installer.mjs +8 -0
  30. package/src/terminal/ansi.mjs +85 -9
  31. package/src/terminal/main.mjs +97 -3
  32. package/src/terminal/repl.mjs +389 -6
  33. package/src/terminal/skills-picker.mjs +121 -0
  34. package/src/terminal/skills.mjs +3 -3
  35. package/src/tools/analyze-code.mjs +39 -0
  36. package/src/tools/bash.mjs +1 -1
  37. package/src/tools/edit.mjs +18 -18
  38. package/src/tools/git-diff.mjs +34 -0
  39. package/src/tools/git-status.mjs +30 -0
  40. package/src/tools/glob.mjs +5 -2
  41. package/src/tools/grep.mjs +1 -1
  42. package/src/tools/meta-tools.mjs +85 -0
  43. package/src/tools/read-files.mjs +37 -0
  44. package/src/tools/read.mjs +20 -10
  45. package/src/tools/registry.mjs +20 -0
  46. package/src/tools/remember.mjs +147 -0
  47. package/src/tools/search-files.mjs +41 -0
  48. package/src/tools/write-project.mjs +62 -0
  49. package/src/tools/write.mjs +1 -1
  50. package/src/ui/banner.mjs +1 -1
  51. package/src/ui/slash-commands.mjs +16 -0
  52. package/src/ui/sub-agent.mjs +8 -2
  53. package/src/ui/transcript-block.mjs +4 -1
@@ -17,6 +17,19 @@ import { buildWorkScope, promptProjectRoots } from './work-scope.mjs';
17
17
  import { persistProjectArtifacts } from './project-artifacts.mjs';
18
18
  import { TarangAuth } from '../auth/tarang-auth.mjs';
19
19
  import { ApprovalManager } from './approval.mjs';
20
+ // daemon wiring — headless (and `bahulam daemonize`) also starts the socket
21
+ // server + relay bridge when eventlog is enabled. Without this the daemon
22
+ // is invisible to attach clients and to paired mobile devices.
23
+ import { tapSseEvent, registerBroadcaster } from '../daemon/event-tap.mjs';
24
+ import { startSocketServer } from '../daemon/socket-server.mjs';
25
+ import { resolvePending } from '../daemon/approval-store.mjs';
26
+ import { startRelayBridge } from '../daemon/relay-client.mjs';
27
+ import { loadRemoteConfig } from '../commands/remote.mjs';
28
+ import { writeSessionMeta } from './event-log.mjs';
29
+ import { daemonSessionDir } from './paths.mjs';
30
+ import { publishSessionDirectory, markSessionClosed } from '../daemon/session-publisher.mjs';
31
+ import * as fsSync from 'node:fs';
32
+ import * as pathSync from 'node:path';
20
33
  import {
21
34
  appendVisionAnalysisToInstruction,
22
35
  prepareImageAttachments,
@@ -174,6 +187,9 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
174
187
  const subAgents = []; // { type, model, duration_s, tool_calls, success }
175
188
  let stagnationCount = 0;
176
189
  let usage = {}; // { input_tokens, output_tokens, cache_read, cache_write }
190
+ // daemon wiring — one-shot per headless invocation.
191
+ let prd092Started = false;
192
+ let currentSessionId = null;
177
193
 
178
194
  try {
179
195
  for await (const event of client.execute(instruction, execContext)) {
@@ -305,7 +321,110 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
305
321
  // Surface session_id in the JSONL so multi-turn harnesses can
306
322
  // capture it from turn N and forward on turn N+1 (via TARANG_SESSION_ID).
307
323
  if (data?.session_id) emit({ type: 'session_info', session_id: data.session_id });
324
+
325
+ // — same daemon wiring the interactive REPL does on
326
+ // session_info: start the socket server so attach clients can
327
+ // connect, tap events, and (if remote is enabled) dial the
328
+ // relay so mobile can see the session. Gated by env var, one-
329
+ // shot per process, fire-and-forget so a wire failure never
330
+ // interrupts the turn.
331
+ const _sid = data?.session_id;
332
+ if (
333
+ _sid &&
334
+ process.env.BAHULAM_DAEMON_EVENTLOG === '1' &&
335
+ !prd092Started
336
+ ) {
337
+ prd092Started = true;
338
+ (async () => {
339
+ try {
340
+ const server = await startSocketServer({
341
+ sessionId: _sid,
342
+ onCommand: {
343
+ approve: async (payload, attachId) => resolvePending('approve', payload?.apr_id, attachId, payload?.note),
344
+ deny: async (payload, attachId) => resolvePending('deny', payload?.apr_id, attachId, payload?.note),
345
+ interrupt: async () => { try { if (typeof client?.cancel === 'function') client.cancel(); } catch {} },
346
+ send_message: async () => { /* Slice C follow-up */ },
347
+ },
348
+ });
349
+ registerBroadcaster(evt => server.broadcastEvent(evt));
350
+
351
+ // Write meta.json + daemon.pid so `bahulam list` /
352
+ // `bahulam stop` and the mobile session directory
353
+ // can find this session.
354
+ try {
355
+ await writeSessionMeta({
356
+ sessionId: _sid,
357
+ meta: {
358
+ cwd: process.cwd(),
359
+ model: options.model || null,
360
+ pid: process.pid,
361
+ sock_path: server.sockPath,
362
+ opened_at: new Date().toISOString(),
363
+ headless: true,
364
+ },
365
+ });
366
+ fsSync.writeFileSync(
367
+ pathSync.join(daemonSessionDir(_sid), 'daemon.pid'),
368
+ String(process.pid),
369
+ { mode: 0o600 },
370
+ );
371
+ } catch (err) {
372
+ try { process.stderr.write(`[prd-092] meta/pid write: ${err.message}\n`); } catch {}
373
+ }
374
+
375
+ // Slice M — publish to session_directory so mobile
376
+ // can list this session even before/after the
377
+ // relay handshake. Fire-and-forget; a failure
378
+ // here doesn't affect anything else.
379
+ try {
380
+ publishSessionDirectory({
381
+ sessionId: _sid,
382
+ token: creds.token,
383
+ cwd: process.cwd(),
384
+ model: options.model || null,
385
+ status: 'running',
386
+ }).catch(() => {});
387
+ } catch { /* silent */ }
388
+
389
+ // Optional relay dial (Slice H) — only when the
390
+ // user opted in via `bahulam remote enable`.
391
+ try {
392
+ const remoteCfg = loadRemoteConfig();
393
+ if (remoteCfg?.enabled) {
394
+ startRelayBridge({
395
+ sessionId: _sid,
396
+ remoteConfig: remoteCfg,
397
+ registerBroadcaster,
398
+ onCommand: {
399
+ approve: async (payload, attachId) => resolvePending('approve', payload?.apr_id, attachId, payload?.note),
400
+ deny: async (payload, attachId) => resolvePending('deny', payload?.apr_id, attachId, payload?.note),
401
+ interrupt: async () => { try { if (typeof client?.cancel === 'function') client.cancel(); } catch {} },
402
+ send_message: async () => { /* Slice C follow-up */ },
403
+ },
404
+ });
405
+ }
406
+ } catch (err) {
407
+ try { process.stderr.write(`[prd-092] relay bridge: ${err.message}\n`); } catch {}
408
+ }
409
+ } catch (err) {
410
+ try { process.stderr.write(`[prd-092] socket server: ${err.message}\n`); } catch {}
411
+ }
412
+ })();
413
+ }
414
+ // Tap this event too, so the very first frame lands in the
415
+ // event log (before broadcasters are registered).
416
+ if (_sid && process.env.BAHULAM_DAEMON_EVENTLOG === '1') {
417
+ tapSseEvent({ type: 'session_info', data }, { sessionId: _sid });
418
+ }
419
+ } else if (process.env.BAHULAM_DAEMON_EVENTLOG === '1') {
420
+ // Tap every other event too, matching the REPL's for-await tap.
421
+ // sessionId is populated once session_info has landed.
422
+ if (currentSessionId) {
423
+ tapSseEvent(event, { sessionId: currentSessionId });
424
+ }
308
425
  }
426
+ // Track current session id for the tap.
427
+ if (type === 'session_info' && data?.session_id) currentSessionId = data.session_id;
309
428
 
310
429
  if (type === 'complete') {
311
430
  if (data?.rate_limit) rateLimit = data.rate_limit;
@@ -456,5 +575,84 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
456
575
  process.stderr.write(`\n--- Response ---\n${finalContent.slice(0, 2000)}\n`);
457
576
  }
458
577
 
578
+ // — DAEMON_HOLD mode.
579
+ // BAHULAM_DAEMON_HOLD=1 keep alive forever
580
+ // BAHULAM_DAEMON_HOLD=<seconds> keep alive for N seconds
581
+ // BAHULAM_DAEMON_SPAWNED=1 (implicit) hold with a default TTL if
582
+ // HOLD not explicitly set
583
+ //
584
+ // When held, we DO NOT exit after agent_complete. The socket server and
585
+ // relay bridge (started earlier by the session_info wiring) stay up so
586
+ // attach clients and paired mobile devices can still see the session,
587
+ // reconnect, replay events from disk, and — once send_message is wired
588
+ // — kick off follow-up turns without spinning up a fresh daemon.
589
+ //
590
+ // Exit signals honored: SIGTERM (from `bahulam stop <id>`), SIGINT
591
+ // (Ctrl-C from a foreground shell), the TTL timer, and the idle-exit
592
+ // policy from §6.6 (30-min default with no attach).
593
+ if (prd092Started) {
594
+ const hold = process.env.BAHULAM_DAEMON_HOLD;
595
+ const spawned = process.env.BAHULAM_DAEMON_SPAWNED === '1';
596
+ const holdMode = hold != null || spawned;
597
+ if (holdMode) {
598
+ // Slice M — mark idle in the session directory so the mobile
599
+ // list shows the session as available-but-not-active.
600
+ try {
601
+ publishSessionDirectory({
602
+ sessionId: currentSessionId,
603
+ token: creds.token,
604
+ cwd: process.cwd(),
605
+ model: options.model || null,
606
+ status: 'idle',
607
+ }).catch(() => {});
608
+ } catch { /* silent */ }
609
+
610
+ const ttlSec = _resolveHoldTtl(hold, spawned);
611
+ const banner = ttlSec === Infinity ? 'until stopped' : `for ${ttlSec}s`;
612
+ log(`[prd-092] daemon holding ${banner}. Attach: bahulam attach ${currentSessionId || '<id>'}`);
613
+ await _blockUntilStop(ttlSec);
614
+ log('[prd-092] daemon exiting (hold expired or signal received)');
615
+ }
616
+ // Whether held or not, mark the session closed in the directory
617
+ // so mobile doesn't list a gone-forever daemon as "idle" forever.
618
+ try {
619
+ await markSessionClosed({ sessionId: currentSessionId, token: creds.token });
620
+ } catch { /* silent */ }
621
+ }
622
+
459
623
  process.exit(0);
460
624
  }
625
+
626
+ // ── daemon-hold helpers ─────────────────────────────────────
627
+
628
+ /**
629
+ * How long to hold. Explicit HOLD wins; else spawned-default is 30min
630
+ * (§6.6 idle_ttl). Infinity for "1" or "true".
631
+ */
632
+ function _resolveHoldTtl(holdEnv, spawned) {
633
+ if (holdEnv === '1' || holdEnv === 'true') return Infinity;
634
+ if (holdEnv != null && holdEnv !== '') {
635
+ const n = Number(holdEnv);
636
+ if (Number.isFinite(n) && n > 0) return n;
637
+ }
638
+ if (spawned) return 30 * 60; // §6.6 default idle_ttl
639
+ return 60; // defensive fallback
640
+ }
641
+
642
+ /**
643
+ * Await SIGTERM/SIGINT or the TTL. Resolves without an error either way —
644
+ * the caller then falls through to process.exit(0) so bahulam stop looks
645
+ * like a clean shutdown, not a crash.
646
+ */
647
+ function _blockUntilStop(ttlSec) {
648
+ return new Promise(resolve => {
649
+ let done = false;
650
+ const _done = () => { if (done) return; done = true; resolve(); };
651
+ process.once('SIGTERM', _done);
652
+ process.once('SIGINT', _done);
653
+ if (ttlSec !== Infinity) {
654
+ const t = setTimeout(_done, ttlSec * 1000);
655
+ if (typeof t.unref === 'function') t.unref();
656
+ }
657
+ });
658
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Thin Agent Loop — iterates turns against /v1/agent/* gateway endpoints.
3
+ *
4
+ * PRD-091 §6.2-6.3: CLI owns iteration. Gateway owns LLM calls,
5
+ * prompt assembly, memory management, and sub-agent orchestration.
6
+ *
7
+ * This loop replaces local-agent.mjs's direct LLM calls with a
8
+ * POST /v1/agent/turn to the gateway. Everything else — tool dispatch,
9
+ * message accumulation, event shapes — matches the existing patterns
10
+ * the REPL already consumes.
11
+ *
12
+ * Usage (replacing client.execute()):
13
+ * const { createAgentLoop } = await import('../core/loop.mjs');
14
+ * for await (const event of createAgentLoop({
15
+ * sessionId: 'sess_...',
16
+ * messages: session.agentHistory,
17
+ * toolExecutor: executor,
18
+ * gatewayFetch: (body) => fetch(`${GATEWAY_URL}/v1/agent/turn`, { ... }),
19
+ * })) {
20
+ * // same event types as client.execute() / LocalAgent.execute()
21
+ * }
22
+ */
23
+
24
+ import * as os from 'node:os';
25
+ import * as path from 'node:path';
26
+ import * as fs from 'node:fs';
27
+
28
+ const MAX_TURNS = 999;
29
+
30
+ // ── Auth / URL discovery ────────────────────────────────────────────────
31
+ // Same precedence as bundled-runtime.mjs::_readCliToken so gateway calls
32
+ // use the SAME credential the user's login saved. Kept inline here so
33
+ // loop.mjs stays importable without pulling the bundled-runtime module.
34
+ function _readCliToken() {
35
+ if (process.env.BAHULAM_API_KEY) return process.env.BAHULAM_API_KEY;
36
+ if (process.env.BAHULAM_CLI_TOKEN) return process.env.BAHULAM_CLI_TOKEN;
37
+ if (process.env.B0_TOKEN) return process.env.B0_TOKEN;
38
+ try {
39
+ const raw = fs.readFileSync(path.join(os.homedir(), '.bahulam', 'config.json'), 'utf8');
40
+ const parsed = JSON.parse(raw);
41
+ return (parsed && typeof parsed.token === 'string' && parsed.token.trim()) || null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function _gatewayUrl() {
48
+ return (process.env.BAHULAM_GATEWAY_URL || 'https://gateway.bahulam.ai/v1').replace(/\/+$/, '');
49
+ }
50
+
51
+ /**
52
+ * Create a session on the gateway. Returns the session config the loop
53
+ * uses on every subsequent /v1/agent/turn (server-owned prompt +
54
+ * tool_schemas + model). Called ONCE per REPL session before iterating.
55
+ */
56
+ export async function createGatewaySession({
57
+ workspace = 'kepler-code',
58
+ model = process.env.BAHULAM_MODEL || undefined,
59
+ token = _readCliToken(),
60
+ gateway = _gatewayUrl(),
61
+ } = {}) {
62
+ if (!token) {
63
+ throw new Error(
64
+ 'Not logged in — set BAHULAM_API_KEY or run `bahulam login` first.',
65
+ );
66
+ }
67
+ const base = gateway.endsWith('/v1') ? gateway.slice(0, -3) : gateway;
68
+ const res = await fetch(`${base}/v1/agent/session`, {
69
+ method: 'POST',
70
+ headers: {
71
+ 'Content-Type': 'application/json',
72
+ 'Authorization': `Bearer ${token}`,
73
+ 'X-Bahulam-User-Id': process.env.BAHULAM_USER_ID || 'cli-user',
74
+ 'X-Bahulam-Tier': process.env.BAHULAM_TIER || 'free',
75
+ },
76
+ body: JSON.stringify({ workspace, ...(model ? { model } : {}) }),
77
+ });
78
+ if (!res.ok) {
79
+ const text = await res.text();
80
+ throw new Error(`session create failed (HTTP ${res.status}): ${text.slice(0, 300)}`);
81
+ }
82
+ return res.json(); // { session_id, workspace, prompt, tool_schemas, model, expires_at, ... }
83
+ }
84
+
85
+ /**
86
+ * Execute one turn against /v1/chat/completions (the standard OpenAI-shape
87
+ * gateway endpoint). This reuses the existing metering, entitlement, and
88
+ * provider translation the gateway already does for BYOK — we don't need
89
+ * a new /v1/agent/turn endpoint. Session config (prompt + tools + model)
90
+ * comes from createGatewaySession() once, then every turn just posts
91
+ * standard OpenAI messages + tools + model.
92
+ *
93
+ * The `session` param carries { prompt, tool_schemas, model } from
94
+ * createGatewaySession. We inject them into the request server prefers
95
+ * client to send explicitly so per-tier gating on the gateway side
96
+ * still works (gateway decides what a caller may use; client just
97
+ * echoes what it received).
98
+ */
99
+ async function _callGateway({ session, messages }) {
100
+ const token = _readCliToken();
101
+ if (!token) throw new Error('Missing gateway token (BAHULAM_API_KEY / bahulam login).');
102
+ const base = _gatewayUrl();
103
+ // Support both `<host>` and `<host>/v1` in BAHULAM_GATEWAY_URL.
104
+ const url = base.endsWith('/v1')
105
+ ? `${base}/chat/completions`
106
+ : `${base}/v1/chat/completions`;
107
+
108
+ // Strip Bahulam-specific metadata from tool schemas before sending
109
+ // — server also strips defensively, but keep the wire clean.
110
+ const tools = (session.tool_schemas || []).map(t => ({
111
+ type: t.type || 'function',
112
+ function: t.function,
113
+ }));
114
+
115
+ const body = {
116
+ model: session.model,
117
+ messages, // OpenAI-shape end-to-end — {role, content} or
118
+ // assistant.tool_calls + tool.tool_call_id
119
+ ...(tools.length ? { tools, tool_choice: 'auto' } : {}),
120
+ temperature: 0.0,
121
+ stream: false,
122
+ };
123
+
124
+ const t0 = performance.now();
125
+ const res = await fetch(url, {
126
+ method: 'POST',
127
+ headers: {
128
+ 'Content-Type': 'application/json',
129
+ 'Authorization': `Bearer ${token}`,
130
+ 'X-Bahulam-User-Id': process.env.BAHULAM_USER_ID || 'cli-user',
131
+ 'X-Bahulam-Tier': process.env.BAHULAM_TIER || 'free',
132
+ },
133
+ body: JSON.stringify(body),
134
+ });
135
+ const roundTripMs = performance.now() - t0;
136
+
137
+ if (!res.ok) {
138
+ const text = await res.text();
139
+ throw new Error(`chat/completions failed (HTTP ${res.status}): ${text.slice(0, 400)}`);
140
+ }
141
+ const data = await res.json();
142
+ const choice = (data.choices || [{}])[0];
143
+ return {
144
+ message: choice.message || {}, // OpenAI assistant message shape
145
+ finish_reason: choice.finish_reason,
146
+ usage: data.usage || {},
147
+ timing_ms: { roundTrip: Math.round(roundTripMs * 10) / 10 },
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Create an async generator that iterates turns against the gateway.
153
+ *
154
+ * Uses standard OpenAI /v1/chat/completions shape throughout — the same
155
+ * shape the gateway already speaks to upstream providers. No custom
156
+ * content-block conversion, no /v1/agent/turn endpoint needed.
157
+ *
158
+ * @param {Object} opts
159
+ * @param {Object} opts.session - From createGatewaySession()
160
+ * { session_id, prompt, tool_schemas, model, ... }
161
+ * @param {Array} opts.messages - Conversation history (mutated across turns).
162
+ * Caller seeds with [{role:'user', content:input}].
163
+ * @param {Object} opts.toolExecutor - From createToolExecutor() — .execute(name, input)
164
+ * @param {Function} [opts.gatewayFetch] - Override for testing
165
+ * @param {number} [opts.maxTurns] - Max iterations (default 999)
166
+ * @yields {Object} Events matching the REPL event protocol
167
+ */
168
+ export async function* createAgentLoop({
169
+ session,
170
+ messages,
171
+ toolExecutor,
172
+ gatewayFetch = _callGateway,
173
+ maxTurns = MAX_TURNS,
174
+ } = {}) {
175
+ let toolCount = 0;
176
+ const startTime = Date.now();
177
+ let usage = { input_tokens: 0, output_tokens: 0 };
178
+
179
+ // Prepend the workspace system prompt if not already present. The
180
+ // messages array (caller-owned) may accumulate across REPL turns,
181
+ // so only inject once.
182
+ if (session?.prompt && !messages.some(m => m.role === 'system')) {
183
+ messages.unshift({ role: 'system', content: session.prompt });
184
+ }
185
+
186
+ for (let i = 0; i < maxTurns; i++) {
187
+ // ── Call gateway (/v1/chat/completions) ─────────────────────
188
+ const turn = await gatewayFetch({ session, messages });
189
+ const asst = turn.message || {};
190
+ usage = {
191
+ input_tokens: (usage.input_tokens || 0) + (turn.usage?.prompt_tokens || 0),
192
+ output_tokens: (usage.output_tokens || 0) + (turn.usage?.completion_tokens || 0),
193
+ };
194
+
195
+ // ── Push assistant response into message history (OpenAI shape)
196
+ // content=null when tool_calls exist (OpenAI protocol requirement),
197
+ // else the text string.
198
+ const openAiAsst = { role: 'assistant' };
199
+ if (asst.tool_calls && asst.tool_calls.length > 0) {
200
+ openAiAsst.content = asst.content ?? null;
201
+ openAiAsst.tool_calls = asst.tool_calls;
202
+ } else {
203
+ openAiAsst.content = asst.content ?? '';
204
+ }
205
+ messages.push(openAiAsst);
206
+
207
+ // ── Yield text content ──────────────────────────────────────
208
+ if (typeof asst.content === 'string' && asst.content) {
209
+ yield { type: 'content', data: { text: asst.content } };
210
+ }
211
+
212
+ // ── Handle tool calls ───────────────────────────────────────
213
+ const toolCalls = asst.tool_calls || [];
214
+ if (toolCalls.length > 0) {
215
+ for (const tc of toolCalls) {
216
+ const id = tc.id;
217
+ const name = tc.function?.name || '';
218
+ let input = {};
219
+ try { input = JSON.parse(tc.function?.arguments || '{}'); }
220
+ catch { input = { _raw: tc.function?.arguments }; }
221
+
222
+ yield { type: 'tool_call', data: { call_id: id, tool: name, args: input } };
223
+
224
+ // Execute locally via the existing tool executor
225
+ let result;
226
+ try {
227
+ result = await toolExecutor.execute(name, input || {});
228
+ } catch (err) {
229
+ result = { success: false, output: `Error: ${err.message}` };
230
+ }
231
+
232
+ yield { type: 'tool_done', data: { tool: name, duration_ms: 0 } };
233
+ toolCount++;
234
+
235
+ // Push tool response as a role='tool' message (OpenAI shape).
236
+ // tool_call_id links it to the assistant's tool_calls[i].id.
237
+ // MUST push one message per tool_call in the SAME order as
238
+ // the assistant's tool_calls, or OpenAI rejects the next
239
+ // turn with "tool_call_ids did not have response messages".
240
+ messages.push({
241
+ role: 'tool',
242
+ tool_call_id: id,
243
+ content: typeof result.output === 'string'
244
+ ? result.output
245
+ : JSON.stringify(result.output ?? result),
246
+ });
247
+ }
248
+ // Loop continues to next iteration
249
+ } else {
250
+ // ── No tool calls → turn is done ────────────────────────
251
+ const duration = (Date.now() - startTime) / 1000;
252
+ yield {
253
+ type: 'complete',
254
+ data: {
255
+ summary: 'Done',
256
+ changes: toolCount,
257
+ duration_s: duration,
258
+ usage,
259
+ },
260
+ };
261
+ return;
262
+ }
263
+ }
264
+
265
+ // Max turns reached
266
+ yield { type: 'error', data: { message: `Max turns (${maxTurns}) reached.`, fatal: false } };
267
+ yield {
268
+ type: 'complete',
269
+ data: {
270
+ summary: 'Aborted (max turns)',
271
+ changes: toolCount,
272
+ duration_s: (Date.now() - startTime) / 1000,
273
+ usage,
274
+ },
275
+ };
276
+ }