zelari-code 0.6.2 → 0.7.5

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 (95) hide show
  1. package/bin/zelari-code.js +1 -1
  2. package/dist/agents/councilApi.js +7 -7
  3. package/dist/agents/councilApi.js.map +1 -1
  4. package/dist/agents/councilDirectives.js +3 -3
  5. package/dist/agents/promoteMember.js +2 -2
  6. package/dist/agents/roles.js +50 -32
  7. package/dist/agents/roles.js.map +1 -1
  8. package/dist/agents/skills/builtin/debugging.js +4 -4
  9. package/dist/agents/skills/builtin/debugging.js.map +1 -1
  10. package/dist/agents/skills/builtin/docs.js +23 -13
  11. package/dist/agents/skills/builtin/docs.js.map +1 -1
  12. package/dist/agents/skills/builtin/git-ops.js +3 -3
  13. package/dist/agents/skills/builtin/git-ops.js.map +1 -1
  14. package/dist/agents/skills/builtin/planning.js +4 -4
  15. package/dist/agents/skills/builtin/planning.js.map +1 -1
  16. package/dist/agents/skills/builtin/refactoring.js +3 -3
  17. package/dist/agents/skills/builtin/refactoring.js.map +1 -1
  18. package/dist/agents/skills/builtin/review.js +9 -9
  19. package/dist/agents/skills/builtin/review.js.map +1 -1
  20. package/dist/agents/skills/builtin/testing.js +3 -3
  21. package/dist/agents/skills/builtin/testing.js.map +1 -1
  22. package/dist/agents/tools.js +2 -9
  23. package/dist/agents/tools.js.map +1 -1
  24. package/dist/cli/app.js +63 -29
  25. package/dist/cli/app.js.map +1 -1
  26. package/dist/cli/components/ChatStream.js +40 -126
  27. package/dist/cli/components/ChatStream.js.map +1 -1
  28. package/dist/cli/components/CollapsibleToolOutput.js +8 -26
  29. package/dist/cli/components/CollapsibleToolOutput.js.map +1 -1
  30. package/dist/cli/components/Header.js +6 -18
  31. package/dist/cli/components/Header.js.map +1 -1
  32. package/dist/cli/components/LiveRegion.js +54 -0
  33. package/dist/cli/components/LiveRegion.js.map +1 -0
  34. package/dist/cli/components/Sidebar.js +33 -31
  35. package/dist/cli/components/Sidebar.js.map +1 -1
  36. package/dist/cli/components/StatusBar.js +42 -0
  37. package/dist/cli/components/StatusBar.js.map +1 -0
  38. package/dist/cli/components/ToolOutput.js +68 -0
  39. package/dist/cli/components/ToolOutput.js.map +1 -0
  40. package/dist/cli/components/toolFormat.js +195 -0
  41. package/dist/cli/components/toolFormat.js.map +1 -0
  42. package/dist/cli/grokOAuth.js +93 -75
  43. package/dist/cli/grokOAuth.js.map +1 -1
  44. package/dist/cli/hooks/chatState.js +139 -0
  45. package/dist/cli/hooks/chatState.js.map +1 -0
  46. package/dist/cli/hooks/eventsToMessages.js +1 -1
  47. package/dist/cli/hooks/eventsToMessages.js.map +1 -1
  48. package/dist/cli/hooks/messageHelpers.js +11 -4
  49. package/dist/cli/hooks/messageHelpers.js.map +1 -1
  50. package/dist/cli/hooks/useBatchedMessages.js +14 -10
  51. package/dist/cli/hooks/useBatchedMessages.js.map +1 -1
  52. package/dist/cli/hooks/useChatTurn.js +400 -87
  53. package/dist/cli/hooks/useChatTurn.js.map +1 -1
  54. package/dist/cli/hooks/useSession.js +23 -11
  55. package/dist/cli/hooks/useSession.js.map +1 -1
  56. package/dist/cli/hooks/useSlashDispatch.js +9 -1
  57. package/dist/cli/hooks/useSlashDispatch.js.map +1 -1
  58. package/dist/cli/main.bundled.js +16972 -15277
  59. package/dist/cli/main.bundled.js.map +4 -4
  60. package/dist/cli/main.js +35 -1
  61. package/dist/cli/main.js.map +1 -1
  62. package/dist/cli/mcp/mcpClient.js +152 -0
  63. package/dist/cli/mcp/mcpClient.js.map +1 -0
  64. package/dist/cli/mcp/mcpManager.js +139 -0
  65. package/dist/cli/mcp/mcpManager.js.map +1 -0
  66. package/dist/cli/refreshRegistry.js +9 -6
  67. package/dist/cli/refreshRegistry.js.map +1 -1
  68. package/dist/cli/skillsMd.js +155 -0
  69. package/dist/cli/skillsMd.js.map +1 -0
  70. package/dist/cli/slashHandlers/skills.js +16 -0
  71. package/dist/cli/slashHandlers/skills.js.map +1 -1
  72. package/dist/cli/toolRegistry.js +31 -0
  73. package/dist/cli/toolRegistry.js.map +1 -1
  74. package/dist/cli/wizard/index.js +1 -1
  75. package/dist/cli/workspace/paths.js +20 -13
  76. package/dist/cli/workspace/paths.js.map +1 -1
  77. package/dist/cli/workspace/storage.js +7 -3
  78. package/dist/cli/workspace/storage.js.map +1 -1
  79. package/dist/cli/workspace/stubs.js +122 -44
  80. package/dist/cli/workspace/stubs.js.map +1 -1
  81. package/dist/cli/workspace/workspaceSummary.js +255 -0
  82. package/dist/cli/workspace/workspaceSummary.js.map +1 -0
  83. package/dist/main/core/AgentHarness.js +36 -7
  84. package/dist/main/core/AgentHarness.js.map +1 -1
  85. package/dist/main/core/tools/builtin/listFiles.js +43 -5
  86. package/dist/main/core/tools/builtin/listFiles.js.map +1 -1
  87. package/dist/main/core/tools/builtin/search.js +28 -120
  88. package/dist/main/core/tools/builtin/search.js.map +1 -1
  89. package/package.json +2 -2
  90. package/dist/cli/oauthCallbackServer.js +0 -207
  91. package/dist/cli/oauthCallbackServer.js.map +0 -1
  92. package/dist/main/core/tools/builtin/_walk.js +0 -129
  93. package/dist/main/core/tools/builtin/_walk.js.map +0 -1
  94. package/dist/main/core/tools/builtin/diff.js +0 -477
  95. package/dist/main/core/tools/builtin/diff.js.map +0 -1
@@ -1,20 +1,27 @@
1
1
  // @ts-nocheck — pre-existing strict-mode type narrowing issues carried over
2
2
  // from app.tsx. Runtime is correct; tighten signatures in a follow-up.
3
- import { useState, useRef, useCallback } from 'react';
4
- import { AgentHarness } from '@zelari/core/harness';
5
- import { getMetricsLogger } from '../metrics.js';
6
- import { openaiCompatibleProvider, providerFromEnv, providerConfigFor } from '../provider/openai-compatible.js';
7
- import { providerFailover } from '../providerFailover.js';
8
- import { resolveFailoverStream } from '../crossProviderFailover.js';
9
- import { PROVIDERS } from '../keyStore.js';
10
- import { createBuiltinToolRegistry } from '../toolRegistry.js';
11
- import { appendOrExtendStreamingAssistant, appendSystem, appendToolStart, finalizeStreamingAssistant, updateToolMessageEnd, } from './messageHelpers.js';
12
- import { computeSessionStatsDelta } from './chatStats.js';
3
+ import { useState, useRef, useCallback } from "react";
4
+ import { AgentHarness } from "@zelari/core/harness";
5
+ import { getMetricsLogger } from "../metrics.js";
6
+ import { openaiCompatibleProvider, providerFromEnv, providerConfigFor, resolveActiveProvider, } from "../provider/openai-compatible.js";
7
+ import { providerFailover } from "../providerFailover.js";
8
+ import { resolveFailoverStream } from "../crossProviderFailover.js";
9
+ import { resolveShell } from "@zelari/core/harness/tools/builtin/shellResolver";
10
+ import { PROVIDERS } from "../keyStore.js";
11
+ import { createBuiltinToolRegistry } from "../toolRegistry.js";
12
+ import { appendOrExtendStreamingAssistant, appendSystem, appendToolStart, finalizeStreamingAssistant, updateToolMessageEnd, } from "./messageHelpers.js";
13
+ import { setStreaming, finalizeStreaming, startTool, completeTool, } from "./chatState.js";
14
+ import { computeSessionStatsDelta } from "./chatStats.js";
13
15
  export function useChatTurn(params) {
14
- const { sessionId, writerRef, setMessages, commitStreaming, flushStreaming, setBusy, setSessionActive, setSessionStats } = params;
16
+ const { sessionId, writerRef, setMessages, commitStreaming, flushStreaming, setBusy, setSessionActive, setSessionStats, setLive, liveRef, } = params;
15
17
  const harnessRef = useRef(null);
16
18
  const [queueCount, setQueueCount] = useState(0);
17
- const dispatchPrompt = useCallback(async (userText) => {
19
+ // v0.7.0: when the live region is wired, streaming + tool events route
20
+ // there; otherwise we fall back to the v0.6 single-array behavior so the
21
+ // existing unit tests (which pass only setMessages/commitStreaming) keep
22
+ // asserting on `messages` directly.
23
+ const useLiveModel = !!(setLive && liveRef);
24
+ const dispatchPrompt = useCallback(async (userText, opts) => {
18
25
  // v0.4.3 audit fix: provider resolution + harness construction now
19
26
  // live INSIDE the try block. Previously, throws from providerFromEnv,
20
27
  // resolveFailoverStream, or createBuiltinToolRegistry happened
@@ -27,14 +34,18 @@ export function useChatTurn(params) {
27
34
  try {
28
35
  envConfig = await providerFromEnv();
29
36
  if (!envConfig) {
30
- appendSystem(setMessages, 'OPENAI_API_KEY not set. Export it before running zelari-code.');
37
+ // Name the ACTIVE provider the old hardcoded "OPENAI_API_KEY not
38
+ // set" message told grok/glm/minimax users to export the wrong var.
39
+ const active = resolveActiveProvider();
40
+ const spec = PROVIDERS.find((p) => p.id === active);
41
+ appendSystem(setMessages, `No API key for the active provider "${active}". Set ${spec?.envVar ?? "the provider API key env var"} or run /login ${active}.`);
31
42
  return;
32
43
  }
33
44
  setBusy(true);
34
45
  const { registry: toolRegistry } = createBuiltinToolRegistry();
35
46
  const baseProviderStream = openaiCompatibleProvider(envConfig);
36
47
  const failoverResolution = await resolveFailoverStream({
37
- failoverEnabled: process.env.ANATHEMA_FAILOVER !== '0',
48
+ failoverEnabled: process.env.ANATHEMA_FAILOVER !== "0",
38
49
  envValue: process.env.ANATHEMA_FAILOVER_PROVIDER,
39
50
  primaryProviderId: envConfig.providerId,
40
51
  primary: baseProviderStream,
@@ -58,13 +69,101 @@ export function useChatTurn(params) {
58
69
  fallback: failoverResolution.fallback,
59
70
  });
60
71
  const cwd = process.cwd();
72
+ // v0.7.3: surface the council plan (if any) to the single agent too.
73
+ // The plan lives in .zelari/plan.json but the agent had no idea it
74
+ // existed — users had to paste task-file paths by hand. Best-effort:
75
+ // no plan → null → zero prompt-token cost.
76
+ let planSummary = null;
77
+ // v0.7.4: give the single agent the same project awareness the council
78
+ // has (tech stack, file layout, scripts) plus a pointer to the council
79
+ // workspace so it reads .zelari/plan.json with its own tools.
80
+ let workspaceSummary = null;
81
+ let zelariReadHint = '';
82
+ try {
83
+ const { buildPlanSummary, buildWorkspaceSummary, buildZelariReadHint } = await import('../workspace/workspaceSummary.js');
84
+ planSummary = buildPlanSummary(cwd);
85
+ workspaceSummary = buildWorkspaceSummary(cwd);
86
+ zelariReadHint = buildZelariReadHint(cwd);
87
+ // v0.7.4: close the plan loop. The single agent implements the tasks
88
+ // the council planned, but had no official way to advance their
89
+ // status — it would have to hand-edit plan.json with write_file
90
+ // (racy, no validation). Register the workspace `updateTask` stub so
91
+ // status changes go through the same mutex + atomic plan.json write
92
+ // the council uses. Only when a plan exists: fresh projects don't pay
93
+ // the extra tool-schema prompt tokens.
94
+ // v0.7.5: also register any workspace stubs a /skill invocation
95
+ // requires (opts.requiredTools), mapping the Electron-era `searchRAG`
96
+ // to the CLI's `searchDocuments`.
97
+ const wantedWorkspaceTools = new Set();
98
+ if (planSummary)
99
+ wantedWorkspaceTools.add('updateTask');
100
+ const WORKSPACE_STUB_NAMES = new Set([
101
+ 'createPhase', 'createTask', 'updateTask', 'addIdea', 'createMilestone',
102
+ 'createDocument', 'searchDocuments', 'linkDocuments', 'getDocumentBacklinks',
103
+ ]);
104
+ for (const raw of opts?.requiredTools ?? []) {
105
+ const name = raw === 'searchRAG' ? 'searchDocuments' : raw;
106
+ if (WORKSPACE_STUB_NAMES.has(name))
107
+ wantedWorkspaceTools.add(name);
108
+ }
109
+ if (wantedWorkspaceTools.size > 0) {
110
+ const { createWorkspaceContext } = await import('../workspace/stubs.js');
111
+ const { createWorkspaceToolRegistry } = await import('../workspace/toolRegistry.js');
112
+ const wsRegistry = createWorkspaceToolRegistry(createWorkspaceContext(cwd));
113
+ for (const name of wantedWorkspaceTools) {
114
+ const td = wsRegistry.get(name);
115
+ if (td)
116
+ toolRegistry.register(td);
117
+ }
118
+ }
119
+ // v0.7.5: MCP tools. Discovery runs once per process (lazy singleton);
120
+ // per-turn cost after that is just re-registering into the fresh
121
+ // registry. Disabled with ZELARI_MCP=0. Best-effort like the rest.
122
+ try {
123
+ const { registerMcpTools } = await import('../mcp/mcpManager.js');
124
+ const mcp = await registerMcpTools(toolRegistry, cwd);
125
+ for (const w of mcp.warnings)
126
+ appendSystem(setMessages, w);
127
+ }
128
+ catch {
129
+ // MCP is an enhancement — a broken server config must not block prompts.
130
+ }
131
+ }
132
+ catch {
133
+ // Plan summary is a nice-to-have — never block a prompt on it.
134
+ }
135
+ // NOTE: computed AFTER the workspace wiring so updateTask (when
136
+ // registered) is advertised in the # Available Tools section too.
61
137
  const toolList = toolRegistry.toOpenAITools().map((t) => `- ${t.function.name}: ${t.function.description}`).join('\n');
138
+ // v0.7.2 (C3): platform-aware shell guidance. The model must know which
139
+ // shell the `bash` tool actually runs in so it writes the right commands
140
+ // (POSIX for Git Bash, Windows-native for cmd.exe fallback).
141
+ const resolvedShell = resolveShell();
142
+ const isWindows = process.platform === 'win32';
143
+ const shellGuidance = resolvedShell.isBash
144
+ ? `The bash tool runs commands via Git Bash / MSYS2 (${resolvedShell.shell}). Write POSIX commands: ls, grep, $VAR, &&, /c/Users/... all work.`
145
+ : isWindows
146
+ ? `The bash tool runs commands via cmd.exe (Git Bash not found). Write Windows-native commands: use dir (not ls), %VAR% (not $VAR), avoid POSIX-only syntax.`
147
+ : `The bash tool runs commands via /bin/sh.`;
148
+ // v0.7.3: the shell has NO interactive stdin. Without this warning the
149
+ // model retried `npm create vite` four times against the interactive
150
+ // prompt ("Operation cancelled") and then gave up asking the user.
151
+ const nonInteractiveGuidance = 'The shell is NON-INTERACTIVE (stdin closed): commands that prompt for input fail immediately. ' +
152
+ 'Always pass non-interactive flags (--yes, -y, --template, --force). ' +
153
+ 'If a scaffolder still insists on prompting (e.g. `npm create vite` in a non-empty directory), do NOT retry it — ' +
154
+ 'scaffold into a fresh empty subdirectory and move the files, or write package.json/configs/sources yourself with write_file, then run `npm install`.';
62
155
  const systemPrompt = [
63
156
  "You are Zelari Code, an interactive AI coding agent operating directly in the user's terminal.",
64
157
  '',
65
158
  'You ARE connected to this machine and have real tools to read, modify, and explore the codebase.',
66
159
  "Never claim you lack filesystem or shell access — you have it. Use your tools instead of asking the user to paste file contents.",
67
160
  '',
161
+ '# Platform & Shell',
162
+ `platform: ${process.platform}`,
163
+ `shell: ${resolvedShell.via}`,
164
+ shellGuidance,
165
+ nonInteractiveGuidance,
166
+ '',
68
167
  '# Working Directory',
69
168
  `You are running in: ${cwd}`,
70
169
  'All relative file paths are resolved against this directory. Always work with real files here.',
@@ -72,12 +171,32 @@ export function useChatTurn(params) {
72
171
  '# Available Tools',
73
172
  'You can call these tools. Use them to take action and gather information autonomously:',
74
173
  toolList,
174
+ ...(workspaceSummary ? ['', workspaceSummary] : []),
175
+ ...(zelariReadHint ? ['', zelariReadHint] : []),
176
+ ...(planSummary ? ['', planSummary] : []),
75
177
  '',
76
178
  '# Guidelines',
77
- '- When the user asks you to write code, debug, or explore, be proactive: list files (bash: ls, list_files) and read key files (read_file) to understand the project instead of asking the user to paste file contents.',
179
+ '- When the user asks you to write code, debug, or explore, be proactive: list files (list_files, or bash: ls/dir) and read key files (read_file) to understand the project instead of asking the user to paste file contents.',
180
+ '- If a command fails or is cancelled, do NOT retry variants of the same command: diagnose why (read the hint in the result if present) and take a DIFFERENT approach (e.g. create the files yourself with write_file).',
181
+ '- After a tool result, CONTINUE your answer from where you left off. NEVER restate or re-print text you already wrote earlier in this turn.',
78
182
  "- Only invoke tools when they are necessary to answer the user's prompt. If the user is just saying hello or greeting them (e.g., \"ciao\", \"hello\"), simply greet them back and ask how you can help, without running any commands or tools.",
79
183
  '- When you finish a task, briefly summarize what you did.',
184
+ ...(planSummary
185
+ ? [
186
+ '- Plan tasks: when you START working on a plan task call updateTask {taskId, status: "in_progress"}; when it is complete and verified call updateTask {taskId, status: "done"}. NEVER edit .zelari/plan.json by hand.',
187
+ ]
188
+ : []),
80
189
  ].join('\n');
190
+ // v0.7.1 (A2): per-turn tool-call budget for single-prompt turns.
191
+ // The council sets 5; the single-prompt path previously set NONE, so a
192
+ // flailing model could loop for the full MAX_TOOL_LOOP_ITERATIONS (12)
193
+ // of junk calls (e.g. read_file same path ×3 then silence). Default 25,
194
+ // overridable via ZELARI_MAX_TOOL_CALLS.
195
+ const maxToolCallsPerTurn = (() => {
196
+ const raw = process.env.ZELARI_MAX_TOOL_CALLS;
197
+ const n = raw ? Number.parseInt(raw, 10) : 25;
198
+ return Number.isFinite(n) && n > 0 ? n : 25;
199
+ })();
81
200
  const harness = new AgentHarness({
82
201
  model: envConfig.model,
83
202
  provider: 'openai-compatible',
@@ -93,6 +212,7 @@ export function useChatTurn(params) {
93
212
  toolRegistry,
94
213
  providerStream,
95
214
  cwd,
215
+ maxToolCallsPerTurn,
96
216
  });
97
217
  harnessRef.current = harness;
98
218
  setQueueCount(harness.queueLength);
@@ -117,7 +237,10 @@ export function useChatTurn(params) {
117
237
  // Message boundary: drain buffered deltas, then seal the streamed
118
238
  // bubble so the next message starts fresh instead of merging.
119
239
  flushStreaming();
120
- finalizeStreamingAssistant(setMessages);
240
+ if (useLiveModel)
241
+ finalizeStreaming(setMessages, setLive);
242
+ else
243
+ finalizeStreamingAssistant(setMessages);
121
244
  streamContent = '';
122
245
  }
123
246
  if (event.type === 'queue_update') {
@@ -162,12 +285,19 @@ export function useChatTurn(params) {
162
285
  streamContent += event.delta;
163
286
  // Route through the throttled setter so per-token deltas (50-200/sec)
164
287
  // coalesce into ≤60 renders/sec instead of flickering the TUI.
165
- // v0.5.0: forward the council member identity when present so
166
- // the UI can render `🜂 Caronte: …` headers.
167
- appendOrExtendStreamingAssistant(commitStreaming, streamContent, Date.now(), {
168
- ...(event.memberId ? { memberId: event.memberId } : {}),
169
- ...(event.memberName ? { memberName: event.memberName } : {}),
170
- });
288
+ if (useLiveModel) {
289
+ setStreaming(commitStreaming, streamContent, Date.now(), {
290
+ ...(event.memberId ? { memberId: event.memberId } : {}),
291
+ ...(event.memberName ? { memberName: event.memberName } : {}),
292
+ });
293
+ }
294
+ else {
295
+ // Legacy single-array fallback (existing tests).
296
+ appendOrExtendStreamingAssistant(commitStreaming, streamContent, Date.now(), {
297
+ ...(event.memberId ? { memberId: event.memberId } : {}),
298
+ ...(event.memberName ? { memberName: event.memberName } : {}),
299
+ });
300
+ }
171
301
  }
172
302
  else if (event.type === 'error') {
173
303
  flushStreaming();
@@ -179,11 +309,26 @@ export function useChatTurn(params) {
179
309
  // call renders above the tool line, not below it — then seal
180
310
  // that bubble: it's complete once the model starts calling tools.
181
311
  flushStreaming();
182
- finalizeStreamingAssistant(setMessages);
183
- appendToolStart(setMessages, event.toolName, event.toolCallId, event.args, event.ts);
312
+ if (useLiveModel) {
313
+ finalizeStreaming(setMessages, setLive);
314
+ startTool(setLive, event.toolName, event.toolCallId, event.args, event.ts);
315
+ }
316
+ else {
317
+ finalizeStreamingAssistant(setMessages);
318
+ appendToolStart(setMessages, event.toolName, event.toolCallId, event.args, event.ts);
319
+ }
320
+ // The pre-tool bubble is sealed: reset the display buffer so the
321
+ // next delta starts a fresh bubble instead of re-showing (and
322
+ // duplicating) the text already printed above the tool line.
323
+ streamContent = '';
184
324
  }
185
325
  else if (event.type === 'tool_execution_end') {
186
- updateToolMessageEnd(setMessages, event.toolCallId, event.isError, event.durationMs, event.result);
326
+ if (useLiveModel) {
327
+ completeTool(setMessages, setLive, event.toolCallId, event.isError, event.durationMs, event.result);
328
+ }
329
+ else {
330
+ updateToolMessageEnd(setMessages, event.toolCallId, event.isError, event.durationMs, event.result);
331
+ }
187
332
  }
188
333
  }
189
334
  }
@@ -191,7 +336,10 @@ export function useChatTurn(params) {
191
336
  // Drain any buffered streaming deltas so the final assistant message
192
337
  // is committed before busy flips to false (and the input re-enables).
193
338
  flushStreaming();
194
- finalizeStreamingAssistant(setMessages);
339
+ if (useLiveModel)
340
+ finalizeStreaming(setMessages, setLive);
341
+ else
342
+ finalizeStreamingAssistant(setMessages);
195
343
  harnessRef.current = null;
196
344
  setQueueCount(0);
197
345
  setBusy(false);
@@ -209,7 +357,19 @@ export function useChatTurn(params) {
209
357
  appendSystem(setMessages, `[dispatch error] ${err instanceof Error ? err.message : String(err)}`);
210
358
  setBusy(false);
211
359
  }
212
- }, [sessionId, writerRef, setMessages, commitStreaming, flushStreaming, setBusy, setSessionActive, setSessionStats]);
360
+ }, [
361
+ sessionId,
362
+ writerRef,
363
+ setMessages,
364
+ commitStreaming,
365
+ flushStreaming,
366
+ setBusy,
367
+ setSessionActive,
368
+ setSessionStats,
369
+ useLiveModel,
370
+ setLive,
371
+ liveRef,
372
+ ]);
213
373
  const dispatchCouncilPrompt = useCallback(async (text) => {
214
374
  await dispatchCouncilPromptImpl(text, {
215
375
  sessionId,
@@ -219,15 +379,33 @@ export function useChatTurn(params) {
219
379
  flushStreaming,
220
380
  setBusy,
221
381
  setQueueCount,
382
+ setLive,
383
+ liveRef,
222
384
  });
223
- }, [sessionId, writerRef, setMessages, commitStreaming, flushStreaming, setBusy, setQueueCount]);
224
- return { dispatchPrompt, dispatchCouncilPrompt, harnessRef, queueCount, setQueueCount };
385
+ }, [
386
+ sessionId,
387
+ writerRef,
388
+ setMessages,
389
+ commitStreaming,
390
+ flushStreaming,
391
+ setBusy,
392
+ setQueueCount,
393
+ setLive,
394
+ liveRef,
395
+ ]);
396
+ return {
397
+ dispatchPrompt,
398
+ dispatchCouncilPrompt,
399
+ harnessRef,
400
+ queueCount,
401
+ setQueueCount,
402
+ };
225
403
  }
226
404
  /**
227
405
  * dispatchCouncilPrompt — multi-agent council dispatch.
228
406
  *
229
- * Surfaces tool_execution_start/end as 'tool' role messages so ChatStream
230
- * renders CollapsibleToolOutput. Runs AGENTS.MD auto-maintenance after the
407
+ * Surfaces tool_execution_start/end as 'tool' role messages so the live
408
+ * region renders them. Runs AGENTS.MD auto-maintenance after the
231
409
  * council finishes (controlled by ZELARI_AGENTS_MD env var).
232
410
  *
233
411
  * Implementation lives outside the hook closure so it doesn't depend on the
@@ -235,20 +413,24 @@ export function useChatTurn(params) {
235
413
  * useChatTurn wrapper.
236
414
  */
237
415
  async function dispatchCouncilPromptImpl(text, deps) {
238
- const { sessionId, writerRef, setMessages, commitStreaming, flushStreaming, setBusy } = deps;
416
+ const { sessionId, writerRef, setMessages, commitStreaming, flushStreaming, setBusy, setLive, liveRef, } = deps;
417
+ const useLiveModel = !!(setLive && liveRef);
239
418
  const envConfig = await providerFromEnv();
240
419
  if (!envConfig) {
241
- appendSystem(setMessages, 'OPENAI_API_KEY not set. Export it before invoking /council.');
420
+ const active = resolveActiveProvider();
421
+ const spec = PROVIDERS.find((p) => p.id === active);
422
+ appendSystem(setMessages, `No API key for the active provider "${active}". Set ${spec?.envVar ?? "the provider API key env var"} or run /login ${active} before invoking /council.`);
242
423
  return;
243
424
  }
244
425
  setBusy(true);
245
426
  // Import dynamically to avoid a circular dep at module-load time.
246
- const { dispatchCouncil } = await import('../councilDispatcher.js');
247
- const { createWorkspaceContext, createWorkspaceStubs } = await import('../workspace/stubs.js');
248
- const { createWorkspaceToolRegistry } = await import('../workspace/toolRegistry.js');
249
- const { setWorkspaceStubs } = await import('@zelari/core/skills');
250
- const { runPostCouncilHook } = await import('../workspace/postCouncilHook.js');
251
- const { FeedbackStore } = await import('../councilFeedback.js');
427
+ const { dispatchCouncil } = await import("../councilDispatcher.js");
428
+ const { createWorkspaceContext, createWorkspaceStubs } = await import("../workspace/stubs.js");
429
+ const { createWorkspaceToolRegistry } = await import("../workspace/toolRegistry.js");
430
+ const { setWorkspaceStubs } = await import("@zelari/core/skills");
431
+ const { runPostCouncilHook } = await import("../workspace/postCouncilHook.js");
432
+ const { buildWorkspaceSummary, buildPlanSummary } = await import("../workspace/workspaceSummary.js");
433
+ const { FeedbackStore } = await import("../councilFeedback.js");
252
434
  const { registry: councilToolRegistry } = createBuiltinToolRegistry();
253
435
  const workspaceCtx = createWorkspaceContext();
254
436
  const workspaceReg = createWorkspaceToolRegistry(workspaceCtx);
@@ -257,71 +439,190 @@ async function dispatchCouncilPromptImpl(text, deps) {
257
439
  if (td)
258
440
  councilToolRegistry.register(td);
259
441
  }
442
+ // v0.7.5: MCP tools for the council too (same lazy singleton as the
443
+ // single-agent path — zero extra spawns).
444
+ try {
445
+ const { registerMcpTools } = await import('../mcp/mcpManager.js');
446
+ const mcp = await registerMcpTools(councilToolRegistry);
447
+ for (const w of mcp.warnings)
448
+ appendSystem(setMessages, w);
449
+ }
450
+ catch {
451
+ // Best-effort.
452
+ }
260
453
  setWorkspaceStubs(createWorkspaceStubs(workspaceCtx));
261
454
  const councilFeedbackStore = new FeedbackStore();
455
+ // v0.7.3: per-member display accumulator for the streaming bubble.
456
+ // The previous code accumulated by reading `liveRef.current.streaming` —
457
+ // but that ref only updates on render (useEffect) and the delta commits go
458
+ // through a 16ms throttle window, so every delta inside a window computed
459
+ // staleContent+delta and the LAST write won: most tokens were silently
460
+ // dropped (the mangled member text from the 2026-07-02 live test).
461
+ // The accumulator lives here, in the event loop, exactly like
462
+ // `streamContent` in dispatchPrompt.
463
+ let streamContent = "";
464
+ let streamMemberId = null;
465
+ // v0.7.3: council members legitimately need more than the core default of
466
+ // 5 tool calls per turn (a planner creating 8 tasks got 3 of them skipped
467
+ // with "[skipped] maxToolCallsPerTurn reached"). Same env override as the
468
+ // single-prompt path.
469
+ const councilMaxToolCalls = (() => {
470
+ const raw = process.env.ZELARI_MAX_TOOL_CALLS;
471
+ const n = raw ? Number.parseInt(raw, 10) : 15;
472
+ return Number.isFinite(n) && n > 0 ? n : 15;
473
+ })();
474
+ // v0.7.1 (A3): track member completion so the AGENTS.MD hook only runs when
475
+ // the council actually produced output. v0.7.1 (A4): track repeated provider
476
+ // errors to abort the remaining members instead of grinding through every
477
+ // specialist after the API is clearly broken.
478
+ let membersCompleted = 0;
479
+ let chairmanProducedOutput = false;
480
+ let consecutiveProviderErrors = 0;
481
+ let lastErrorMessage = "";
482
+ let councilAborted = false;
483
+ const PROVIDER_ERROR_ABORT_THRESHOLD = 2;
262
484
  try {
263
485
  for await (const event of dispatchCouncil(text, {
264
486
  apiKey: envConfig.apiKey,
265
487
  model: envConfig.model,
266
- provider: 'openai-compatible',
488
+ provider: "openai-compatible",
267
489
  providerStream: openaiCompatibleProvider(envConfig),
268
490
  sessionId,
269
491
  tools: councilToolRegistry,
270
492
  feedbackStore: councilFeedbackStore,
493
+ // v0.7.2 (B2): give the council the same project awareness the
494
+ // single-prompt path has — cwd, tech stack, file layout, build scripts.
495
+ // Without this, members had no idea which project they were operating
496
+ // on and projected their identity onto the task.
497
+ // v0.7.3: append the existing plan (if any) so a follow-up /council
498
+ // continues it instead of re-planning from scratch.
499
+ workspaceContext: [
500
+ buildWorkspaceSummary(process.cwd()),
501
+ buildPlanSummary(process.cwd()),
502
+ ]
503
+ .filter(Boolean)
504
+ .join("\n\n"),
505
+ maxToolCallsPerTurn: councilMaxToolCalls,
271
506
  })) {
507
+ if (councilAborted) {
508
+ // Drain remaining events silently after the abort decision.
509
+ if (writerRef.current)
510
+ await writerRef.current.append(event);
511
+ continue;
512
+ }
272
513
  if (writerRef.current) {
273
514
  await writerRef.current.append(event);
274
515
  }
275
- if (event.type === 'message_delta') {
516
+ if (event.type === "message_delta") {
276
517
  // Coalesce streaming assistant content through the throttled setter so
277
518
  // per-token deltas don't flicker the TUI (same as dispatchPrompt).
278
- // v0.5.0: forward the council member identity when present so
279
- // the UI can render `🜂 Caronte: …` headers above the streamed
280
- // text. Extend the trailing streaming bubble only when it belongs
281
- // to the SAME member otherwise one specialist's text would be
282
- // appended to (and attributed to) the previous one.
283
- commitStreaming((prev) => {
284
- const last = prev[prev.length - 1];
285
- if (last &&
286
- last.role === 'assistant' &&
287
- last.id.startsWith('streaming-') &&
288
- (last.memberId ?? null) === (event.memberId ?? null)) {
289
- return [...prev.slice(0, -1), { ...last, content: last.content + event.delta }];
519
+ if (useLiveModel) {
520
+ // v0.7.3: accumulate in the local `streamContent` (NOT via liveRef
521
+ // stale under the throttle, see the accumulator comment above) and
522
+ // always push the FULL content: dropped intermediate commits are
523
+ // then harmless because the last one supersedes them.
524
+ const memberId = event.memberId ?? null;
525
+ if (memberId !== streamMemberId) {
526
+ // Member boundary without a message_end (defensive): seal the
527
+ // previous member's bubble before starting the new one.
528
+ flushStreaming();
529
+ finalizeStreaming(setMessages, setLive);
530
+ streamContent = "";
531
+ streamMemberId = memberId;
290
532
  }
291
- return [
292
- ...prev,
293
- {
294
- id: `streaming-${crypto.randomUUID()}`,
295
- role: 'assistant',
296
- content: event.delta,
297
- ts: event.ts,
298
- ...(event.memberId ? { memberId: event.memberId } : {}),
299
- ...(event.memberName ? { memberName: event.memberName } : {}),
300
- },
301
- ];
302
- });
533
+ streamContent += event.delta;
534
+ setStreaming(commitStreaming, streamContent, event.ts, {
535
+ ...(event.memberId ? { memberId: event.memberId } : {}),
536
+ ...(event.memberName ? { memberName: event.memberName } : {}),
537
+ });
538
+ }
539
+ else {
540
+ // Legacy single-array fallback. Extend the trailing streaming bubble
541
+ // only when it belongs to the SAME member — otherwise one specialist's
542
+ // text would be appended to (and attributed to) the previous one.
543
+ commitStreaming((prev) => {
544
+ const last = prev[prev.length - 1];
545
+ if (last &&
546
+ last.role === "assistant" &&
547
+ last.id.startsWith("streaming-") &&
548
+ (last.memberId ?? null) === (event.memberId ?? null)) {
549
+ return [
550
+ ...prev.slice(0, -1),
551
+ { ...last, content: last.content + event.delta },
552
+ ];
553
+ }
554
+ return [
555
+ ...prev,
556
+ {
557
+ id: `streaming-${crypto.randomUUID()}`,
558
+ role: "assistant",
559
+ content: event.delta,
560
+ ts: event.ts,
561
+ ...(event.memberId ? { memberId: event.memberId } : {}),
562
+ ...(event.memberName ? { memberName: event.memberName } : {}),
563
+ },
564
+ ];
565
+ });
566
+ }
303
567
  }
304
- else if (event.type === 'message_end') {
568
+ else if (event.type === "message_end") {
305
569
  // Member/turn boundary: drain buffered deltas and seal the bubble so
306
570
  // the next streamed message starts fresh.
307
571
  flushStreaming();
308
- finalizeStreamingAssistant(setMessages);
572
+ if (useLiveModel)
573
+ finalizeStreaming(setMessages, setLive);
574
+ else
575
+ finalizeStreamingAssistant(setMessages);
576
+ streamContent = "";
577
+ streamMemberId = null;
578
+ membersCompleted++;
579
+ // Chairman is the last member; any assistant content from it counts.
580
+ if (event.memberId === "lucifer" || event.memberName === "Lucifero") {
581
+ chairmanProducedOutput = true;
582
+ }
309
583
  }
310
- else if (event.type === 'tool_execution_start') {
311
- // Single 'tool' role message per invocation (CollapsibleToolOutput);
312
- // the previous extra `▶ name(args)` system line duplicated it.
584
+ else if (event.type === "tool_execution_start") {
313
585
  // Drain buffered deltas first so ordering matches reality, and seal
314
586
  // the pre-tool bubble (complete once the member starts calling tools).
315
587
  flushStreaming();
316
- finalizeStreamingAssistant(setMessages);
317
- appendToolStart(setMessages, event.toolName, event.toolCallId, event.args, event.ts);
588
+ if (useLiveModel) {
589
+ finalizeStreaming(setMessages, setLive);
590
+ startTool(setLive, event.toolName, event.toolCallId, event.args, event.ts);
591
+ }
592
+ else {
593
+ finalizeStreamingAssistant(setMessages);
594
+ appendToolStart(setMessages, event.toolName, event.toolCallId, event.args, event.ts);
595
+ }
596
+ // The pre-tool bubble is sealed: the next delta starts a fresh one.
597
+ streamContent = "";
318
598
  }
319
- else if (event.type === 'tool_execution_end') {
320
- updateToolMessageEnd(setMessages, event.toolCallId, event.isError, event.durationMs, event.result);
599
+ else if (event.type === "tool_execution_end") {
600
+ if (useLiveModel) {
601
+ completeTool(setMessages, setLive, event.toolCallId, event.isError, event.durationMs, event.result);
602
+ }
603
+ else {
604
+ updateToolMessageEnd(setMessages, event.toolCallId, event.isError, event.durationMs, event.result);
605
+ }
321
606
  }
322
- else if (event.type === 'error') {
607
+ else if (event.type === "error") {
323
608
  flushStreaming();
324
- appendSystem(setMessages, `[error] ${event.message}`, event.ts);
609
+ // v0.7.1 (A4): attribute the error to the member when known, so the
610
+ // user sees `[error · Caronte] …` instead of three anonymous lines.
611
+ const memberTag = event.memberName ? ` · ${event.memberName}` : "";
612
+ appendSystem(setMessages, `[error${memberTag}] ${event.message}`, event.ts);
613
+ // v0.7.1 (A4): detect repeated identical provider errors and abort the
614
+ // remaining members instead of grinding through every specialist.
615
+ if (event.message === lastErrorMessage) {
616
+ consecutiveProviderErrors++;
617
+ }
618
+ else {
619
+ consecutiveProviderErrors = 1;
620
+ lastErrorMessage = event.message;
621
+ }
622
+ if (consecutiveProviderErrors >= PROVIDER_ERROR_ABORT_THRESHOLD) {
623
+ councilAborted = true;
624
+ appendSystem(setMessages, `[council aborted: repeated provider error — ${consecutiveProviderErrors}× "${event.message.slice(0, 80)}"]`, Date.now());
625
+ }
325
626
  }
326
627
  }
327
628
  }
@@ -334,20 +635,32 @@ async function dispatchCouncilPromptImpl(text, deps) {
334
635
  // Drain any buffered streaming deltas before status messages / busy flip,
335
636
  // so the final council output is committed to the chat.
336
637
  flushStreaming();
337
- finalizeStreamingAssistant(setMessages);
338
- try {
339
- const hook = await runPostCouncilHook(workspaceCtx);
340
- if (hook.ran && hook.changed) {
341
- appendSystem(setMessages, `[agents.md] updated: ${hook.sections.length} section(s) changed (${hook.sections.join(', ')})`, Date.now());
342
- }
343
- else if (hook.ran && hook.reason) {
344
- if (!hook.reason.includes('disabled')) {
345
- appendSystem(setMessages, `[agents.md] ${hook.reason}`, Date.now());
638
+ if (useLiveModel)
639
+ finalizeStreaming(setMessages, setLive);
640
+ else
641
+ finalizeStreamingAssistant(setMessages);
642
+ // v0.7.1 (A3): only auto-write AGENTS.MD when the council actually produced
643
+ // output. Running the hook after an all-error run (e.g. the HTTP 400 from
644
+ // A1) dirtied the working tree with sections rewritten from nothing.
645
+ const hookShouldRun = membersCompleted > 0 || chairmanProducedOutput;
646
+ if (hookShouldRun) {
647
+ try {
648
+ const hook = await runPostCouncilHook(workspaceCtx);
649
+ if (hook.ran && hook.changed) {
650
+ appendSystem(setMessages, `[agents.md] updated: ${hook.sections.length} section(s) changed (${hook.sections.join(", ")})`, Date.now());
651
+ }
652
+ else if (hook.ran && hook.reason) {
653
+ if (!hook.reason.includes("disabled")) {
654
+ appendSystem(setMessages, `[agents.md] ${hook.reason}`, Date.now());
655
+ }
346
656
  }
347
657
  }
658
+ catch {
659
+ // Best-effort — never block on AGENTS.MD errors.
660
+ }
348
661
  }
349
- catch {
350
- // Best-effortnever block on AGENTS.MD errors.
662
+ else if (!councilAborted) {
663
+ appendSystem(setMessages, "[agents.md] skipped council produced no output", Date.now());
351
664
  }
352
665
  setBusy(false);
353
666
  }