lazyclaw 4.2.0 → 4.2.2

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.
@@ -31,6 +31,32 @@ export class SlackError extends Error {
31
31
  }
32
32
  }
33
33
 
34
+ // Decide whether a Socket Mode event should drive a handler call.
35
+ // Pulled out of dispatchEvent so we can unit-test the filter without
36
+ // standing up a WebSocket. Returns false (= skip) for:
37
+ // - non-objects / null
38
+ // - the bot's own message in any of the four wire shapes Slack uses
39
+ // (legacy bot_id, legacy bot_message subtype, modern bot_profile.id,
40
+ // or the message.user field matching our cached auth.test user_id —
41
+ // this last one is the chat:write.customize trap that caused the
42
+ // v4.2.0 listener-loop)
43
+ // - non-message-shaped events (we only handle `message` and
44
+ // `app_mention`)
45
+ // - empty / whitespace-only text bodies (defense in depth so a
46
+ // stray blocks-only post can't loop us back into "(empty message)"
47
+ // fallback)
48
+ export function shouldDispatchEvent(event, { selfUserId = null, selfBotId = null } = {}) {
49
+ if (!event || typeof event !== 'object') return false;
50
+ if (event.bot_id || event.subtype === 'bot_message') return false;
51
+ if (selfUserId && event.user === selfUserId) return false;
52
+ if (selfBotId && event.bot_id === selfBotId) return false;
53
+ if (selfBotId && event.bot_profile && event.bot_profile.id === selfBotId) return false;
54
+ if (event.type !== 'app_mention' && event.type !== 'message') return false;
55
+ const text = typeof event.text === 'string' ? event.text : '';
56
+ if (text.trim() === '') return false;
57
+ return true;
58
+ }
59
+
34
60
  export function readSlackEnv(env = process.env) {
35
61
  const out = {
36
62
  botToken: env.SLACK_BOT_TOKEN || null,
@@ -81,7 +107,11 @@ export class SlackChannel extends Channel {
81
107
 
82
108
  // Called by Socket Mode wiring (or tests) for every inbound message
83
109
  // routed to this app. The handler returns the bot's reply; the
84
- // adapter posts it back to Slack in the same thread.
110
+ // adapter posts it back to Slack in the same thread. A null /
111
+ // empty-string reply skips the send entirely (Phase 19.2) so a
112
+ // handler that decided to stay silent — e.g. the listener dropping
113
+ // an empty-after-mention-strip inbound — doesn't leak a "(empty
114
+ // reply)" placeholder into the channel.
85
115
  async _simulateInbound(text, threadId) {
86
116
  let reply;
87
117
  try {
@@ -94,6 +124,7 @@ export class SlackChannel extends Channel {
94
124
  await this.send(threadId, `(error: ${err?.message || err})`);
95
125
  return;
96
126
  }
127
+ if (reply == null || (typeof reply === 'string' && reply.trim() === '')) return;
97
128
  await this.send(threadId, reply);
98
129
  }
99
130
 
@@ -187,6 +218,37 @@ export class SlackChannel extends Channel {
187
218
  return json.url;
188
219
  };
189
220
 
221
+ // Resolve our own identity so dispatchEvent can refuse loops where
222
+ // the router posted a message with chat:write.customize (Slack
223
+ // strips bot_id / subtype from those events, so the original
224
+ // filter missed them and we replied to ourselves). Best-effort —
225
+ // a failed auth.test leaves the filter relying on the legacy
226
+ // bot_id / subtype check; better than refusing to start.
227
+ let selfUserId = null;
228
+ let selfBotId = null;
229
+ try {
230
+ const authUrl = `${apiBase}/auth.test`;
231
+ const r = await fetch(authUrl, {
232
+ method: 'POST',
233
+ headers: {
234
+ 'Authorization': `Bearer ${this._env.botToken}`,
235
+ 'Content-Type': 'application/x-www-form-urlencoded',
236
+ },
237
+ });
238
+ if (r.ok) {
239
+ const j = await r.json().catch(() => ({}));
240
+ if (j && j.ok) {
241
+ selfUserId = j.user_id || null;
242
+ selfBotId = j.bot_id || null;
243
+ logger(`[slack] auth.test OK — self user=${selfUserId} bot=${selfBotId}\n`);
244
+ }
245
+ }
246
+ } catch (err) {
247
+ logger(`[slack] auth.test failed: ${err?.message || err}\n`);
248
+ }
249
+ this._selfUserId = selfUserId;
250
+ this._selfBotId = selfBotId;
251
+
190
252
  // (channel, ts) dedupe — a single user message can fire both
191
253
  // `message` and `app_mention` events in the same channel. Both
192
254
  // arrive as separate Socket Mode envelopes (different envelope_id),
@@ -209,10 +271,15 @@ export class SlackChannel extends Channel {
209
271
  };
210
272
 
211
273
  const dispatchEvent = async (event) => {
212
- if (!event || typeof event !== 'object') return;
213
- // Skip the bot's own messages so we don't loop on our own replies.
214
- if (event.bot_id || event.subtype === 'bot_message') return;
215
- if (event.type !== 'app_mention' && event.type !== 'message') return;
274
+ if (!shouldDispatchEvent(event, { selfUserId: this._selfUserId, selfBotId: this._selfBotId })) {
275
+ // shouldDispatchEvent already encodes the "skip the bot's own
276
+ // chat:write.customize message" trap that caused the v4.2.0
277
+ // listener loop. Bail before the reaction / handler call.
278
+ if (event && (event.type === 'message' || event.type === 'app_mention')) {
279
+ logger(`[slack] skipping ${event.type} from ${event.channel || '?'}:${event.ts || '?'}\n`);
280
+ }
281
+ return;
282
+ }
216
283
  // For DMs (`im`) channel_type is 'im'; for channel mentions we only
217
284
  // get app_mention events. Either way we have channel + ts.
218
285
  const text = typeof event.text === 'string' ? event.text : '';
@@ -227,16 +294,10 @@ export class SlackChannel extends Channel {
227
294
  const threadId = `${channel}:${replyTs}`;
228
295
  logger(`[slack] inbound ${event.type} from ${channel} (${text.length} chars)\n`);
229
296
 
230
- // Immediate acknowledgement so the user sees the bot picked up the
231
- // message before the LLM finishes. Prefer a reaction (no message
232
- // spam); fall back to a transient text reply when the workspace
233
- // doesn't grant reactions:write.
234
- const eyesOk = await this._reaction('add', channel, sourceTs, 'eyes');
235
- if (!eyesOk) {
236
- logger(`[slack] reactions:write missing — falling back to text ack\n`);
237
- try { await this.send(threadId, '_확인해보겠습니다…_'); }
238
- catch (err) { logger(`[slack] text ack failed: ${err?.message || err}\n`); }
239
- }
297
+ // Immediate acknowledgement. _ackInbound is silent when
298
+ // reactions:write is missing (Phase 19.2 no more text-ack
299
+ // spam).
300
+ const eyesOk = await this._ackInbound(channel, sourceTs, logger);
240
301
 
241
302
  try {
242
303
  await this._simulateInbound(text, threadId);
@@ -329,6 +390,24 @@ export class SlackChannel extends Channel {
329
390
  return this._socketHandle;
330
391
  }
331
392
 
393
+ // Mark an inbound message as "being worked on" without spamming the
394
+ // channel. Tries the :eyes: reaction first (silent UX). When
395
+ // reactions:write is missing we used to fall back to a text post
396
+ // ("확인해보겠습니다…") which doubled the noise per turn; Phase 19.2
397
+ // dropped that fallback so the channel stays clean when the scope
398
+ // is unavailable. The operator can flip reactions:write on at any
399
+ // time to bring the visible signal back.
400
+ //
401
+ // Exposed as a method (not closure-private in dispatchEvent) so the
402
+ // listener-noise unit tests can drive it directly.
403
+ async _ackInbound(channel, sourceTs, logger = () => {}) {
404
+ const eyesOk = await this._reaction('add', channel, sourceTs, 'eyes');
405
+ if (!eyesOk) {
406
+ logger('[slack] reactions:write missing — silent ack only (no text fallback)\n');
407
+ }
408
+ return eyesOk;
409
+ }
410
+
332
411
  // Best-effort chat.delete — used by typing-indicator workflows where
333
412
  // we post a placeholder and want to clean it up. Returns true on
334
413
  // success, silent false otherwise.
package/cli.mjs CHANGED
@@ -1484,145 +1484,78 @@ function _attachGhostAutocomplete(rl) {
1484
1484
  // Width-management rule: every inner line is forced through
1485
1485
  // `.padEnd(W)` so a stray width miscount can't punch the right
1486
1486
  // border off the box (which is exactly the bug v3.99.5 shipped:
1487
- // two of the inner lines were 33 cols vs the others' 32, so the
1488
- // rendered into the next line).
1489
- // v3.99.29 8-bit crab mascot (Claude Design crab sheet). Strict
1490
- // 17-wide canvas, 12 rows, so every row aligns in any monospace font.
1491
- // Layout: short eye-stalks (●) at cols 4 & 12, a red carapace dome
1492
- // (╭──╮ … ╰──╯) worn like a hood, an orange face box at cols 4-12
1493
- // with two dot-eyes + a mouth, two yellow legs (┃) below. State →
1494
- // expression: idle 기본 · working 화남(연기) focused+smoke · done
1495
- // 미소 smile+sparkle · error 경고/놀람 shocked+alert. Colour is
1496
- // two-tone (red shell / orange face / yellow legs) NOT white — so
1497
- // it reads like the pixel sprite even in a plain terminal.
1498
- const _MASCOT_W = 17;
1499
- const _MASCOT_BIG = {
1500
- idle: [
1501
- ' ╷ ╷ ',
1502
- ' ● ● ',
1503
- ' ╭───────────╮ ',
1504
- ' │ │ ',
1505
- ' │ │ ',
1506
- ' ╰───────────╯ ',
1507
- ' ╭───────╮ ',
1508
- ' │ ● ● │ ',
1509
- ' │ ─── │ ',
1510
- ' ╰───────╯ ',
1511
- ' ┃ ┃ ',
1512
- ' ┗┛ ┗┛ ',
1513
- ],
1514
- working: [
1515
- ' ° ╷ ╷ ° ',
1516
- ' ● ● ',
1517
- ' ╭───────────╮ ',
1518
- ' ╱╲│ │╱╲ ',
1519
- ' │ │ ',
1520
- ' ╰───────────╯ ',
1521
- ' ╭───────╮ ',
1522
- ' │ ─ ─ │ ',
1523
- ' │ ═══ │ ',
1524
- ' ╰───────╯ ',
1525
- ' ┃ ┃ ',
1526
- ' ┗┛ ┗┛ ',
1527
- ],
1528
- done: [
1529
- ' ✦ ╷ ╷ ✦ ',
1530
- ' ● ● ',
1531
- ' ╭───────────╮ ',
1532
- ' │ │ ',
1533
- ' │ │ ',
1534
- ' ╰───────────╯ ',
1535
- ' ╭───────╮ ',
1536
- ' │ ^ ^ │ ',
1537
- ' │ ‿‿‿ │ ',
1538
- ' ╰───────╯ ',
1539
- ' ┃ ┃ ',
1540
- ' ┗┛ ┗┛ ',
1541
- ],
1542
- error: [
1543
- ' \\ ╷ ╷ / ',
1544
- ' ! ● ● ! ',
1545
- ' ╭───────────╮ ',
1546
- ' │ │ ',
1547
- ' │ │ ',
1548
- ' ╰───────────╯ ',
1549
- ' ╭───────╮ ',
1550
- ' │ O O │ ',
1551
- ' │ ╭─╮ │ ',
1552
- ' ╰───────╯ ',
1553
- ' ┃ ┃ ',
1554
- ' ┗┛ ┗┛ ',
1555
- ],
1556
- };
1557
- const _MASCOT_TINY = {
1558
- idle: ' ● ● \n(│ ─ │)\n ┃ ┃ ',
1559
- working: ' ● ● \n(│ ═ │)°\n ┃ ┃ ',
1560
- done: ' ^ ^ \n(│ ‿ │)✦\n ┃ ┃ ',
1561
- error: ' O O \n(│╭╮│)!\n ┃ ┃ ',
1562
- };
1487
+ // v4.2.2 boxed figlet "lazy" wordmark, single-colour orange. The
1488
+ // previous mixed-colour banner (helmet-red letter art + ink-beige
1489
+ // caption) read as "two banners glued together" because the colour
1490
+ // changed mid-box. We use one warm orange (#F08246) for everything
1491
+ // border, letter art, caption so the eye reads it as one badge.
1492
+ //
1493
+ // Letter art is figlet "standard" (6 rows) rather than the v3.99.11
1494
+ // "small" (4 rows), because small renders as a pixel mush in most
1495
+ // terminal fonts. Standard's strokes are wide enough that the
1496
+ // letters read as `l a z y` even at small terminal sizes.
1497
+ //
1498
+ // Layout invariant: every inner row is exactly INNER_W visible cells
1499
+ // (no double-width glyphs, all chars are 1 cell in any monospace
1500
+ // font), so the right edge `│` always lands in the same column.
1501
+ //
1502
+ // _renderMascot / _renderMascotTiny are kept as stubs so any leftover
1503
+ // caller doesn't crash; no state-coloured art is produced any more.
1563
1504
 
1564
- // Crab palette true colour. Shell red, face orange, legs yellow.
1565
- // Kept as wrappers so the banner caller can compose rows freely.
1566
- const _MC_RED = (s) => `\x1b[38;2;219;59;43m${s}\x1b[0m`;
1567
- const _MC_ORG = (s) => `\x1b[38;2;239;131;48m${s}\x1b[0m`;
1568
- const _MC_YEL = (s) => `\x1b[38;2;242;169;59m${s}\x1b[0m`;
1569
-
1570
- // Two-tone renderer: rows 6-9 are the orange face box (interior cols
1571
- // 4-12 inked orange, the rest of the row red); rows 10-11 are the
1572
- // yellow legs; everything else is the red carapace. Box-drawing and
1573
- // the accent glyphs are all single BMP code points, so slice() index
1574
- // == visual column and the alignment survives the colour wrap.
1575
- function _renderMascot(state) {
1576
- const rows = _MASCOT_BIG[state] || _MASCOT_BIG.idle;
1577
- return rows.map((r, i) => {
1578
- if (i >= 6 && i <= 9) return _MC_RED(r.slice(0, 4)) + _MC_ORG(r.slice(4, 13)) + _MC_RED(r.slice(13));
1579
- if (i >= 10) return _MC_YEL(r);
1580
- return _MC_RED(r);
1581
- });
1505
+ const _ORANGE_RGB = '241;130;70'; // #F18246
1506
+ function _orange(s) { return `\x1b[38;2;${_ORANGE_RGB}m${s}\x1b[0m`; }
1507
+
1508
+ function _renderMascot() {
1509
+ return ['lazyclaw'];
1582
1510
  }
1583
1511
 
1584
- // Tiny inline mascot — picked up by chat/agent helpers when they want
1585
- // to flash a one-line status without re-rendering the whole banner.
1586
- // Returns a string; callers add their own newline. Inked crab-red so
1587
- // it stays on-brand wherever it's spliced in.
1588
- function _renderMascotTiny(state) {
1589
- return _MC_RED(_MASCOT_TINY[state] || _MASCOT_TINY.idle);
1512
+ function _renderMascotTiny() {
1513
+ return 'lazyclaw';
1590
1514
  }
1591
1515
 
1516
+ // figlet "standard" "lazy", trimmed of leading blank line. Each row
1517
+ // is left-padded by two spaces inside the box, and every row is then
1518
+ // padded to INNER_W cells.
1519
+ const _LAZY_STANDARD = [
1520
+ ' _ ',
1521
+ '| | __ _ _____ _ ',
1522
+ '| |/ _` |_ / | | | ',
1523
+ '| | (_| |/ /| |_| | ',
1524
+ '|_|\\__,_/___|\\__, | ',
1525
+ ' |___/ ',
1526
+ ];
1527
+
1528
+ const _INNER_W = 32; // 2 left pad + 20 letter art + caption headroom
1529
+
1592
1530
  function _renderBanner(version) {
1593
- const ink = (s) => `\x1b[38;2;241;234;217m${s}\x1b[0m`;
1594
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
1595
1531
  const v = String(version || '?.?.?');
1596
- const left = _renderMascot('idle');
1597
- const right = [
1598
- '',
1599
- '',
1600
- '',
1601
- ` ${ink('lazyclaw')} ${dim('v' + v)}`,
1602
- ` ${dim('a sleepy 8-bit')}`,
1603
- ` ${dim('terminal assistant')}`,
1604
- '',
1605
- '',
1606
- '',
1607
- '',
1608
- '',
1609
- '',
1532
+ const cap = ` LazyClaw v${v}`;
1533
+ const padInner = (s) => ' ' + s.padEnd(_INNER_W - 2, ' ');
1534
+ const wrap = (inner) => _orange('') + _orange(inner) + _orange('│');
1535
+ const top = _orange('' + '─'.repeat(_INNER_W) + '╮');
1536
+ const bot = _orange('' + '─'.repeat(_INNER_W) + '╯');
1537
+ return [
1538
+ top,
1539
+ ..._LAZY_STANDARD.map((row) => wrap(padInner(row))),
1540
+ wrap(padInner(cap)),
1541
+ bot,
1610
1542
  ];
1611
- return left.map((l, i) => ' ' + l + (right[i] || ''));
1612
1543
  }
1613
1544
 
1614
1545
  function _printChatBanner(activeProvName, activeModel, version) {
1615
1546
  if (!process.stdout.isTTY) return;
1616
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
1617
- const ok = (s) => `\x1b[32m${s}\x1b[0m`;
1547
+ // Single-hue header: labels dim-orange, values/emphasis full-orange, so the
1548
+ // four caption rows below the box read as part of the same warm badge.
1549
+ const dimOrange = (s) => `\x1b[2m\x1b[38;2;${_ORANGE_RGB}m${s}\x1b[0m`;
1550
+ const orange = _orange;
1618
1551
  const lines = [
1619
1552
  '',
1620
1553
  ..._renderBanner(version),
1621
1554
  '',
1622
- ` ${dim('provider ·')} ${ok(activeProvName)}`,
1623
- ` ${dim('model ·')} ${ok(activeModel || '(default)')}`,
1624
- ` ${dim('slash ·')} /help · /model · /provider · /exit`,
1625
- ` ${dim('hint ·')} → ${dim('to accept the suggested command,')} Tab ${dim('to cycle')}`,
1555
+ ` ${dimOrange('provider ·')} ${orange(activeProvName)}`,
1556
+ ` ${dimOrange('model ·')} ${orange(activeModel || '(default)')}`,
1557
+ ` ${dimOrange('slash ·')} ${orange('/help · /model · /provider · /exit')}`,
1558
+ ` ${dimOrange('hint ·')} ${orange('')} ${dimOrange('to accept the suggested command,')} ${orange('Tab')} ${dimOrange('to cycle')}`,
1626
1559
  '',
1627
1560
  ];
1628
1561
  process.stdout.write(lines.join('\n') + '\n');
@@ -4935,7 +4868,15 @@ async function cmdSlack(sub, positional, flags = {}) {
4935
4868
 
4936
4869
  const handler = async ({ threadId, text }) => {
4937
4870
  const cleaned = String(text || '').replace(/<@[A-Z0-9]+>/g, '').trim();
4938
- if (!cleaned) return '(empty message)';
4871
+ // Phase 19.2: never post a placeholder ("(empty message)" / "(empty
4872
+ // reply)") into the thread — those leaked through as visible noise
4873
+ // when listener self-message echoes happened. Return null and let
4874
+ // _simulateInbound's guard drop the send. Real provider errors
4875
+ // still surface so the operator knows something went wrong.
4876
+ if (!cleaned) {
4877
+ process.stderr.write('[slack] dropping empty inbound (after mention strip)\n');
4878
+ return null;
4879
+ }
4939
4880
  const msgs = threadMsgs.get(threadId) || [];
4940
4881
  msgs.push({ role: 'user', content: cleaned });
4941
4882
  let acc = '';
@@ -4953,7 +4894,11 @@ async function cmdSlack(sub, positional, flags = {}) {
4953
4894
  msgs.push({ role: 'assistant', content: acc });
4954
4895
  if (msgs.length > MAX_TURNS) msgs.splice(0, msgs.length - MAX_TURNS);
4955
4896
  threadMsgs.set(threadId, msgs);
4956
- return acc || '(empty reply)';
4897
+ if (!acc.trim()) {
4898
+ process.stderr.write('[slack] provider returned empty text — not posting\n');
4899
+ return null;
4900
+ }
4901
+ return acc;
4957
4902
  };
4958
4903
 
4959
4904
  const { SlackChannel } = await import('./channels/slack.mjs');
@@ -179,9 +179,10 @@ export async function reflectOnce({ agent, task, apiKey, baseUrl, fetchImpl, max
179
179
 
180
180
  async function pickAdapter(provider) {
181
181
  switch (provider) {
182
- case 'anthropic': return await import('../providers/tool_use/anthropic.mjs');
183
- case 'openai': return await import('../providers/tool_use/openai.mjs');
184
- case 'gemini': return await import('../providers/tool_use/gemini.mjs');
182
+ case 'anthropic': return await import('../providers/tool_use/anthropic.mjs');
183
+ case 'openai': return await import('../providers/tool_use/openai.mjs');
184
+ case 'gemini': return await import('../providers/tool_use/gemini.mjs');
185
+ case 'claude-cli': return await import('../providers/tool_use/claude_cli.mjs');
185
186
  default:
186
187
  throw new AgentMemoryError(`provider "${provider}" does not support reflection`, 'AGENT_MEMORY_NO_PROVIDER');
187
188
  }
@@ -24,6 +24,7 @@ import { listToolSchemas, runTool, ToolError } from './tool_runner.mjs';
24
24
  import * as anthropic from '../providers/tool_use/anthropic.mjs';
25
25
  import * as openai from '../providers/tool_use/openai.mjs';
26
26
  import * as gemini from '../providers/tool_use/gemini.mjs';
27
+ import * as claudeCli from '../providers/tool_use/claude_cli.mjs';
27
28
 
28
29
  export class AgentTurnError extends Error {
29
30
  constructor(message, code) {
@@ -37,9 +38,14 @@ const DEFAULT_MAX_ITERATIONS = 10;
37
38
 
38
39
  function adapterFor(provider) {
39
40
  switch (provider) {
40
- case 'anthropic': return { ...anthropic, toolSchemas: anthropic.toAnthropicTools };
41
- case 'openai': return { ...openai, toolSchemas: openai.toOpenAITools };
42
- case 'gemini': return { ...gemini, toolSchemas: gemini.toGeminiTools };
41
+ case 'anthropic': return { ...anthropic, toolSchemas: anthropic.toAnthropicTools };
42
+ case 'openai': return { ...openai, toolSchemas: openai.toOpenAITools };
43
+ case 'gemini': return { ...gemini, toolSchemas: gemini.toGeminiTools };
44
+ // claude-cli runs the tool-use loop INSIDE the binary. Our adapter
45
+ // resolves every call to kind:'final' so the mention router still
46
+ // gets a normalised reply, even though no tool_calls envelope is
47
+ // ever observed.
48
+ case 'claude-cli': return { ...claudeCli, toolSchemas: (s) => s };
43
49
  default:
44
50
  throw new AgentTurnError(`provider "${provider}" does not support tool-use yet`, 'PROVIDER_UNSUPPORTED');
45
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "4.2.0",
3
+ "version": "4.2.2",
4
4
  "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",
@@ -0,0 +1,215 @@
1
+ // claude-cli tool-use adapter (Phase 19).
2
+ //
3
+ // Unlike the API-based adapters, this one wraps the official `claude`
4
+ // CLI (Claude Code). The CLI runs the *entire* tool-use loop inside
5
+ // itself — bash, edit, read, write, grep, etc. — and emits a single
6
+ // final text answer. From lazyclaw's mention-router perspective every
7
+ // call resolves to `{ kind: 'final', text }` after one iteration, so
8
+ // the multi-agent handoff still works (we just lose lazyclaw's audit
9
+ // log for tools claude ran on its own; the CLI keeps its own log).
10
+ //
11
+ // Wiring choices:
12
+ // - --output-format stream-json + --verbose so we can accumulate
13
+ // text deltas exactly like providers/claude_cli.mjs does (proven
14
+ // parser, no second JSON shape to maintain).
15
+ // - --permission-mode bypassPermissions because spec §10 #6 ships
16
+ // destructive-pattern confirmation OFF by default. Audit log
17
+ // still captures every tool the CLI runs (via the CLI's own
18
+ // telemetry — we don't double-write here).
19
+ // - --tools maps the lazyclaw whitelist into claude's built-in
20
+ // names (bash → Bash, etc.). When the whitelist is empty we pass
21
+ // `""` so tools are fully disabled.
22
+ // - --system-prompt carries the agent role + memory + team metadata
23
+ // the mention router builds.
24
+ // - LAZYCLAW_CLAUDE_BIN overrides the binary path so tests can
25
+ // point at a deterministic shim script.
26
+
27
+ import { spawn } from 'node:child_process';
28
+
29
+ const DEFAULT_BIN = 'claude';
30
+ const LAZYCLAW_TO_CLAUDE_TOOL = {
31
+ bash: 'Bash',
32
+ read: 'Read',
33
+ write: 'Write',
34
+ grep: 'Grep',
35
+ web_search: 'WebSearch',
36
+ web_fetch: 'WebFetch',
37
+ };
38
+
39
+ export class ClaudeCliToolUseError extends Error {
40
+ constructor(message, code, body) {
41
+ super(message);
42
+ this.name = 'ClaudeCliToolUseError';
43
+ this.code = code || 'CLAUDE_CLI_ERR';
44
+ if (body) this.body = body;
45
+ }
46
+ }
47
+
48
+ // The schemas value from listToolSchemas comes in lazyclaw form; claude
49
+ // expects a comma-separated string of its OWN built-in tool names.
50
+ // Returning a string rather than an array lets us pass it as a single
51
+ // CLI argument unchanged. An empty string is meaningful — it disables
52
+ // all tools — so callers should distinguish "no tools whitelisted" from
53
+ // "tools field omitted".
54
+ export function toClaudeTools(schemas) {
55
+ if (!Array.isArray(schemas) || schemas.length === 0) return '';
56
+ const names = schemas
57
+ .map((s) => LAZYCLAW_TO_CLAUDE_TOOL[s?.name])
58
+ .filter(Boolean);
59
+ return [...new Set(names)].join(',');
60
+ }
61
+
62
+ export function normalizeHistory(turns) {
63
+ return Array.isArray(turns) ? [...turns] : [];
64
+ }
65
+
66
+ export function initialUserMessage(text) {
67
+ return { role: 'user', content: String(text) };
68
+ }
69
+
70
+ // Build the single prompt string the CLI sees. Concatenate every
71
+ // non-system message, prefixing prior assistant turns with a "[prior]
72
+ // " marker so the model can tell them apart from the live user turn.
73
+ // Mirrors the established pattern in providers/claude_cli.mjs.
74
+ function buildPrompt(messages) {
75
+ const lastUser = [...messages].reverse().find((m) => m && m.role === 'user');
76
+ if (!lastUser) return '';
77
+ const history = messages
78
+ .filter((m) => m !== lastUser && m && m.role !== 'system')
79
+ .map((m) => {
80
+ const tag = m.role === 'assistant' ? '[prior assistant]' : '[prior user]';
81
+ return `${tag} ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`;
82
+ })
83
+ .join('\n\n');
84
+ return history ? `${history}\n\n${lastUser.content}` : String(lastUser.content);
85
+ }
86
+
87
+ function extractTextDelta(obj) {
88
+ if (!obj || typeof obj !== 'object') return '';
89
+ if (obj.type !== 'stream_event') return '';
90
+ const ev = obj.event || {};
91
+ if (ev.type === 'content_block_delta') {
92
+ const d = ev.delta || {};
93
+ if (d.type === 'text_delta' && typeof d.text === 'string') return d.text;
94
+ }
95
+ return '';
96
+ }
97
+
98
+ // Walk stream-json output to completion, accumulating text deltas, and
99
+ // return the final concatenated reply. The CLI also emits an
100
+ // 'assistant' record carrying the full message content; we fall back
101
+ // to that when no `stream_event` deltas were observed (some claude
102
+ // versions only emit the consolidated record).
103
+ async function readUntilDone(proc) {
104
+ return new Promise((resolve, reject) => {
105
+ let stdout = '';
106
+ let stderr = '';
107
+ let acc = '';
108
+ let assistantFallback = '';
109
+ let resultText = '';
110
+ proc.stdout.setEncoding('utf8');
111
+ proc.stderr.setEncoding('utf8');
112
+ proc.stdout.on('data', (chunk) => {
113
+ stdout += chunk;
114
+ let nl;
115
+ while ((nl = stdout.indexOf('\n')) >= 0) {
116
+ const line = stdout.slice(0, nl).trim();
117
+ stdout = stdout.slice(nl + 1);
118
+ if (!line) continue;
119
+ let obj;
120
+ try { obj = JSON.parse(line); } catch { continue; }
121
+ const delta = extractTextDelta(obj);
122
+ if (delta) acc += delta;
123
+ // Fallback: consolidated assistant content block (no streaming).
124
+ if (obj?.type === 'assistant' && obj?.message?.content) {
125
+ for (const block of obj.message.content) {
126
+ if (block?.type === 'text' && typeof block.text === 'string') {
127
+ assistantFallback += block.text;
128
+ }
129
+ }
130
+ }
131
+ if (obj?.type === 'result' && typeof obj.result === 'string') {
132
+ resultText = obj.result;
133
+ }
134
+ }
135
+ });
136
+ proc.stderr.on('data', (chunk) => { stderr += chunk; });
137
+ proc.on('error', reject);
138
+ proc.on('close', (code) => {
139
+ if (code !== 0) {
140
+ return reject(new ClaudeCliToolUseError(`claude CLI exit ${code}: ${stderr.slice(0, 300)}`, 'CLAUDE_CLI_EXIT', stderr));
141
+ }
142
+ // Prefer accumulated stream deltas; fall back to the assistant
143
+ // record or the final result text when streaming was disabled.
144
+ const text = acc || assistantFallback || resultText || '';
145
+ resolve(text);
146
+ });
147
+ });
148
+ }
149
+
150
+ export async function callOnce({
151
+ messages,
152
+ tools = [],
153
+ model,
154
+ apiKey, // unused — the CLI authenticates itself
155
+ system,
156
+ baseUrl, // unused
157
+ fetchImpl, // unused
158
+ signal,
159
+ bin,
160
+ cwd,
161
+ permissionMode = 'bypassPermissions',
162
+ } = {}) {
163
+ if (!Array.isArray(messages) || messages.length === 0) {
164
+ throw new ClaudeCliToolUseError('messages[] is required and non-empty', 'NO_MESSAGES');
165
+ }
166
+ const prompt = buildPrompt(messages);
167
+ if (!prompt) {
168
+ throw new ClaudeCliToolUseError('messages produced an empty prompt', 'NO_PROMPT');
169
+ }
170
+ const args = [
171
+ '-p', prompt,
172
+ '--output-format', 'stream-json',
173
+ '--include-partial-messages',
174
+ '--verbose',
175
+ '--permission-mode', permissionMode,
176
+ ];
177
+ if (model) args.push('--model', model);
178
+ if (system && String(system).trim()) {
179
+ args.push('--system-prompt', String(system));
180
+ }
181
+ // Phase 19: pass the lazyclaw whitelist through to claude's --tools
182
+ // even when empty (an empty string explicitly disables every tool).
183
+ const toolsArg = toClaudeTools(tools);
184
+ args.push('--tools', toolsArg);
185
+
186
+ const binPath = bin || process.env.LAZYCLAW_CLAUDE_BIN || DEFAULT_BIN;
187
+ let proc;
188
+ try {
189
+ proc = spawn(binPath, args, {
190
+ cwd: cwd || process.cwd(),
191
+ stdio: ['ignore', 'pipe', 'pipe'],
192
+ env: process.env,
193
+ });
194
+ } catch (err) {
195
+ if (err?.code === 'ENOENT') {
196
+ throw new ClaudeCliToolUseError(`claude CLI binary not found at "${binPath}"`, 'CLAUDE_CLI_NOT_FOUND');
197
+ }
198
+ throw err;
199
+ }
200
+ const onAbort = () => { try { proc.kill('SIGTERM'); } catch { /* gone */ } };
201
+ if (signal) signal.addEventListener('abort', onAbort);
202
+ try {
203
+ const text = await readUntilDone(proc);
204
+ return { kind: 'final', text, raw: null };
205
+ } finally {
206
+ if (signal) signal.removeEventListener('abort', onAbort);
207
+ }
208
+ }
209
+
210
+ // The CLI handles tools internally — these helpers exist to keep the
211
+ // adapter surface symmetrical with anthropic/openai/gemini. Neither
212
+ // path is actually exercised at runtime because callOnce always
213
+ // returns kind:'final'.
214
+ export function assistantTurnMessages(_resp) { return []; }
215
+ export function toolResultMessages(_results) { return []; }