jaitus 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jaitus",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Jaitus — a coding agent in your terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "node": ">=20"
17
17
  },
18
18
  "scripts": {
19
- "test": "node tests/source-sanity.js && node tests/rules.js && node tests/run.js && node tests/commands.js && node tests/login.js && node tests/session.js && node tests/openUrl.js && node tests/ink-tui.js && node tests/tui-render.js && node tests/mouse-e2e.js && node tests/environment.js && node tests/update.js",
19
+ "test": "node tests/source-sanity.js && node tests/rules.js && node tests/run.js && node tests/commands.js && node tests/login.js && node tests/session.js && node tests/openUrl.js && node tests/ink-tui.js && node tests/tui-render.js && node tests/mouse-e2e.js && node tests/environment.js && node tests/update.js && node tests/continuation.js",
20
20
  "preview": "node scripts/tui-preview.js",
21
21
  "prepublishOnly": "npm test"
22
22
  },
package/src/agent/loop.js CHANGED
@@ -31,6 +31,23 @@ import { fromResponse, fromGatewayBody } from '../errors.js';
31
31
 
32
32
  const MAX_ITERATIONS = 40;
33
33
 
34
+ // Room for a long answer or a large file in one reply. The models behind Jaitus reason
35
+ // before they answer, and that reasoning is spent from the same budget — at 8192 a long
36
+ // answer, or any file of a few hundred lines, was cut off partway.
37
+ export const MAX_OUTPUT_TOKENS = 32000;
38
+
39
+ // A reply that stopped before it finished — the output limit, or a connection dropped
40
+ // somewhere between the provider and here — is continued, not presented as the answer.
41
+ // Bounded, so a model that never finishes cannot loop on the user's allowance.
42
+ export const MAX_CONTINUATIONS = 4;
43
+ export const CONTINUE_NOTE = 'Your previous reply was cut off before it finished (an output limit or a dropped connection). '
44
+ + 'Continue from exactly where it stopped: do not repeat or summarise what you already wrote, and do not mention this note. '
45
+ + 'If you were in the middle of a tool call, make that call again in full.';
46
+
47
+ // A request that failed before anything arrived (a network blip) is tried again this
48
+ // many times before it is reported.
49
+ const NETWORK_RETRIES = 2;
50
+
34
51
  /** opencode tool icon per tool, muted when done. ASCII twins included. */
35
52
  const TOOL_MARKS = {
36
53
  run_command: ['$', '$'], read_file: ['→', '>'], read_image: ['→', '>'],
@@ -145,7 +162,7 @@ ${project}
145
162
  async function streamTurn({ client, messages, model, system, tools, onText, onToolStart, signal }) {
146
163
  const res = await client.messages({
147
164
  model,
148
- max_tokens: 8192,
165
+ max_tokens: MAX_OUTPUT_TOKENS,
149
166
  system: system || SYSTEM_PROMPT,
150
167
  tools,
151
168
  messages,
@@ -158,6 +175,10 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
158
175
  // Blocks are keyed by the index the stream gives them, because they interleave.
159
176
  const blocks = new Map();
160
177
  let stopReason = null;
178
+ // Whether the stream ended the way a finished message ends. A stream that just stops
179
+ // — no message_stop, no stop reason — was cut, and used to be taken as a complete,
180
+ // silent answer: the "it stops midway for no reason" bug.
181
+ let sawStop = false;
161
182
  const usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
162
183
  // Usage can arrive at the START of a stream (Anthropic-format providers) or only at
163
184
  // the END, in message_delta (OpenAI-format providers, translated by the gateway —
@@ -208,6 +229,9 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
208
229
  if (event.delta?.stop_reason) stopReason = event.delta.stop_reason;
209
230
  takeUsage(event.usage);
210
231
  break;
232
+ case 'message_stop':
233
+ sawStop = true;
234
+ break;
211
235
  default:
212
236
  break;
213
237
  }
@@ -222,21 +246,31 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
222
246
  // independently would corrupt it. The decoder holds the partial bytes until complete.
223
247
  const decoder = new TextDecoder();
224
248
 
225
- for await (const chunk of res.body) {
226
- buffer += decoder.decode(chunk, { stream: true });
227
- const frames = buffer.split('\n\n');
228
- buffer = frames.pop() || '';
229
- for (const frame of frames) {
230
- for (const line of frame.split('\n')) {
231
- if (!line.startsWith('data:')) continue;
232
- const raw = line.slice(5).trim();
233
- if (!raw || raw === '[DONE]') continue;
234
- try { handle(JSON.parse(raw)); } catch { /* keepalive or partial */ }
249
+ let cut = false;
250
+ try {
251
+ for await (const chunk of res.body) {
252
+ buffer += decoder.decode(chunk, { stream: true });
253
+ const frames = buffer.split('\n\n');
254
+ buffer = frames.pop() || '';
255
+ for (const frame of frames) {
256
+ for (const line of frame.split('\n')) {
257
+ if (!line.startsWith('data:')) continue;
258
+ const raw = line.slice(5).trim();
259
+ if (!raw || raw === '[DONE]') continue;
260
+ try { handle(JSON.parse(raw)); } catch { /* keepalive or partial */ }
261
+ }
235
262
  }
263
+ if (streamError) break;
236
264
  }
237
- if (streamError) break;
265
+ } catch (e) {
266
+ if (signal?.aborted || e?.name === 'AbortError') throw e;
267
+ // The connection dropped mid-answer. What arrived is kept and continued from; with
268
+ // nothing received yet, the caller retries the request instead.
269
+ if (!blocks.size) throw e;
270
+ cut = true;
238
271
  }
239
272
  if (streamError) throw streamError;
273
+ if (cut || (!sawStop && !stopReason)) stopReason = 'cut';
240
274
 
241
275
  const content = [...blocks.entries()]
242
276
  .sort((a, b) => a[0] - b[0])
@@ -285,6 +319,12 @@ export async function runAgent({
285
319
  ...Object.fromEntries(mcp.map((tool) => [tool.name, tool])),
286
320
  };
287
321
 
322
+ let continuations = 0;
323
+ let networkRetries = 0;
324
+ // True while the previous reply is being continued: its text is still on screen as
325
+ // one live message, and the continuation must extend it rather than start another.
326
+ let continuing = false;
327
+
288
328
  for (let iteration = 0; iteration < maxIterations; iteration++) {
289
329
  let streamed = false;
290
330
  const spin = tui ? { stop: () => {}, update: () => {} } : (silent ? { stop: () => {}, update: () => {} } : spinner(glyph('Working…', 'Working...')));
@@ -323,6 +363,15 @@ export async function runAgent({
323
363
  spin.stop();
324
364
  // Esc during streaming: stop mid-sentence, keep what landed, don't treat it
325
365
  // as an error. The partial text is already on screen.
366
+ // A network failure before anything arrived: try again, briefly, before reporting.
367
+ // Anything the gateway itself refused (a limit, a sign-in problem) has a status and
368
+ // is reported at once — retrying it would only repeat the refusal.
369
+ if (!(signal?.aborted || e?.name === 'AbortError') && !e?.status && networkRetries < NETWORK_RETRIES) {
370
+ networkRetries += 1;
371
+ await new Promise((r) => setTimeout(r, 800 * networkRetries));
372
+ iteration -= 1;
373
+ continue;
374
+ }
326
375
  if (signal?.aborted || e?.name === 'AbortError') {
327
376
  if (tui) tui.flushStream();
328
377
  if (streamed && !silent) out('');
@@ -334,8 +383,16 @@ export async function runAgent({
334
383
  throw e;
335
384
  }
336
385
  spin.stop();
337
- if (tui) tui.flushStream();
338
- if (streamed) { if (tui) tui.printStream('\n'); else if (!silent && !onText) out(''); }
386
+ networkRetries = 0;
387
+ const incomplete = turn.stopReason === 'max_tokens' || turn.stopReason === 'cut';
388
+ // A tool call cut off halfway has JSON that is not whole; it is dropped and the model
389
+ // is asked to make it again.
390
+ if (incomplete) turn.content = turn.content.filter((b) => b.type !== 'tool_use' || b.input?.__parse_error === undefined);
391
+ const willContinue = incomplete && continuations < MAX_CONTINUATIONS && !turn.content.some((b) => b.type === 'tool_use');
392
+ if (!willContinue) {
393
+ if (tui) tui.flushStream();
394
+ if (streamed || continuing) { if (tui) tui.printStream('\n'); else if (!silent && !onText) out(''); }
395
+ }
339
396
 
340
397
  totals.input += turn.usage.input_tokens || 0;
341
398
  totals.cached += (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
@@ -346,6 +403,24 @@ export async function runAgent({
346
403
  // appeared; now it moves after every step of the turn.
347
404
  tui?.setTurnTokens?.(totals.input + totals.cached + totals.output);
348
405
 
406
+ if (willContinue) {
407
+ continuations += 1;
408
+ continuing = true;
409
+ const text = turn.content.filter((b) => b.type === 'text');
410
+ if (text.length) messages.push({ role: 'assistant', content: text });
411
+ messages.push({ role: 'user', content: [{ type: 'text', text: CONTINUE_NOTE }] });
412
+ onEvent?.({ type: 'continued', reason: turn.stopReason, count: continuations });
413
+ continue;
414
+ }
415
+ continuing = false;
416
+ if (incomplete && !turn.content.some((b) => b.type === 'tool_use')) {
417
+ // Still unfinished after every continuation: say so, rather than let a partial
418
+ // answer pass as a whole one.
419
+ const why = 'The reply was still unfinished after several continuations. Ask for the rest, or narrow the request.';
420
+ if (tui?.notice) tui.notice(why, 'warn');
421
+ else if (!silent) out(c.yellow(` ${why}`));
422
+ }
423
+
349
424
  // Append the assistant turn verbatim — tool_use blocks and all.
350
425
  messages.push({ role: 'assistant', content: turn.content });
351
426
  onEvent?.({ type: 'assistant_turn', content: turn.content, usage: turn.usage, iteration });
package/src/index.js CHANGED
@@ -792,9 +792,11 @@ async function session({ flags }) {
792
792
  spent.turns += 1;
793
793
  // opencode assistant metadata: ▣ Mode · model · duration.
794
794
  const secs = ((Date.now() - turnStarted) / 1000).toFixed(1);
795
- const posture = permissions.mode === 'plan' ? 'Read-only'
795
+ // The same words the header uses for the mode. "Off" on its own read as though
796
+ // something was broken; it meant approvals were off.
797
+ const posture = permissions.mode === 'plan' ? 'Plan'
796
798
  : permissions.mode === 'acceptEdits' ? 'Auto-edit'
797
- : permissions.mode === 'bypassPermissions' ? 'Off' : 'Ask';
799
+ : permissions.mode === 'bypassPermissions' ? 'Bypass' : 'Ask';
798
800
  const meta = ` ${c.brand(glyph('▣', '*'))} ${posture} ${c.dim(`· ${model} · ${secs}s`)}`;
799
801
  if (tui) tui.print(meta);
800
802
  else out(meta);
@@ -1131,7 +1133,7 @@ async function session({ flags }) {
1131
1133
  const { columns: termColumns } = await import('./ui.js');
1132
1134
  const approval = permissions.mode === 'plan' ? 'read-only'
1133
1135
  : permissions.mode === 'acceptEdits' ? 'auto-edit'
1134
- : permissions.mode === 'bypassPermissions' ? 'off' : 'ask';
1136
+ : permissions.mode === 'bypassPermissions' ? 'bypass' : 'ask';
1135
1137
  for (const line of renderClassicOpen({
1136
1138
  version: VERSION, model, approval, cwd: flags.cwd,
1137
1139
  account: creds?.user?.email ? `${creds.user.email}${creds.user.isPaid ? '' : ' (free plan)'}` : null,
@@ -2062,7 +2064,7 @@ async function slashCommand(verb, args, ctx) {
2062
2064
  // Session header on demand, codex-style.
2063
2065
  const modeApproval = ctx.permissions.mode === 'plan' ? 'read-only'
2064
2066
  : ctx.permissions.mode === 'acceptEdits' ? 'auto-edit'
2065
- : ctx.permissions.mode === 'bypassPermissions' ? 'off' : 'ask';
2067
+ : ctx.permissions.mode === 'bypassPermissions' ? 'bypass' : 'ask';
2066
2068
  ctx.tui.print(` ${c.dim(`jaitus v${VERSION}`)}`);
2067
2069
  ctx.tui.print(` ${c.dim(`model ${ctx.model} · approval ${modeApproval} · ${ctx.flags.cwd}`)}`);
2068
2070
  return null;