lazyclaw 6.3.1 → 6.4.0

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 (165) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +6 -1
  10. package/commands/auth_nodes.mjs +19 -1
  11. package/commands/automation.mjs +20 -5
  12. package/commands/channels.mjs +46 -3
  13. package/commands/chat.mjs +90 -33
  14. package/commands/chat_hardening.mjs +23 -0
  15. package/commands/daemon.mjs +99 -1
  16. package/commands/gateway.mjs +123 -4
  17. package/commands/mcp.mjs +150 -0
  18. package/commands/misc.mjs +20 -0
  19. package/commands/sessions.mjs +68 -8
  20. package/commands/setup.mjs +44 -8
  21. package/commands/setup_channels.mjs +219 -47
  22. package/commands/skills.mjs +19 -1
  23. package/commands/workflow_named.mjs +137 -0
  24. package/config-validate.mjs +10 -1
  25. package/config_features.mjs +45 -0
  26. package/cron.mjs +19 -8
  27. package/daemon/lib/auth.mjs +25 -0
  28. package/daemon/lib/cost.mjs +36 -0
  29. package/daemon/lib/respond.mjs +20 -1
  30. package/daemon/lib/team_inbound.mjs +69 -0
  31. package/daemon/route_table.mjs +5 -1
  32. package/daemon/routes/_deps.mjs +2 -2
  33. package/daemon/routes/config.mjs +11 -6
  34. package/daemon/routes/conversation.mjs +103 -40
  35. package/daemon/routes/events.mjs +45 -0
  36. package/daemon/routes/meta.mjs +38 -7
  37. package/daemon/routes/providers.mjs +26 -6
  38. package/daemon/routes/workflows.mjs +30 -0
  39. package/daemon.mjs +26 -2
  40. package/dotenv_min.mjs +15 -3
  41. package/gateway/challenge_registry.mjs +90 -0
  42. package/gateway/device_auth.mjs +71 -97
  43. package/gateway/http_gateway.mjs +29 -3
  44. package/lib/args.mjs +69 -3
  45. package/lib/config.mjs +85 -3
  46. package/lib/render.mjs +43 -0
  47. package/lib/service_install.mjs +15 -1
  48. package/mas/agent_turn.mjs +61 -12
  49. package/mas/confidence.mjs +20 -1
  50. package/mas/embedder.mjs +100 -0
  51. package/mas/events.mjs +57 -0
  52. package/mas/index_db.mjs +103 -10
  53. package/mas/learning.mjs +96 -72
  54. package/mas/mention_router.mjs +48 -90
  55. package/mas/prompt_stack.mjs +60 -3
  56. package/mas/recall_blend.mjs +56 -0
  57. package/mas/redact.mjs +55 -0
  58. package/mas/router_posting.mjs +96 -0
  59. package/mas/router_termination.mjs +63 -0
  60. package/mas/scrub_env.mjs +21 -6
  61. package/mas/skill_synth.mjs +7 -33
  62. package/mas/tool_runner.mjs +3 -2
  63. package/mas/tools/bash.mjs +9 -2
  64. package/mas/tools/coding.mjs +28 -5
  65. package/mas/tools/delegation.mjs +34 -4
  66. package/mas/tools/git.mjs +19 -9
  67. package/mas/tools/ha.mjs +2 -0
  68. package/mas/tools/learning.mjs +55 -0
  69. package/mas/tools/media.mjs +21 -3
  70. package/mas/tools/os.mjs +70 -16
  71. package/mas/tools/recall.mjs +28 -2
  72. package/mcp/client.mjs +8 -1
  73. package/mcp/server_spawn.mjs +5 -1
  74. package/memory.mjs +24 -0
  75. package/package.json +3 -1
  76. package/providers/anthropic.mjs +101 -6
  77. package/providers/cache.mjs +9 -1
  78. package/providers/claude_cli.mjs +104 -22
  79. package/providers/claude_cli_session.mjs +166 -0
  80. package/providers/cli_error.mjs +38 -0
  81. package/providers/cli_login.mjs +179 -0
  82. package/providers/codex_cli.mjs +66 -11
  83. package/providers/gemini.mjs +101 -3
  84. package/providers/gemini_cli.mjs +62 -15
  85. package/providers/model_catalogue.mjs +33 -2
  86. package/providers/ollama.mjs +104 -3
  87. package/providers/openai.mjs +110 -8
  88. package/providers/openai_compat.mjs +97 -6
  89. package/providers/orchestrator.mjs +112 -12
  90. package/providers/registry.mjs +15 -9
  91. package/providers/retry.mjs +14 -2
  92. package/providers/tool_use/anthropic.mjs +17 -3
  93. package/providers/tool_use/claude_cli.mjs +72 -20
  94. package/providers/tool_use/gemini.mjs +18 -2
  95. package/providers/tool_use/openai.mjs +28 -5
  96. package/sandbox/confiners/seatbelt.mjs +37 -12
  97. package/sandbox/index.mjs +49 -0
  98. package/sandbox/local.mjs +7 -1
  99. package/sandbox/spawn.mjs +144 -0
  100. package/sandbox.mjs +5 -1
  101. package/scripts/loop-worker.mjs +25 -1
  102. package/sessions.mjs +0 -0
  103. package/skills/channel-style.md +20 -0
  104. package/skills/code-review.md +33 -0
  105. package/skills/commit-message.md +30 -0
  106. package/skills/concise.md +24 -0
  107. package/skills/debug-coach.md +25 -0
  108. package/skills/explain.md +24 -0
  109. package/skills/korean.md +25 -0
  110. package/skills/summarize.md +33 -0
  111. package/skills.mjs +24 -2
  112. package/skills_curator.mjs +6 -0
  113. package/skills_install.mjs +10 -2
  114. package/tasks.mjs +6 -1
  115. package/teams.mjs +78 -0
  116. package/tui/chat_mode_slash.mjs +59 -0
  117. package/tui/config_picker.mjs +1 -0
  118. package/tui/editor.mjs +178 -151
  119. package/tui/editor_keys.mjs +275 -0
  120. package/tui/hud.mjs +111 -0
  121. package/tui/login_flow.mjs +113 -0
  122. package/tui/modal_picker.mjs +10 -1
  123. package/tui/model_pick.mjs +287 -0
  124. package/tui/orchestrator_flow.mjs +164 -0
  125. package/tui/pickers.mjs +196 -80
  126. package/tui/repl.mjs +106 -44
  127. package/tui/repl_reset.mjs +37 -0
  128. package/tui/run_turn.mjs +228 -26
  129. package/tui/slash_args.mjs +159 -0
  130. package/tui/slash_commands.mjs +7 -5
  131. package/tui/slash_dispatcher.mjs +537 -344
  132. package/tui/slash_popup.mjs +5 -1
  133. package/tui/splash.mjs +15 -2
  134. package/tui/status_bar.mjs +26 -0
  135. package/tui/theme.mjs +28 -0
  136. package/web/avatars/01.png +0 -0
  137. package/web/avatars/02.png +0 -0
  138. package/web/avatars/03.png +0 -0
  139. package/web/avatars/04.png +0 -0
  140. package/web/avatars/05.png +0 -0
  141. package/web/avatars/06.png +0 -0
  142. package/web/avatars/07.png +0 -0
  143. package/web/avatars/08.png +0 -0
  144. package/web/avatars/09.png +0 -0
  145. package/web/avatars/10.png +0 -0
  146. package/web/avatars/11.png +0 -0
  147. package/web/avatars/12.png +0 -0
  148. package/web/avatars/13.png +0 -0
  149. package/web/avatars/14.png +0 -0
  150. package/web/avatars/15.png +0 -0
  151. package/web/avatars/16.png +0 -0
  152. package/web/avatars/17.png +0 -0
  153. package/web/avatars/18.png +0 -0
  154. package/web/avatars/19.png +0 -0
  155. package/web/avatars/20.png +0 -0
  156. package/web/dashboard.css +77 -0
  157. package/web/dashboard.html +29 -2
  158. package/web/dashboard.js +296 -33
  159. package/workflow/builtin_caps.mjs +94 -0
  160. package/workflow/declarative.mjs +101 -0
  161. package/workflow/named.mjs +71 -0
  162. package/workflow/named_cron.mjs +50 -0
  163. package/workflow/nodes.mjs +67 -0
  164. package/workflow/run_request.mjs +74 -0
  165. package/workflow/yaml_min.mjs +97 -0
@@ -45,6 +45,61 @@ class ConnectionError extends Error {
45
45
  }
46
46
  }
47
47
 
48
+ class TimeoutError extends Error {
49
+ constructor(idleMs) {
50
+ super(`ollama: idle timeout — no stream activity for ${idleMs}ms`);
51
+ this.name = 'TimeoutError';
52
+ this.code = 'TIMEOUT';
53
+ this.idleMs = idleMs;
54
+ }
55
+ }
56
+
57
+ const DEFAULT_IDLE_TIMEOUT_MS = 120000;
58
+
59
+ // Resolve the IDLE (inter-chunk) timeout once per request: opts override
60
+ // wins, then env LAZYCLAW_REQUEST_TIMEOUT_MS (positive int), else 120s.
61
+ function resolveIdleTimeoutMs(opts) {
62
+ if (Number.isFinite(opts?.idleTimeoutMs) && opts.idleTimeoutMs > 0) return opts.idleTimeoutMs;
63
+ const raw = parseInt(process.env.LAZYCLAW_REQUEST_TIMEOUT_MS ?? '', 10);
64
+ if (Number.isFinite(raw) && raw > 0) return raw;
65
+ return DEFAULT_IDLE_TIMEOUT_MS;
66
+ }
67
+
68
+ // Wrap a chunk iterator with an IDLE timeout: abort only when NO chunk has
69
+ // arrived for idleMs (the timer resets on every received chunk, and also
70
+ // guards the connect/first-byte phase). This is NOT a total-duration cap,
71
+ // so a long but healthy generation that streams steadily is never aborted.
72
+ // `controller` is aborted on idle expiry so the real connection tears down.
73
+ async function* iterateWithIdleTimeout(body, idleMs, controller) {
74
+ const iterator = iterateBody(body)[Symbol.asyncIterator]();
75
+ try {
76
+ while (true) {
77
+ let timer;
78
+ const idle = new Promise((_, reject) => {
79
+ timer = setTimeout(() => {
80
+ controller.abort();
81
+ reject(new TimeoutError(idleMs));
82
+ }, idleMs);
83
+ });
84
+ let step;
85
+ try {
86
+ step = await Promise.race([iterator.next(), idle]);
87
+ } finally {
88
+ clearTimeout(timer);
89
+ }
90
+ if (step.done) return;
91
+ yield step.value;
92
+ }
93
+ } finally {
94
+ // Best-effort, NON-awaited cleanup: on idle abort the underlying reader
95
+ // may be suspended on a read that never settles, so awaiting return()
96
+ // would re-hang us. The controller abort already tears down the socket.
97
+ if (typeof iterator.return === 'function') {
98
+ try { Promise.resolve(iterator.return()).catch(() => {}); } catch { /* ignore */ }
99
+ }
100
+ }
101
+ }
102
+
48
103
  async function* iterateBody(body) {
49
104
  if (body && typeof body.getReader === 'function') {
50
105
  const reader = body.getReader();
@@ -64,6 +119,28 @@ async function* iterateBody(body) {
64
119
  throw new Error('ollama: response body is not iterable');
65
120
  }
66
121
 
122
+ // The final `done:true` frame carries prompt_eval_count (input) / eval_count
123
+ // (output) and a done_reason. Report tokens via onUsage (cost 0 for a local
124
+ // model) and signal a num_predict/context cut (done_reason 'length') so the
125
+ // caller can warn rather than treat a truncated answer as complete.
126
+ function _emitOllamaDone(obj, opts) {
127
+ const inT = obj.prompt_eval_count;
128
+ const outT = obj.eval_count;
129
+ if ((inT != null || outT != null) && typeof opts.onUsage === 'function') {
130
+ try {
131
+ opts.onUsage({
132
+ inputTokens: inT ?? null,
133
+ outputTokens: outT ?? null,
134
+ totalTokens: (inT ?? 0) + (outT ?? 0),
135
+ totalCostUsd: 0,
136
+ });
137
+ } catch { /* never fail the stream on a usage callback */ }
138
+ }
139
+ if (obj.done_reason === 'length' && typeof opts.onTruncated === 'function') {
140
+ try { opts.onTruncated('length'); } catch { /* never fail the stream on a callback */ }
141
+ }
142
+ }
143
+
67
144
  export const ollamaProvider = {
68
145
  name: 'ollama',
69
146
  /**
@@ -87,13 +164,28 @@ export const ollamaProvider = {
87
164
 
88
165
  if (opts.signal?.aborted) throw new AbortError('aborted before request');
89
166
 
167
+ // Compose the caller's signal with an internal controller that the idle
168
+ // timeout aborts. The composed signal goes to fetch so a stalled
169
+ // connection (or a user cancel) tears down the real socket.
170
+ const idleMs = resolveIdleTimeoutMs(opts);
171
+ const idleController = new AbortController();
172
+ const fetchSignal = opts.signal
173
+ ? (typeof AbortSignal.any === 'function'
174
+ ? AbortSignal.any([opts.signal, idleController.signal])
175
+ : idleController.signal)
176
+ : idleController.signal;
177
+ const forwardCallerAbort = () => idleController.abort();
178
+ if (opts.signal && typeof AbortSignal.any !== 'function') {
179
+ opts.signal.addEventListener('abort', forwardCallerAbort, { once: true });
180
+ }
181
+
90
182
  let res;
91
183
  try {
92
184
  res = await fetchFn(`${baseUrl}/api/chat`, {
93
185
  method: 'POST',
94
186
  headers: { 'content-type': 'application/json' },
95
187
  body: JSON.stringify({ model, messages: apiMessages, stream: true }),
96
- signal: opts.signal,
188
+ signal: fetchSignal,
97
189
  });
98
190
  } catch (err) {
99
191
  // Distinguish "ollama isn't running" from generic network errors so
@@ -112,7 +204,10 @@ export const ollamaProvider = {
112
204
 
113
205
  const decoder = new TextDecoder('utf-8', { fatal: false });
114
206
  let buffer = '';
115
- for await (const chunk of iterateBody(res.body)) {
207
+ try {
208
+ for await (const chunk of iterateWithIdleTimeout(res.body, idleMs, idleController)) {
209
+ // A user cancel surfaces as an idle-controller abort too; map it back
210
+ // to ABORT so it stays distinguishable from a TIMEOUT.
116
211
  if (opts.signal?.aborted) throw new AbortError('aborted mid-stream');
117
212
  buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
118
213
  // Ollama is newline-delimited JSON, not SSE — split on '\n', parse
@@ -125,7 +220,7 @@ export const ollamaProvider = {
125
220
  try {
126
221
  const obj = JSON.parse(line);
127
222
  if (obj?.message?.content) yield obj.message.content;
128
- if (obj?.done) return;
223
+ if (obj?.done) { _emitOllamaDone(obj, opts); return; }
129
224
  if (obj?.error) throw new ApiError(500, obj.error);
130
225
  } catch (err) {
131
226
  if (err instanceof ApiError) throw err;
@@ -133,6 +228,11 @@ export const ollamaProvider = {
133
228
  }
134
229
  }
135
230
  }
231
+ } finally {
232
+ if (opts.signal && typeof AbortSignal.any !== 'function') {
233
+ opts.signal.removeEventListener('abort', forwardCallerAbort);
234
+ }
235
+ }
136
236
  // Any trailing bytes the decoder still holds onto get flushed and
137
237
  // parsed once. Empty after a clean stream.
138
238
  const tail = decoder.decode();
@@ -140,6 +240,7 @@ export const ollamaProvider = {
140
240
  try {
141
241
  const obj = JSON.parse(tail);
142
242
  if (obj?.message?.content) yield obj.message.content;
243
+ if (obj?.done) _emitOllamaDone(obj, opts);
143
244
  } catch { /* malformed tail — drop */ }
144
245
  }
145
246
  },
@@ -14,6 +14,13 @@
14
14
 
15
15
  const DEFAULT_MAX_TOKENS = 4096;
16
16
 
17
+ // OpenAI o-series reasoning models (o1/o3/o4...) reject `max_tokens` with
18
+ // HTTP 400 'Unsupported parameter: max_tokens, use max_completion_tokens'.
19
+ // They are surfaced in this provider's own suggestedModels (registry.mjs).
20
+ function isReasoningModel(model) {
21
+ return /^o\d/i.test(String(model || ''));
22
+ }
23
+
17
24
  class InvalidApiKeyError extends Error {
18
25
  constructor(message = 'invalid OpenAI api key') {
19
26
  super(message);
@@ -41,6 +48,61 @@ class RateLimitError extends Error {
41
48
  }
42
49
  }
43
50
 
51
+ class TimeoutError extends Error {
52
+ constructor(idleMs) {
53
+ super(`openai: idle timeout — no stream activity for ${idleMs}ms`);
54
+ this.name = 'TimeoutError';
55
+ this.code = 'TIMEOUT';
56
+ this.idleMs = idleMs;
57
+ }
58
+ }
59
+
60
+ const DEFAULT_IDLE_TIMEOUT_MS = 120000;
61
+
62
+ // Resolve the IDLE (inter-chunk) timeout once per request: opts override
63
+ // wins, then env LAZYCLAW_REQUEST_TIMEOUT_MS (positive int), else 120s.
64
+ function resolveIdleTimeoutMs(opts) {
65
+ if (Number.isFinite(opts?.idleTimeoutMs) && opts.idleTimeoutMs > 0) return opts.idleTimeoutMs;
66
+ const raw = parseInt(process.env.LAZYCLAW_REQUEST_TIMEOUT_MS ?? '', 10);
67
+ if (Number.isFinite(raw) && raw > 0) return raw;
68
+ return DEFAULT_IDLE_TIMEOUT_MS;
69
+ }
70
+
71
+ // Wrap a chunk iterator with an IDLE timeout: abort only when NO chunk has
72
+ // arrived for idleMs (the timer resets on every received chunk, and also
73
+ // guards the connect/first-byte phase). This is NOT a total-duration cap,
74
+ // so a long but healthy generation that streams steadily is never aborted.
75
+ // `controller` is aborted on idle expiry so the real connection tears down.
76
+ async function* iterateWithIdleTimeout(body, idleMs, controller) {
77
+ const iterator = iterateBody(body)[Symbol.asyncIterator]();
78
+ try {
79
+ while (true) {
80
+ let timer;
81
+ const idle = new Promise((_, reject) => {
82
+ timer = setTimeout(() => {
83
+ controller.abort();
84
+ reject(new TimeoutError(idleMs));
85
+ }, idleMs);
86
+ });
87
+ let step;
88
+ try {
89
+ step = await Promise.race([iterator.next(), idle]);
90
+ } finally {
91
+ clearTimeout(timer);
92
+ }
93
+ if (step.done) return;
94
+ yield step.value;
95
+ }
96
+ } finally {
97
+ // Best-effort, NON-awaited cleanup: on idle abort the underlying reader
98
+ // may be suspended on a read that never settles, so awaiting return()
99
+ // would re-hang us. The controller abort already tears down the socket.
100
+ if (typeof iterator.return === 'function') {
101
+ try { Promise.resolve(iterator.return()).catch(() => {}); } catch { /* ignore */ }
102
+ }
103
+ }
104
+ }
105
+
44
106
  class ApiError extends Error {
45
107
  constructor(status, body) {
46
108
  super(`openai api ${status}: ${String(body).slice(0, 200)}`);
@@ -120,10 +182,14 @@ export const openaiProvider = {
120
182
 
121
183
  const body = {
122
184
  model,
123
- max_tokens: opts.maxTokens || DEFAULT_MAX_TOKENS,
124
185
  stream: true,
125
186
  messages: apiMessages,
126
187
  };
188
+ // Reasoning models take max_completion_tokens; everything else takes
189
+ // max_tokens. Never emit both — the o-series rejects max_tokens.
190
+ const tokenCap = opts.maxTokens || DEFAULT_MAX_TOKENS;
191
+ if (isReasoningModel(model)) body.max_completion_tokens = tokenCap;
192
+ else body.max_tokens = tokenCap;
127
193
  // Tool-use passthrough mirrors the anthropic provider: opts.tools is an
128
194
  // array of OpenAI-shaped tools. opts.toolChoice maps to tool_choice.
129
195
  if (Array.isArray(opts.tools) && opts.tools.length > 0) {
@@ -140,6 +206,20 @@ export const openaiProvider = {
140
206
  }
141
207
 
142
208
  if (opts.signal?.aborted) throw new AbortError('aborted before request');
209
+ // Compose the caller's signal with an internal controller that the idle
210
+ // timeout aborts. The composed signal goes to fetch so a stalled
211
+ // connection (or a user cancel) tears down the real socket.
212
+ const idleMs = resolveIdleTimeoutMs(opts);
213
+ const idleController = new AbortController();
214
+ const fetchSignal = opts.signal
215
+ ? (typeof AbortSignal.any === 'function'
216
+ ? AbortSignal.any([opts.signal, idleController.signal])
217
+ : idleController.signal)
218
+ : idleController.signal;
219
+ const forwardCallerAbort = () => idleController.abort();
220
+ if (opts.signal && typeof AbortSignal.any !== 'function') {
221
+ opts.signal.addEventListener('abort', forwardCallerAbort, { once: true });
222
+ }
143
223
  const res = await fetchFn('https://api.openai.com/v1/chat/completions', {
144
224
  method: 'POST',
145
225
  headers: {
@@ -147,7 +227,7 @@ export const openaiProvider = {
147
227
  'authorization': `Bearer ${opts.apiKey}`,
148
228
  },
149
229
  body: JSON.stringify(body),
150
- signal: opts.signal,
230
+ signal: fetchSignal,
151
231
  });
152
232
 
153
233
  if (!res.ok) {
@@ -165,6 +245,7 @@ export const openaiProvider = {
165
245
  // shape that mirrors the anthropic provider's onUsage payload so
166
246
  // callers don't have to special-case per provider.
167
247
  let usage = null;
248
+ let lastFinish = null;
168
249
  // OpenAI streams tool_calls as deltas with an `index` we use as the
169
250
  // accumulation key. Each delta may carry a partial id, name, and/or
170
251
  // arguments string. We assemble until the stream signals
@@ -187,7 +268,25 @@ export const openaiProvider = {
187
268
  });
188
269
  } catch { /* never let a callback abort the stream */ }
189
270
  };
190
- for await (const chunk of iterateBody(res.body)) {
271
+ // Fire onUsage + (if truncated) onTruncated EXACTLY ONCE — from the [DONE]
272
+ // sentinel, or from the post-loop flush if the upstream closed after the
273
+ // usage frame without ever sending [DONE].
274
+ let finalized = false;
275
+ const finalize = () => {
276
+ if (finalized) return;
277
+ finalized = true;
278
+ for (const idx of Array.from(toolCallsByIndex.keys())) flushToolCall(idx);
279
+ if (usage && typeof opts.onUsage === 'function') {
280
+ try { opts.onUsage(usage); } catch { /* never let a callback abort */ }
281
+ }
282
+ if (lastFinish === 'length' && typeof opts.onTruncated === 'function') {
283
+ try { opts.onTruncated('length'); } catch { /* ditto */ }
284
+ }
285
+ };
286
+ try {
287
+ for await (const chunk of iterateWithIdleTimeout(res.body, idleMs, idleController)) {
288
+ // A user cancel surfaces as an idle-controller abort too; map it back
289
+ // to ABORT so it stays distinguishable from a TIMEOUT.
191
290
  if (opts.signal?.aborted) throw new AbortError('aborted mid-stream');
192
291
  buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
193
292
  let consumed = 0;
@@ -195,11 +294,7 @@ export const openaiProvider = {
195
294
  consumed = frame.nextCursor;
196
295
  if (!frame.data) continue;
197
296
  if (frame.data === '[DONE]') {
198
- // Drain any tool calls that haven't been flushed by finish_reason.
199
- for (const idx of Array.from(toolCallsByIndex.keys())) flushToolCall(idx);
200
- if (usage && typeof opts.onUsage === 'function') {
201
- try { opts.onUsage(usage); } catch { /* never let a callback abort */ }
202
- }
297
+ finalize();
203
298
  return;
204
299
  }
205
300
  try {
@@ -226,6 +321,7 @@ export const openaiProvider = {
226
321
  toolCallsByIndex.set(idx, cur);
227
322
  }
228
323
  }
324
+ if (choice?.finish_reason) lastFinish = choice.finish_reason;
229
325
  if (choice?.finish_reason === 'tool_calls') {
230
326
  for (const idx of Array.from(toolCallsByIndex.keys())) flushToolCall(idx);
231
327
  }
@@ -235,6 +331,12 @@ export const openaiProvider = {
235
331
  }
236
332
  if (consumed > 0) buffer = buffer.slice(consumed);
237
333
  }
334
+ } finally {
335
+ if (opts.signal && typeof AbortSignal.any !== 'function') {
336
+ opts.signal.removeEventListener('abort', forwardCallerAbort);
337
+ }
338
+ }
339
+ finalize(); // flush captured usage/truncation if the stream ended without [DONE]
238
340
  const tail = decoder.decode();
239
341
  if (tail) buffer += tail;
240
342
  },
@@ -48,6 +48,61 @@ class RateLimitError extends Error {
48
48
  }
49
49
  }
50
50
 
51
+ class TimeoutError extends Error {
52
+ constructor(idleMs) {
53
+ super(`openai-compat: idle timeout — no stream activity for ${idleMs}ms`);
54
+ this.name = 'TimeoutError';
55
+ this.code = 'TIMEOUT';
56
+ this.idleMs = idleMs;
57
+ }
58
+ }
59
+
60
+ const DEFAULT_IDLE_TIMEOUT_MS = 120000;
61
+
62
+ // Resolve the IDLE (inter-chunk) timeout once per request: opts override
63
+ // wins, then env LAZYCLAW_REQUEST_TIMEOUT_MS (positive int), else 120s.
64
+ function resolveIdleTimeoutMs(opts) {
65
+ if (Number.isFinite(opts?.idleTimeoutMs) && opts.idleTimeoutMs > 0) return opts.idleTimeoutMs;
66
+ const raw = parseInt(process.env.LAZYCLAW_REQUEST_TIMEOUT_MS ?? '', 10);
67
+ if (Number.isFinite(raw) && raw > 0) return raw;
68
+ return DEFAULT_IDLE_TIMEOUT_MS;
69
+ }
70
+
71
+ // Wrap a chunk iterator with an IDLE timeout: abort only when NO chunk has
72
+ // arrived for idleMs (the timer resets on every received chunk, and also
73
+ // guards the connect/first-byte phase). This is NOT a total-duration cap,
74
+ // so a long but healthy generation that streams steadily is never aborted.
75
+ // `controller` is aborted on idle expiry so the real connection tears down.
76
+ async function* iterateWithIdleTimeout(body, idleMs, controller) {
77
+ const iterator = iterateBody(body)[Symbol.asyncIterator]();
78
+ try {
79
+ while (true) {
80
+ let timer;
81
+ const idle = new Promise((_, reject) => {
82
+ timer = setTimeout(() => {
83
+ controller.abort();
84
+ reject(new TimeoutError(idleMs));
85
+ }, idleMs);
86
+ });
87
+ let step;
88
+ try {
89
+ step = await Promise.race([iterator.next(), idle]);
90
+ } finally {
91
+ clearTimeout(timer);
92
+ }
93
+ if (step.done) return;
94
+ yield step.value;
95
+ }
96
+ } finally {
97
+ // Best-effort, NON-awaited cleanup: on idle abort the underlying reader
98
+ // may be suspended on a read that never settles, so awaiting return()
99
+ // would re-hang us. The controller abort already tears down the socket.
100
+ if (typeof iterator.return === 'function') {
101
+ try { Promise.resolve(iterator.return()).catch(() => {}); } catch { /* ignore */ }
102
+ }
103
+ }
104
+ }
105
+
51
106
  class ApiError extends Error {
52
107
  constructor(status, body) {
53
108
  super(`openai-compat ${status}: ${String(body).slice(0, 200)}`);
@@ -175,12 +230,26 @@ export function makeOpenAICompatProvider(cfg) {
175
230
  if (apiKey) headers['authorization'] = `Bearer ${apiKey}`;
176
231
 
177
232
  if (opts.signal?.aborted) throw new AbortError('aborted before request');
233
+ // Compose the caller's signal with an internal controller that the idle
234
+ // timeout aborts. The composed signal goes to fetch so a stalled
235
+ // connection (or a user cancel) tears down the real socket.
236
+ const idleMs = resolveIdleTimeoutMs(opts);
237
+ const idleController = new AbortController();
238
+ const fetchSignal = opts.signal
239
+ ? (typeof AbortSignal.any === 'function'
240
+ ? AbortSignal.any([opts.signal, idleController.signal])
241
+ : idleController.signal)
242
+ : idleController.signal;
243
+ const forwardCallerAbort = () => idleController.abort();
244
+ if (opts.signal && typeof AbortSignal.any !== 'function') {
245
+ opts.signal.addEventListener('abort', forwardCallerAbort, { once: true });
246
+ }
178
247
  const url = `${baseUrl}/chat/completions`;
179
248
  const res = await fetchFn(url, {
180
249
  method: 'POST',
181
250
  headers,
182
251
  body: JSON.stringify(body),
183
- signal: opts.signal,
252
+ signal: fetchSignal,
184
253
  });
185
254
 
186
255
  if (!res.ok) {
@@ -193,6 +262,7 @@ export function makeOpenAICompatProvider(cfg) {
193
262
  const decoder = new TextDecoder('utf-8', { fatal: false });
194
263
  let buffer = '';
195
264
  let usage = null;
265
+ let lastFinish = null;
196
266
  const toolCallsByIndex = new Map();
197
267
  const flushToolCall = (idx) => {
198
268
  const tc = toolCallsByIndex.get(idx);
@@ -211,7 +281,24 @@ export function makeOpenAICompatProvider(cfg) {
211
281
  });
212
282
  } catch { /* never let a callback abort the stream */ }
213
283
  };
214
- for await (const chunk of iterateBody(res.body)) {
284
+ // Fire onUsage + (if truncated) onTruncated EXACTLY ONCE — from [DONE] or
285
+ // the post-loop flush if the upstream closes without the sentinel.
286
+ let finalized = false;
287
+ const finalize = () => {
288
+ if (finalized) return;
289
+ finalized = true;
290
+ for (const idx of Array.from(toolCallsByIndex.keys())) flushToolCall(idx);
291
+ if (usage && typeof opts.onUsage === 'function') {
292
+ try { opts.onUsage(usage); } catch { /* swallow */ }
293
+ }
294
+ if (lastFinish === 'length' && typeof opts.onTruncated === 'function') {
295
+ try { opts.onTruncated('length'); } catch { /* swallow */ }
296
+ }
297
+ };
298
+ try {
299
+ for await (const chunk of iterateWithIdleTimeout(res.body, idleMs, idleController)) {
300
+ // A user cancel surfaces as an idle-controller abort too; map it back
301
+ // to ABORT so it stays distinguishable from a TIMEOUT.
215
302
  if (opts.signal?.aborted) throw new AbortError('aborted mid-stream');
216
303
  buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
217
304
  let consumed = 0;
@@ -219,10 +306,7 @@ export function makeOpenAICompatProvider(cfg) {
219
306
  consumed = frame.nextCursor;
220
307
  if (!frame.data) continue;
221
308
  if (frame.data === '[DONE]') {
222
- for (const idx of Array.from(toolCallsByIndex.keys())) flushToolCall(idx);
223
- if (usage && typeof opts.onUsage === 'function') {
224
- try { opts.onUsage(usage); } catch { /* swallow */ }
225
- }
309
+ finalize();
226
310
  return;
227
311
  }
228
312
  try {
@@ -247,6 +331,7 @@ export function makeOpenAICompatProvider(cfg) {
247
331
  toolCallsByIndex.set(idx, cur);
248
332
  }
249
333
  }
334
+ if (choice?.finish_reason) lastFinish = choice.finish_reason;
250
335
  if (choice?.finish_reason === 'tool_calls') {
251
336
  for (const idx of Array.from(toolCallsByIndex.keys())) flushToolCall(idx);
252
337
  }
@@ -256,6 +341,12 @@ export function makeOpenAICompatProvider(cfg) {
256
341
  }
257
342
  if (consumed > 0) buffer = buffer.slice(consumed);
258
343
  }
344
+ } finally {
345
+ if (opts.signal && typeof AbortSignal.any !== 'function') {
346
+ opts.signal.removeEventListener('abort', forwardCallerAbort);
347
+ }
348
+ }
349
+ finalize(); // flush captured usage/truncation if the stream ended without [DONE]
259
350
  const tail = decoder.decode();
260
351
  if (tail) buffer += tail;
261
352
  },
@@ -27,7 +27,8 @@
27
27
  // "gemini:gemini-2.5-pro"
28
28
  // ],
29
29
  // "maxSubtasks": 5, // optional, default 5
30
- // "concurrency": 0 // optional, 0 = sequential (visible streaming)
30
+ // "concurrency": 0 // optional; default = min(3, workers), parallel.
31
+ // // 0 or 1 = sequential (visible live streaming)
31
32
  // }
32
33
  // }
33
34
  //
@@ -86,6 +87,24 @@ function _bestPlanArray(text) {
86
87
  return null;
87
88
  }
88
89
 
90
+ // Default worker-pool concurrency when cfg.orchestrator.concurrency is unset.
91
+ // Parallel is the point of a worker pool; an unconfigured fleet should not run
92
+ // subtasks one at a time. Clamped to the worker count at the call site, and an
93
+ // explicit 0/1 still selects the sequential live-streaming path.
94
+ const DEFAULT_CONCURRENCY = 3;
95
+
96
+ // Opt-in agentic workers (cfg.orchestrator.agenticWorkers): an EXECUTE worker
97
+ // runs through runAgentTurn so it can actually DO work with its tools (shell,
98
+ // file read, recall) instead of only streaming text. Default OFF — the
99
+ // text-streaming path is byte-stable. Tool calls are confined by the sandbox
100
+ // the caller passes; the loop is bounded by workerMaxIterations.
101
+ const DEFAULT_WORKER_TOOLS = ['bash', 'read', 'grep', 'recall'];
102
+ const DEFAULT_WORKER_MAX_ITERATIONS = 8;
103
+ const AGENTIC_WORKER_ROLE =
104
+ 'You are an orchestrator worker. Complete ONLY the assigned subtask. Use your ' +
105
+ 'tools (shell, file read, grep, recall) to do real work when useful, then report ' +
106
+ 'the result concisely. Do not ask questions — act, then summarise what you found.';
107
+
89
108
  const PLANNER_SYSTEM = `You are an orchestrator that decomposes a user request into independent subtasks for parallel worker agents.
90
109
 
91
110
  Rules:
@@ -262,9 +281,47 @@ export function makeOrchestratorProvider(opts = {}) {
262
281
  // regardless of which worker finished first.
263
282
  // The number is clamped to [1, workers.length] so a runaway value
264
283
  // can't accidentally over-subscribe a single worker.
265
- const rawConcurrency = Number.isFinite(o.concurrency) ? Math.floor(o.concurrency) : 1;
284
+ const rawConcurrency = Number.isFinite(o.concurrency) ? Math.floor(o.concurrency) : DEFAULT_CONCURRENCY;
266
285
  const concurrency = Math.max(1, Math.min(rawConcurrency, workers.length));
267
286
  yield `### 2. Executing ${trimmed.length} subtask${trimmed.length === 1 ? '' : 's'}${concurrency > 1 ? ` (concurrency=${concurrency}, parallel)` : ''}\n\n`;
287
+
288
+ // Run one worker on one subtask through the agentic tool loop. Confined by
289
+ // the caller's sandbox; bounded by workerMaxIterations; abort-propagating.
290
+ const _runAgenticWorker = async (worker, sub) => {
291
+ const { runAgentTurn } = await import('../mas/agent_turn.mjs');
292
+ const r = await runAgentTurn({
293
+ agent: {
294
+ name: worker.name, provider: worker.name, model: worker.model,
295
+ role: AGENTIC_WORKER_ROLE,
296
+ tools: Array.isArray(o.workerTools) ? o.workerTools : DEFAULT_WORKER_TOOLS,
297
+ },
298
+ userMessage: sub.task,
299
+ configDir: callerOpts.configDir,
300
+ cwd: callerOpts.cwd,
301
+ apiKey: keyResolver(cfg, worker.name),
302
+ fetchImpl: callerOpts.fetchImpl,
303
+ sandbox: callerOpts.sandbox,
304
+ signal: callerOpts.signal,
305
+ maxIterations: Number.isFinite(o.workerMaxIterations) ? o.workerMaxIterations : DEFAULT_WORKER_MAX_ITERATIONS,
306
+ });
307
+ return r.text || '';
308
+ };
309
+ // Worker output as a chunk stream. agenticWorkers OFF (default) → the
310
+ // provider's live token stream, byte-stable. ON → one buffered chunk with
311
+ // the agent turn's final answer (tool work happened inside).
312
+ const _workerChunks = async function* (worker, sub) {
313
+ if (o.agenticWorkers) {
314
+ const text = await _runAgenticWorker(worker, sub);
315
+ if (text) yield text;
316
+ return;
317
+ }
318
+ yield* worker.prov.sendMessage([{ role: 'user', content: sub.task }], {
319
+ apiKey: keyResolver(cfg, worker.name),
320
+ model: worker.model || undefined,
321
+ signal: callerOpts.signal,
322
+ });
323
+ };
324
+
268
325
  const results = [];
269
326
  if (concurrency <= 1) {
270
327
  // Sequential streaming path — historical default, preserved.
@@ -274,11 +331,7 @@ export function makeOrchestratorProvider(opts = {}) {
274
331
  yield `**Subtask ${sub.id}** \`${worker.name}${worker.model ? ':' + worker.model : ''}\` — ${sub.task}\n\n`;
275
332
  let res = '';
276
333
  try {
277
- for await (const chunk of worker.prov.sendMessage([{ role: 'user', content: sub.task }], {
278
- apiKey: keyResolver(cfg, worker.name),
279
- model: worker.model || undefined,
280
- signal: callerOpts.signal,
281
- })) {
334
+ for await (const chunk of _workerChunks(worker, sub)) {
282
335
  const s = String(chunk);
283
336
  res += s;
284
337
  yield s;
@@ -301,11 +354,7 @@ export function makeOrchestratorProvider(opts = {}) {
301
354
  const chunks = [];
302
355
  let error = null;
303
356
  try {
304
- for await (const chunk of worker.prov.sendMessage([{ role: 'user', content: sub.task }], {
305
- apiKey: keyResolver(cfg, worker.name),
306
- model: worker.model || undefined,
307
- signal: callerOpts.signal,
308
- })) {
357
+ for await (const chunk of _workerChunks(worker, sub)) {
309
358
  chunks.push(String(chunk));
310
359
  }
311
360
  } catch (e) {
@@ -393,3 +442,54 @@ export function makeOrchestratorProvider(opts = {}) {
393
442
  },
394
443
  };
395
444
  }
445
+
446
+ // Minimal one-shot worker dispatch for the `delegate` agent tool.
447
+ //
448
+ // `delegate` hands a single subtask to ONE provider — no plan/synthesis.
449
+ // It reuses the same spec-resolution + key-resolution infra the
450
+ // orchestrator's EXECUTE phase uses (a "<provider>[:<model>]" spec routed
451
+ // through PROVIDERS/PROVIDER_INFO, key via lib/config::_resolveAuthKey),
452
+ // but as a standalone call so the `delegate` tool actually runs.
453
+ //
454
+ // Imports are lazy so this module keeps its leaf static-dep graph (it must
455
+ // NOT statically import ./registry.mjs — that re-forms the registry↔orch
456
+ // cycle; see the header note above).
457
+ //
458
+ // job = { worker: '<provider>[:<model>]', prompt, model? }
459
+ // returns { ok:true, text } | { ok:false, error }
460
+ export async function dispatchWorker(job = {}) {
461
+ const workerSpec = String(job?.worker || '');
462
+ const prompt = String(job?.prompt || '');
463
+ if (!workerSpec || !prompt) return { ok: false, error: 'delegate: worker + prompt required' };
464
+
465
+ let registry, config;
466
+ try {
467
+ registry = await import('./registry.mjs');
468
+ config = await import('../lib/config.mjs');
469
+ } catch (e) {
470
+ return { ok: false, error: `delegate: failed to load provider infra: ${e?.message || String(e)}` };
471
+ }
472
+
473
+ const lookup = (p) => ({ prov: registry.PROVIDERS[p], info: registry.PROVIDER_INFO[p] });
474
+ const worker = _lookupProvider(workerSpec, lookup);
475
+ if (!worker) return { ok: false, error: `delegate: unknown worker provider "${_parseSpec(workerSpec).provider}"` };
476
+ if (worker.name === 'orchestrator') return { ok: false, error: 'delegate: worker cannot be "orchestrator"' };
477
+ // Explicit job.model overrides the model parsed from the spec.
478
+ const model = String(job?.model || '') || worker.model || '';
479
+
480
+ const cfg = config.readConfig() || {};
481
+ const apiKey = config._resolveAuthKey(cfg, worker.name);
482
+
483
+ let text = '';
484
+ try {
485
+ for await (const chunk of worker.prov.sendMessage([{ role: 'user', content: prompt }], {
486
+ apiKey,
487
+ model: model || undefined,
488
+ })) {
489
+ text += String(chunk);
490
+ }
491
+ } catch (e) {
492
+ return { ok: false, error: `delegate: ${worker.name} error: ${e?.message || String(e)}` };
493
+ }
494
+ return { ok: true, text };
495
+ }