zelari-code 0.4.3 → 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 (127) hide show
  1. package/README.md +42 -0
  2. package/bin/zelari-code.js +1 -1
  3. package/dist/agents/councilApi.js +7 -7
  4. package/dist/agents/councilApi.js.map +1 -1
  5. package/dist/agents/councilDirectives.js +3 -3
  6. package/dist/agents/promoteMember.js +2 -2
  7. package/dist/agents/roles.js +50 -32
  8. package/dist/agents/roles.js.map +1 -1
  9. package/dist/agents/skills/builtin/debugging.js +4 -4
  10. package/dist/agents/skills/builtin/debugging.js.map +1 -1
  11. package/dist/agents/skills/builtin/docs.js +23 -13
  12. package/dist/agents/skills/builtin/docs.js.map +1 -1
  13. package/dist/agents/skills/builtin/git-ops.js +3 -3
  14. package/dist/agents/skills/builtin/git-ops.js.map +1 -1
  15. package/dist/agents/skills/builtin/planning.js +4 -4
  16. package/dist/agents/skills/builtin/planning.js.map +1 -1
  17. package/dist/agents/skills/builtin/refactoring.js +3 -3
  18. package/dist/agents/skills/builtin/refactoring.js.map +1 -1
  19. package/dist/agents/skills/builtin/review.js +9 -9
  20. package/dist/agents/skills/builtin/review.js.map +1 -1
  21. package/dist/agents/skills/builtin/testing.js +3 -3
  22. package/dist/agents/skills/builtin/testing.js.map +1 -1
  23. package/dist/agents/tools.js +2 -9
  24. package/dist/agents/tools.js.map +1 -1
  25. package/dist/cli/app.js +72 -33
  26. package/dist/cli/app.js.map +1 -1
  27. package/dist/cli/compaction.js.map +1 -1
  28. package/dist/cli/components/ChatStream.js +43 -82
  29. package/dist/cli/components/ChatStream.js.map +1 -1
  30. package/dist/cli/components/CollapsibleToolOutput.js +8 -21
  31. package/dist/cli/components/CollapsibleToolOutput.js.map +1 -1
  32. package/dist/cli/components/Header.js +6 -18
  33. package/dist/cli/components/Header.js.map +1 -1
  34. package/dist/cli/components/LiveRegion.js +54 -0
  35. package/dist/cli/components/LiveRegion.js.map +1 -0
  36. package/dist/cli/components/Sidebar.js +33 -31
  37. package/dist/cli/components/Sidebar.js.map +1 -1
  38. package/dist/cli/components/StatusBar.js +42 -0
  39. package/dist/cli/components/StatusBar.js.map +1 -0
  40. package/dist/cli/components/ToolOutput.js +68 -0
  41. package/dist/cli/components/ToolOutput.js.map +1 -0
  42. package/dist/cli/components/toolFormat.js +195 -0
  43. package/dist/cli/components/toolFormat.js.map +1 -0
  44. package/dist/cli/councilDispatcher.js +1 -1
  45. package/dist/cli/councilDispatcher.js.map +1 -1
  46. package/dist/cli/grokOAuth.js +93 -75
  47. package/dist/cli/grokOAuth.js.map +1 -1
  48. package/dist/cli/headless.js +137 -0
  49. package/dist/cli/headless.js.map +1 -0
  50. package/dist/cli/hooks/chatState.js +139 -0
  51. package/dist/cli/hooks/chatState.js.map +1 -0
  52. package/dist/cli/hooks/eventsToMessages.js +42 -11
  53. package/dist/cli/hooks/eventsToMessages.js.map +1 -1
  54. package/dist/cli/hooks/messageHelpers.js +72 -15
  55. package/dist/cli/hooks/messageHelpers.js.map +1 -1
  56. package/dist/cli/hooks/useBatchedMessages.js +71 -0
  57. package/dist/cli/hooks/useBatchedMessages.js.map +1 -0
  58. package/dist/cli/hooks/useChatTurn.js +453 -84
  59. package/dist/cli/hooks/useChatTurn.js.map +1 -1
  60. package/dist/cli/hooks/useSession.js +24 -3
  61. package/dist/cli/hooks/useSession.js.map +1 -1
  62. package/dist/cli/hooks/useSlashDispatch.js +13 -2
  63. package/dist/cli/hooks/useSlashDispatch.js.map +1 -1
  64. package/dist/cli/main.bundled.js +10607 -7889
  65. package/dist/cli/main.bundled.js.map +4 -4
  66. package/dist/cli/main.js +120 -2
  67. package/dist/cli/main.js.map +1 -1
  68. package/dist/cli/mcp/mcpClient.js +152 -0
  69. package/dist/cli/mcp/mcpClient.js.map +1 -0
  70. package/dist/cli/mcp/mcpManager.js +139 -0
  71. package/dist/cli/mcp/mcpManager.js.map +1 -0
  72. package/dist/cli/provider/openai-compatible.js +9 -2
  73. package/dist/cli/provider/openai-compatible.js.map +1 -1
  74. package/dist/cli/refreshRegistry.js +9 -6
  75. package/dist/cli/refreshRegistry.js.map +1 -1
  76. package/dist/cli/runHeadless.js +160 -0
  77. package/dist/cli/runHeadless.js.map +1 -0
  78. package/dist/cli/sessionManager.js +1 -1
  79. package/dist/cli/sessionManager.js.map +1 -1
  80. package/dist/cli/skillSuggest.js +1 -1
  81. package/dist/cli/skillsMd.js +155 -0
  82. package/dist/cli/skillsMd.js.map +1 -0
  83. package/dist/cli/slashHandlers/branch.js +11 -2
  84. package/dist/cli/slashHandlers/branch.js.map +1 -1
  85. package/dist/cli/slashHandlers/git.js +0 -75
  86. package/dist/cli/slashHandlers/git.js.map +1 -1
  87. package/dist/cli/slashHandlers/promoteMember.js +25 -0
  88. package/dist/cli/slashHandlers/promoteMember.js.map +1 -0
  89. package/dist/cli/slashHandlers/skills.js +16 -0
  90. package/dist/cli/slashHandlers/skills.js.map +1 -1
  91. package/dist/cli/slashHandlers/transcript.js +13 -0
  92. package/dist/cli/slashHandlers/transcript.js.map +1 -0
  93. package/dist/cli/slashHandlers/updater.js +40 -0
  94. package/dist/cli/slashHandlers/updater.js.map +1 -0
  95. package/dist/cli/toolRegistry.js +37 -6
  96. package/dist/cli/toolRegistry.js.map +1 -1
  97. package/dist/cli/wizard/firstRun.js +63 -0
  98. package/dist/cli/wizard/firstRun.js.map +1 -0
  99. package/dist/cli/wizard/index.js +150 -0
  100. package/dist/cli/wizard/index.js.map +1 -0
  101. package/dist/cli/wizard/runWizard.js +137 -0
  102. package/dist/cli/wizard/runWizard.js.map +1 -0
  103. package/dist/cli/wizard/useWizardState.js +151 -0
  104. package/dist/cli/wizard/useWizardState.js.map +1 -0
  105. package/dist/cli/workspace/paths.js +20 -13
  106. package/dist/cli/workspace/paths.js.map +1 -1
  107. package/dist/cli/workspace/storage.js +7 -3
  108. package/dist/cli/workspace/storage.js.map +1 -1
  109. package/dist/cli/workspace/stubs.js +122 -44
  110. package/dist/cli/workspace/stubs.js.map +1 -1
  111. package/dist/cli/workspace/toolRegistry.js +2 -2
  112. package/dist/cli/workspace/toolRegistry.js.map +1 -1
  113. package/dist/cli/workspace/workspaceSummary.js +255 -0
  114. package/dist/cli/workspace/workspaceSummary.js.map +1 -0
  115. package/dist/main/core/AgentHarness.js +36 -7
  116. package/dist/main/core/AgentHarness.js.map +1 -1
  117. package/dist/main/core/tools/builtin/listFiles.js +43 -5
  118. package/dist/main/core/tools/builtin/listFiles.js.map +1 -1
  119. package/dist/main/core/tools/builtin/search.js +28 -120
  120. package/dist/main/core/tools/builtin/search.js.map +1 -1
  121. package/package.json +6 -1
  122. package/dist/cli/oauthCallbackServer.js +0 -207
  123. package/dist/cli/oauthCallbackServer.js.map +0 -1
  124. package/dist/main/core/tools/builtin/_walk.js +0 -129
  125. package/dist/main/core/tools/builtin/_walk.js.map +0 -1
  126. package/dist/main/core/tools/builtin/diff.js +0 -477
  127. 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 '../../main/core/AgentHarness.js';
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, appendToolEnd, appendToolStart, 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, 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,
@@ -43,7 +54,9 @@ export function useChatTurn(params) {
43
54
  buildStream: (config) => openaiCompatibleProvider(config),
44
55
  });
45
56
  if (failoverResolution.warning) {
46
- console.warn(failoverResolution.warning);
57
+ // Surface in the chat instead of console.warn: writes that bypass
58
+ // Ink force a full repaint of the TUI frame (visible flicker).
59
+ appendSystem(setMessages, `[failover] ${failoverResolution.warning}`);
47
60
  }
48
61
  const providerStream = failoverResolution.fallbackLabel
49
62
  ? providerFailover({
@@ -56,13 +69,101 @@ export function useChatTurn(params) {
56
69
  fallback: failoverResolution.fallback,
57
70
  });
58
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.
59
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`.';
60
155
  const systemPrompt = [
61
156
  "You are Zelari Code, an interactive AI coding agent operating directly in the user's terminal.",
62
157
  '',
63
158
  'You ARE connected to this machine and have real tools to read, modify, and explore the codebase.',
64
159
  "Never claim you lack filesystem or shell access — you have it. Use your tools instead of asking the user to paste file contents.",
65
160
  '',
161
+ '# Platform & Shell',
162
+ `platform: ${process.platform}`,
163
+ `shell: ${resolvedShell.via}`,
164
+ shellGuidance,
165
+ nonInteractiveGuidance,
166
+ '',
66
167
  '# Working Directory',
67
168
  `You are running in: ${cwd}`,
68
169
  'All relative file paths are resolved against this directory. Always work with real files here.',
@@ -70,12 +171,32 @@ export function useChatTurn(params) {
70
171
  '# Available Tools',
71
172
  'You can call these tools. Use them to take action and gather information autonomously:',
72
173
  toolList,
174
+ ...(workspaceSummary ? ['', workspaceSummary] : []),
175
+ ...(zelariReadHint ? ['', zelariReadHint] : []),
176
+ ...(planSummary ? ['', planSummary] : []),
73
177
  '',
74
178
  '# Guidelines',
75
- '- 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.',
76
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.",
77
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
+ : []),
78
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
+ })();
79
200
  const harness = new AgentHarness({
80
201
  model: envConfig.model,
81
202
  provider: 'openai-compatible',
@@ -91,16 +212,36 @@ export function useChatTurn(params) {
91
212
  toolRegistry,
92
213
  providerStream,
93
214
  cwd,
215
+ maxToolCallsPerTurn,
94
216
  });
95
217
  harnessRef.current = harness;
96
218
  setQueueCount(harness.queueLength);
219
+ // Total assistant output across the whole turn — feeds the token/cost
220
+ // estimate fallback in computeSessionStatsDelta.
97
221
  let assistantContent = '';
222
+ // Display buffer for the CURRENT streamed message only. Reset on every
223
+ // message_end: without this, the post-tool-call message re-rendered the
224
+ // full accumulated turn text, duplicating everything said before the
225
+ // tool ran.
226
+ let streamContent = '';
227
+ // tool_execution_end doesn't carry toolName — remember it from the
228
+ // matching start event (keyed by toolCallId) for metrics.
229
+ const toolNameById = new Map();
98
230
  const metrics = getMetricsLogger();
99
231
  let realUsage = null;
100
232
  try {
101
233
  for await (const event of harness.run()) {
102
- if (event.type === 'message_end' && event.usage) {
103
- realUsage = event.usage;
234
+ if (event.type === 'message_end') {
235
+ if (event.usage)
236
+ realUsage = event.usage;
237
+ // Message boundary: drain buffered deltas, then seal the streamed
238
+ // bubble so the next message starts fresh instead of merging.
239
+ flushStreaming();
240
+ if (useLiveModel)
241
+ finalizeStreaming(setMessages, setLive);
242
+ else
243
+ finalizeStreamingAssistant(setMessages);
244
+ streamContent = '';
104
245
  }
105
246
  if (event.type === 'queue_update') {
106
247
  setQueueCount(harness.queueLength);
@@ -133,7 +274,7 @@ export function useChatTurn(params) {
133
274
  sessionId,
134
275
  provider: envConfig.providerId,
135
276
  model: envConfig.model,
136
- toolName: event.toolName,
277
+ toolName: toolNameById.get(event.toolCallId) ?? 'unknown',
137
278
  toolCallId: event.toolCallId,
138
279
  durationMs: event.durationMs,
139
280
  ok: !event.isError,
@@ -141,26 +282,64 @@ export function useChatTurn(params) {
141
282
  }
142
283
  if (event.type === 'message_delta') {
143
284
  assistantContent += event.delta;
144
- appendOrExtendStreamingAssistant(setMessages, assistantContent, Date.now());
285
+ streamContent += event.delta;
286
+ // Route through the throttled setter so per-token deltas (50-200/sec)
287
+ // coalesce into ≤60 renders/sec instead of flickering the TUI.
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
+ }
145
301
  }
146
302
  else if (event.type === 'error') {
303
+ flushStreaming();
147
304
  appendSystem(setMessages, `[error] ${event.message}`, Date.now());
148
305
  }
149
- else if (event.type === 'tool_call') {
150
- appendSystem(setMessages, `[tool_call] ${event.toolName}(${JSON.stringify(event.arguments).slice(0, 80)})`, event.ts);
151
- }
152
- else if (event.type === 'tool_result') {
153
- appendSystem(setMessages, `[tool_result] ${event.toolName} → ${event.ok ? 'ok' : 'error'}`, event.ts);
154
- }
155
306
  else if (event.type === 'tool_execution_start') {
156
- appendToolStart(setMessages, event.toolName, event.args, event.ts);
307
+ toolNameById.set(event.toolCallId, event.toolName);
308
+ // Drain buffered deltas FIRST so the text streamed before the
309
+ // call renders above the tool line, not below it — then seal
310
+ // that bubble: it's complete once the model starts calling tools.
311
+ flushStreaming();
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 = '';
157
324
  }
158
325
  else if (event.type === 'tool_execution_end') {
159
- appendToolEnd(setMessages, event.result, event.isError, event.durationMs, event.ts);
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
+ }
160
332
  }
161
333
  }
162
334
  }
163
335
  finally {
336
+ // Drain any buffered streaming deltas so the final assistant message
337
+ // is committed before busy flips to false (and the input re-enables).
338
+ flushStreaming();
339
+ if (useLiveModel)
340
+ finalizeStreaming(setMessages, setLive);
341
+ else
342
+ finalizeStreamingAssistant(setMessages);
164
343
  harnessRef.current = null;
165
344
  setQueueCount(0);
166
345
  setBusy(false);
@@ -172,26 +351,61 @@ export function useChatTurn(params) {
172
351
  // resolveFailoverStream / AgentHarness construction that were
173
352
  // previously escaping the function unhandled. Surfaces the error
174
353
  // to the chat instead of hanging silently.
354
+ // Flush first so any partial assistant content streamed before the
355
+ // throw is committed before the error message renders.
356
+ flushStreaming();
175
357
  appendSystem(setMessages, `[dispatch error] ${err instanceof Error ? err.message : String(err)}`);
176
358
  setBusy(false);
177
359
  }
178
- }, [sessionId, writerRef, setMessages, 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
+ ]);
179
373
  const dispatchCouncilPrompt = useCallback(async (text) => {
180
374
  await dispatchCouncilPromptImpl(text, {
181
375
  sessionId,
182
376
  writerRef,
183
377
  setMessages,
378
+ commitStreaming,
379
+ flushStreaming,
184
380
  setBusy,
185
381
  setQueueCount,
382
+ setLive,
383
+ liveRef,
186
384
  });
187
- }, [sessionId, writerRef, setMessages, setBusy, setQueueCount]);
188
- 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
+ };
189
403
  }
190
404
  /**
191
405
  * dispatchCouncilPrompt — multi-agent council dispatch.
192
406
  *
193
- * Surfaces tool_execution_start/end as 'tool' role messages so ChatStream
194
- * 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
195
409
  * council finishes (controlled by ZELARI_AGENTS_MD env var).
196
410
  *
197
411
  * Implementation lives outside the hook closure so it doesn't depend on the
@@ -199,20 +413,24 @@ export function useChatTurn(params) {
199
413
  * useChatTurn wrapper.
200
414
  */
201
415
  async function dispatchCouncilPromptImpl(text, deps) {
202
- const { sessionId, writerRef, setMessages, setBusy } = deps;
416
+ const { sessionId, writerRef, setMessages, commitStreaming, flushStreaming, setBusy, setLive, liveRef, } = deps;
417
+ const useLiveModel = !!(setLive && liveRef);
203
418
  const envConfig = await providerFromEnv();
204
419
  if (!envConfig) {
205
- 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.`);
206
423
  return;
207
424
  }
208
425
  setBusy(true);
209
426
  // Import dynamically to avoid a circular dep at module-load time.
210
- const { dispatchCouncil } = await import('../councilDispatcher.js');
211
- const { createWorkspaceContext, createWorkspaceStubs } = await import('../workspace/stubs.js');
212
- const { createWorkspaceToolRegistry } = await import('../workspace/toolRegistry.js');
213
- const { setWorkspaceStubs } = await import('../../agents/tools.js');
214
- const { runPostCouncilHook } = await import('../workspace/postCouncilHook.js');
215
- 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");
216
434
  const { registry: councilToolRegistry } = createBuiltinToolRegistry();
217
435
  const workspaceCtx = createWorkspaceContext();
218
436
  const workspaceReg = createWorkspaceToolRegistry(workspaceCtx);
@@ -221,77 +439,228 @@ async function dispatchCouncilPromptImpl(text, deps) {
221
439
  if (td)
222
440
  councilToolRegistry.register(td);
223
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
+ }
224
453
  setWorkspaceStubs(createWorkspaceStubs(workspaceCtx));
225
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;
226
484
  try {
227
485
  for await (const event of dispatchCouncil(text, {
228
486
  apiKey: envConfig.apiKey,
229
487
  model: envConfig.model,
230
- provider: 'openai-compatible',
488
+ provider: "openai-compatible",
231
489
  providerStream: openaiCompatibleProvider(envConfig),
232
490
  sessionId,
233
491
  tools: councilToolRegistry,
234
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,
235
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
+ }
236
513
  if (writerRef.current) {
237
514
  await writerRef.current.append(event);
238
515
  }
239
- if (event.type === 'message_delta') {
240
- // Coalesce streaming assistant content
241
- setMessages((prev) => {
242
- const last = prev[prev.length - 1];
243
- if (last && last.role === 'assistant' && last.id.startsWith('streaming-')) {
244
- return [...prev.slice(0, -1), { ...last, content: last.content + event.delta }];
516
+ if (event.type === "message_delta") {
517
+ // Coalesce streaming assistant content through the throttled setter so
518
+ // per-token deltas don't flicker the TUI (same as dispatchPrompt).
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;
245
532
  }
246
- return [
247
- ...prev,
248
- { id: `streaming-${crypto.randomUUID()}`, role: 'assistant', content: event.delta, ts: event.ts },
249
- ];
250
- });
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
+ }
251
567
  }
252
- else if (event.type === 'tool_execution_start') {
253
- const argsPreview = JSON.stringify(event.args).slice(0, 120);
254
- appendSystem(setMessages, `▶ ${event.toolName}(${argsPreview})`, event.ts);
255
- // Also surface as 'tool' role for CollapsibleToolOutput rendering.
256
- setMessages((prev) => [
257
- ...prev,
258
- {
259
- id: crypto.randomUUID(),
260
- role: 'tool',
261
- content: `${event.toolName}(${argsPreview})`,
262
- ts: event.ts,
263
- toolName: event.toolName,
264
- toolCallId: event.toolCallId,
265
- toolOk: undefined,
266
- toolDurationMs: undefined,
267
- },
268
- ]);
568
+ else if (event.type === "message_end") {
569
+ // Member/turn boundary: drain buffered deltas and seal the bubble so
570
+ // the next streamed message starts fresh.
571
+ flushStreaming();
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
+ }
269
583
  }
270
- else if (event.type === 'tool_execution_end') {
271
- updateToolMessageEnd(setMessages, event.toolCallId, event.isError, event.durationMs);
584
+ else if (event.type === "tool_execution_start") {
585
+ // Drain buffered deltas first so ordering matches reality, and seal
586
+ // the pre-tool bubble (complete once the member starts calling tools).
587
+ flushStreaming();
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 = "";
272
598
  }
273
- else if (event.type === 'error') {
274
- appendSystem(setMessages, `[error] ${event.message}`, event.ts);
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
+ }
606
+ }
607
+ else if (event.type === "error") {
608
+ flushStreaming();
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
+ }
275
626
  }
276
627
  }
277
628
  }
278
629
  catch (err) {
630
+ // Flush any partial streamed content before the error message renders.
631
+ flushStreaming();
279
632
  appendSystem(setMessages, `[council error] ${err instanceof Error ? err.message : String(err)}`, Date.now());
280
633
  }
281
634
  finally {
282
- try {
283
- const hook = await runPostCouncilHook(workspaceCtx);
284
- if (hook.ran && hook.changed) {
285
- appendSystem(setMessages, `[agents.md] updated: ${hook.sections.length} section(s) changed (${hook.sections.join(', ')})`, Date.now());
286
- }
287
- else if (hook.ran && hook.reason) {
288
- if (!hook.reason.includes('disabled')) {
289
- appendSystem(setMessages, `[agents.md] ${hook.reason}`, Date.now());
635
+ // Drain any buffered streaming deltas before status messages / busy flip,
636
+ // so the final council output is committed to the chat.
637
+ flushStreaming();
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
+ }
290
656
  }
291
657
  }
658
+ catch {
659
+ // Best-effort — never block on AGENTS.MD errors.
660
+ }
292
661
  }
293
- catch {
294
- // Best-effortnever block on AGENTS.MD errors.
662
+ else if (!councilAborted) {
663
+ appendSystem(setMessages, "[agents.md] skipped council produced no output", Date.now());
295
664
  }
296
665
  setBusy(false);
297
666
  }