jeopi-cli 16.4.2 → 16.4.3

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 (125) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/cli.js +3179 -3140
  3. package/dist/types/advisor/config.d.ts +4 -3
  4. package/dist/types/cli/args.d.ts +2 -0
  5. package/dist/types/cli/update-cli.d.ts +4 -1
  6. package/dist/types/cli/usage-error.d.ts +4 -0
  7. package/dist/types/commit/agentic/agent.d.ts +1 -0
  8. package/dist/types/config/model-resolver.d.ts +7 -1
  9. package/dist/types/config/settings-schema.d.ts +3 -3
  10. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  11. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  12. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  13. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  14. package/dist/types/internal-urls/memory-protocol.d.ts +6 -6
  15. package/dist/types/internal-urls/types.d.ts +7 -0
  16. package/dist/types/irc/bus.d.ts +4 -0
  17. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  18. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  19. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  20. package/dist/types/modes/components/welcome.d.ts +4 -0
  21. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  22. package/dist/types/modes/orchestrate.d.ts +1 -1
  23. package/dist/types/modes/ultrathink.d.ts +1 -1
  24. package/dist/types/modes/workflow.d.ts +1 -1
  25. package/dist/types/session/agent-session.d.ts +5 -0
  26. package/dist/types/session/auth-broker-config.d.ts +2 -2
  27. package/dist/types/tools/bash-skill-urls.d.ts +3 -2
  28. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  29. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  30. package/dist/types/tools/browser/launch.d.ts +1 -0
  31. package/dist/types/tools/browser/run-cancellation.d.ts +17 -2
  32. package/dist/types/tools/browser/run-output.d.ts +25 -0
  33. package/dist/types/tools/browser/tab-worker.d.ts +22 -0
  34. package/dist/types/utils/changelog.d.ts +35 -1
  35. package/package.json +12 -12
  36. package/src/advisor/__tests__/advisor.test.ts +13 -0
  37. package/src/advisor/__tests__/config.test.ts +36 -0
  38. package/src/advisor/config.ts +11 -8
  39. package/src/autolearn/controller.ts +14 -2
  40. package/src/cli/args.ts +12 -0
  41. package/src/cli/flag-tables.ts +21 -7
  42. package/src/cli/update-cli.ts +63 -13
  43. package/src/cli/usage-error.ts +7 -0
  44. package/src/commands/acp.ts +11 -2
  45. package/src/commands/launch.ts +12 -3
  46. package/src/commit/agentic/agent.ts +4 -0
  47. package/src/commit/agentic/index.ts +46 -21
  48. package/src/config/model-resolver.ts +24 -6
  49. package/src/config/settings-schema.ts +3 -3
  50. package/src/edit/index.ts +10 -76
  51. package/src/edit/renderer.ts +13 -10
  52. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  53. package/src/eval/__tests__/bridge-timeout.test.ts +60 -0
  54. package/src/eval/executor-base.ts +84 -7
  55. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  56. package/src/eval/py/tool-bridge.ts +13 -14
  57. package/src/exec/non-interactive-env.ts +0 -1
  58. package/src/export/ttsr.ts +0 -3
  59. package/src/extensibility/custom-tools/loader.ts +70 -37
  60. package/src/extensibility/custom-tools/types.ts +3 -0
  61. package/src/extensibility/extensions/runner.ts +3 -0
  62. package/src/extensibility/extensions/types.ts +3 -0
  63. package/src/extensibility/plugins/manager.ts +21 -0
  64. package/src/internal-urls/docs-index.generated.txt +1 -1
  65. package/src/internal-urls/memory-protocol.ts +13 -8
  66. package/src/internal-urls/skill-protocol.ts +1 -1
  67. package/src/internal-urls/types.ts +7 -0
  68. package/src/irc/bus.ts +48 -18
  69. package/src/lsp/client.ts +7 -1
  70. package/src/lsp/deferred-diagnostics.ts +66 -0
  71. package/src/main.ts +13 -13
  72. package/src/mcp/tool-bridge.ts +57 -6
  73. package/src/mcp/transports/stdio.test.ts +45 -1
  74. package/src/mcp/transports/stdio.ts +6 -3
  75. package/src/modes/acp/acp-agent.ts +73 -2
  76. package/src/modes/acp/acp-event-mapper.ts +5 -0
  77. package/src/modes/acp/acp-mode.ts +11 -0
  78. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  79. package/src/modes/components/advisor-config.ts +15 -7
  80. package/src/modes/components/move-overlay.ts +2 -3
  81. package/src/modes/components/plan-review-overlay.ts +7 -0
  82. package/src/modes/components/tips.txt +1 -1
  83. package/src/modes/components/welcome.ts +13 -14
  84. package/src/modes/controllers/command-controller.ts +8 -10
  85. package/src/modes/controllers/event-controller.ts +17 -30
  86. package/src/modes/controllers/input-controller.ts +9 -6
  87. package/src/modes/controllers/selector-controller.ts +3 -2
  88. package/src/modes/controllers/tan-command-controller.ts +40 -1
  89. package/src/modes/interactive-mode.ts +13 -0
  90. package/src/modes/magic-keyword-boundary.ts +23 -0
  91. package/src/modes/orchestrate.ts +6 -5
  92. package/src/modes/print-mode.ts +9 -0
  93. package/src/modes/ultrathink.ts +6 -5
  94. package/src/modes/workflow.ts +5 -4
  95. package/src/prompts/system/eager-task.md +2 -2
  96. package/src/prompts/system/tan-context-switch.md +17 -0
  97. package/src/prompts/tools/browser.md +5 -5
  98. package/src/sdk.ts +3 -0
  99. package/src/session/agent-session.ts +172 -25
  100. package/src/session/auth-broker-config.ts +4 -4
  101. package/src/session/snapcompact-inline.ts +3 -19
  102. package/src/session/streaming-output.ts +40 -12
  103. package/src/slash-commands/builtin-registry.ts +8 -8
  104. package/src/task/executor.ts +8 -0
  105. package/src/tools/bash-skill-urls.ts +7 -4
  106. package/src/tools/bash.ts +30 -22
  107. package/src/tools/browser/cmux/cmux-tab.ts +49 -50
  108. package/src/tools/browser/cmux/rpc.ts +50 -0
  109. package/src/tools/browser/launch.ts +8 -4
  110. package/src/tools/browser/run-cancellation.ts +44 -5
  111. package/src/tools/browser/run-output.ts +76 -0
  112. package/src/tools/browser/tab-worker.ts +280 -133
  113. package/src/tools/eval-render.ts +16 -14
  114. package/src/tools/glob.ts +20 -6
  115. package/src/tools/grep.ts +1 -0
  116. package/src/tools/image-gen.ts +3 -4
  117. package/src/tools/irc.ts +27 -13
  118. package/src/tools/read.ts +14 -7
  119. package/src/tools/write.ts +22 -5
  120. package/src/tools/yield.ts +29 -1
  121. package/src/utils/changelog.ts +106 -9
  122. package/src/utils/git.ts +0 -1
  123. package/src/utils/title-generator.ts +68 -7
  124. package/src/web/search/index.ts +14 -6
  125. package/src/web/search/providers/perplexity.ts +18 -2
@@ -414,37 +414,39 @@ function formatStatusEventExpanded(event: EvalStatusEvent, theme: Theme): string
414
414
  return lines;
415
415
  }
416
416
 
417
- /** Render status events as tree lines. */
417
+ /**
418
+ * Render status events as tree lines. Shows a tail window (newest events are
419
+ * the live edge for `log()` progress loops) behind an "… N earlier" marker,
420
+ * matching the code/output tail-window convention. Collapsed keeps a small
421
+ * fixed window; expanded widens to the viewport-sized preview window.
422
+ */
418
423
  function renderStatusEvents(events: EvalStatusEvent[], theme: Theme, expanded: boolean): string[] {
419
424
  if (events.length === 0) return [];
420
425
 
421
- const maxCollapsed = 3;
422
- const maxExpanded = 10;
423
- const displayCount = expanded ? Math.min(events.length, maxExpanded) : Math.min(events.length, maxCollapsed);
426
+ const max = expanded ? Math.max(10, previewWindowRows()) : 3;
427
+ const hidden = Math.max(0, events.length - max);
428
+ const visible = hidden > 0 ? events.slice(hidden) : events;
424
429
 
425
430
  const lines: string[] = [];
426
- for (let i = 0; i < displayCount; i++) {
427
- const isLast = i === displayCount - 1 && (expanded || events.length <= maxCollapsed);
431
+ if (hidden > 0) {
432
+ lines.push(`${theme.fg("dim", theme.tree.branch)} ${theme.fg("dim", `… ${hidden} earlier`)}`);
433
+ }
434
+ for (let i = 0; i < visible.length; i++) {
435
+ const isLast = i === visible.length - 1;
428
436
  const branch = isLast ? theme.tree.last : theme.tree.branch;
429
437
 
430
438
  if (expanded) {
431
- const eventLines = formatStatusEventExpanded(events[i], theme);
439
+ const eventLines = formatStatusEventExpanded(visible[i], theme);
432
440
  lines.push(`${theme.fg("dim", branch)} ${eventLines[0]}`);
433
441
  const continueBranch = isLast ? " " : `${theme.tree.vertical} `;
434
442
  for (let j = 1; j < eventLines.length; j++) {
435
443
  lines.push(`${theme.fg("dim", continueBranch)}${eventLines[j]}`);
436
444
  }
437
445
  } else {
438
- lines.push(`${theme.fg("dim", branch)} ${formatStatusEvent(events[i], theme)}`);
446
+ lines.push(`${theme.fg("dim", branch)} ${formatStatusEvent(visible[i], theme)}`);
439
447
  }
440
448
  }
441
449
 
442
- if (!expanded && events.length > maxCollapsed) {
443
- lines.push(`${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", `… ${events.length - maxCollapsed} more`)}`);
444
- } else if (expanded && events.length > maxExpanded) {
445
- lines.push(`${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", `… ${events.length - maxExpanded} more`)}`);
446
- }
447
-
448
450
  return lines;
449
451
  }
450
452
 
package/src/tools/glob.ts CHANGED
@@ -173,6 +173,7 @@ export class GlobTool implements AgentTool<typeof findSchema, GlobToolDetails> {
173
173
  signal,
174
174
  localProtocolOptions: this.session.localProtocolOptions,
175
175
  skills: this.session.skills,
176
+ pathOnly: true,
176
177
  });
177
178
  if (!resource.sourcePath) {
178
179
  throw new ToolError(`Cannot find internal URL without a backing file: ${rawPattern}`);
@@ -247,7 +248,7 @@ export class GlobTool implements AgentTool<typeof findSchema, GlobToolDetails> {
247
248
 
248
249
  const buildResult = (
249
250
  files: string[],
250
- opts?: { notice?: string; forceTruncated?: boolean },
251
+ opts?: { notice?: string; forceTruncated?: boolean; timedOut?: boolean },
251
252
  ): AgentToolResult<GlobToolDetails> => {
252
253
  const notice = opts?.notice;
253
254
  const forceTruncated = opts?.forceTruncated ?? false;
@@ -260,7 +261,10 @@ export class GlobTool implements AgentTool<typeof findSchema, GlobToolDetails> {
260
261
  cwd: this.session.cwd,
261
262
  missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
262
263
  };
263
- const parts = ["No files found matching pattern"];
264
+ // A timed-out empty result is an incomplete scan, not a verified
265
+ // absence — never emit the definitive "No files found" claim next
266
+ // to a timeout notice (the two statements contradict each other).
267
+ const parts = opts?.timedOut ? [] : ["No files found matching pattern"];
264
268
  if (notice) parts.push(notice);
265
269
  if (missingPathsNote) parts.push(missingPathsNote);
266
270
  // Zero results is useless regardless of notices: the follow-up
@@ -441,8 +445,15 @@ export class GlobTool implements AgentTool<typeof findSchema, GlobToolDetails> {
441
445
  partial.sort((a, b) => b.m - a.m);
442
446
  const sortedPaths = partial.map(entry => entry.p);
443
447
  const seconds = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}` : (timeoutMs / 1000).toFixed(1);
444
- const notice = `glob timed out after ${seconds}s; returning ${sortedPaths.length} partial matches — narrow the pattern instead of retrying blindly`;
445
- return buildResult(sortedPaths, { notice, forceTruncated: true });
448
+ // Walk cost tracks directory-tree size, not pattern specificity: a
449
+ // mtime-ranked scan cannot early-exit, so a "narrow" pattern over a
450
+ // huge tree still times out. Say so instead of implying the pattern
451
+ // was too broad.
452
+ const notice =
453
+ sortedPaths.length > 0
454
+ ? `glob timed out after ${seconds}s; returning ${sortedPaths.length} partial matches — results are incomplete, scope to a deeper directory instead of retrying blindly`
455
+ : `Glob timed out after ${seconds}s before finding any matches — the scan is incomplete, NOT proof of absence. The walk is bounded by directory size, not pattern width; scope the search to a deeper directory (e.g. \`sub/dir/*.ext\` instead of \`*.ext\` at a huge root).`;
456
+ return buildResult(sortedPaths, { notice, forceTruncated: true, timedOut: true });
446
457
  }
447
458
 
448
459
  // Merge per-target results: native glob already ranks each target's own
@@ -568,17 +579,20 @@ export const globToolRenderer = {
568
579
  missingPaths.length > 0 ? uiTheme.fg("warning", `skipped missing: ${missingPaths.join(", ")}`) : undefined;
569
580
 
570
581
  if (fileCount === 0) {
582
+ // `truncated` on an empty result means the scan timed out mid-walk —
583
+ // render "incomplete", not a definitive "No files found".
584
+ const emptyLabel = truncated ? "No matches before timeout (scan incomplete)" : "No files found";
571
585
  const header = renderStatusLine(
572
586
  {
573
587
  icon: "warning",
574
588
  title: "Glob",
575
589
  titleColor: "toolTitle",
576
590
  description: formatGlobRenderPaths(args?.paths),
577
- meta: ["0 files"],
591
+ meta: truncated ? ["0 files", uiTheme.fg("warning", "timed out")] : ["0 files"],
578
592
  },
579
593
  uiTheme,
580
594
  );
581
- const lines = [header, formatEmptyMessage("No files found", uiTheme)];
595
+ const lines = [header, formatEmptyMessage(emptyLabel, uiTheme)];
582
596
  if (missingNote) lines.push(missingNote);
583
597
  return new Text(lines.join("\n"), 1, 0);
584
598
  }
package/src/tools/grep.ts CHANGED
@@ -766,6 +766,7 @@ async function resolveInternalSearchInputs(opts: {
766
766
  localProtocolOptions: opts.localProtocolOptions,
767
767
  skills: opts.skills,
768
768
  skipDirectoryListing: true,
769
+ pathOnly: true,
769
770
  };
770
771
 
771
772
  for (let idx = 0; idx < paths.length; idx++) {
@@ -822,11 +822,10 @@ function buildOpenAIImageHeaders(model: Model, apiKey: string, sessionId: string
822
822
 
823
823
  if (model.api === "openai-codex-responses" || model.provider === "openai-codex") {
824
824
  const accountId = getCodexAccountId(apiKey);
825
- if (!accountId) {
826
- throw new Error("Failed to extract accountId from OpenAI Codex token");
827
- }
828
825
  headers.delete("x-api-key");
829
- headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId);
826
+ if (accountId) {
827
+ headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId);
828
+ }
830
829
  headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES);
831
830
  headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
832
831
  headers.set("User-Agent", `pi/${packageJson.version} (${os.platform()} ${os.release()}; ${os.arch()})`);
package/src/tools/irc.ts CHANGED
@@ -171,7 +171,7 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
171
171
  case "send":
172
172
  return this.#executeSend(registry, senderId, params, signal);
173
173
  case "wait":
174
- return this.#executeWait(senderId, params, signal);
174
+ return this.#executeWait(registry, senderId, params, signal);
175
175
  case "inbox":
176
176
  return this.#executeInbox(registry, senderId, params);
177
177
  default:
@@ -367,23 +367,37 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
367
367
  }
368
368
  }
369
369
 
370
- async #executeWait(senderId: string, params: IrcParams, signal?: AbortSignal): Promise<AgentToolResult<IrcDetails>> {
370
+ async #executeWait(
371
+ registry: AgentRegistry,
372
+ senderId: string,
373
+ params: IrcParams,
374
+ signal?: AbortSignal,
375
+ ): Promise<AgentToolResult<IrcDetails>> {
371
376
  const from = params.from?.trim() || undefined;
372
377
  const timeoutMs = this.#resolveTimeoutMs(params);
373
- const waited = await IrcBus.global().wait(senderId, { from }, timeoutMs, signal);
374
- if (!waited) {
375
- const filterNote = from ? ` from ${from}` : "";
378
+ try {
379
+ const waited = await IrcBus.global().wait(senderId, { from }, timeoutMs, signal, {
380
+ liveness: { registry, senderId },
381
+ });
382
+ if (!waited) {
383
+ const filterNote = from ? ` from ${from}` : "";
384
+ return {
385
+ content: [{ type: "text", text: `No message${filterNote} within ${formatDuration(timeoutMs)}.` }],
386
+ details: { op: "wait", from: senderId, waited: null },
387
+ // A clean wait timeout carries no information once consumed.
388
+ useless: true,
389
+ };
390
+ }
376
391
  return {
377
- content: [{ type: "text", text: `No message${filterNote} within ${formatDuration(timeoutMs)}.` }],
378
- details: { op: "wait", from: senderId, waited: null },
379
- // A clean wait timeout carries no information once consumed.
380
- useless: true,
392
+ content: [{ type: "text", text: formatIncoming(waited) }],
393
+ details: { op: "wait", from: senderId, waited },
381
394
  };
395
+ } catch (error) {
396
+ if (signal?.aborted) {
397
+ throw error;
398
+ }
399
+ return errorResult(error instanceof Error ? error.message : String(error), { op: "wait", from: senderId });
382
400
  }
383
- return {
384
- content: [{ type: "text", text: formatIncoming(waited) }],
385
- details: { op: "wait", from: senderId, waited },
386
- };
387
401
  }
388
402
 
389
403
  #executeInbox(registry: AgentRegistry, senderId: string, params: IrcParams): AgentToolResult<IrcDetails> {
package/src/tools/read.ts CHANGED
@@ -1251,13 +1251,17 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1251
1251
  const ignoreResultLimits = options.ignoreResultLimits ?? false;
1252
1252
  const requestedEnd = limit !== undefined ? Math.min(requestedStart + limit, allLines.length) : allLines.length;
1253
1253
  // Expand only on sides the user actually constrained: leading context
1254
- // when offset>1, trailing context when a finite limit was set.
1254
+ // when offset>1, trailing context when a finite limit was set. Raw mode
1255
+ // never expands — without line numbers the padding is indistinguishable
1256
+ // from requested content, so `raw:31-31` must return line 31 and nothing
1257
+ // else (verbatim-extraction contract).
1258
+ const rawDisplay = options.raw === true;
1255
1259
  const expanded = expandRangeWithContext(
1256
1260
  requestedStart,
1257
1261
  requestedEnd,
1258
1262
  allLines.length,
1259
- offset !== undefined && offset > 1,
1260
- limit !== undefined,
1263
+ !rawDisplay && offset !== undefined && offset > 1,
1264
+ !rawDisplay && limit !== undefined,
1261
1265
  );
1262
1266
  const startLine = expanded.startLine;
1263
1267
  const endLineExpanded = expanded.endLine;
@@ -2415,10 +2419,14 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2415
2419
  }
2416
2420
 
2417
2421
  // User-requested 0-indexed range start. Lines BEFORE this become
2418
- // leading context (added below if offset is explicit).
2422
+ // leading context (added below if offset is explicit). Raw mode
2423
+ // never adds context: without line numbers the padding is
2424
+ // indistinguishable from requested content, so `raw:31-31` must
2425
+ // return line 31 and nothing else.
2426
+ const rawSelector = isRawSelector(parsed);
2419
2427
  const requestedStart = offset ? Math.max(0, offset - 1) : 0;
2420
- const expandStart = offset !== undefined && offset > 1;
2421
- const expandEnd = limit !== undefined;
2428
+ const expandStart = !rawSelector && offset !== undefined && offset > 1;
2429
+ const expandEnd = !rawSelector && limit !== undefined;
2422
2430
  const leadingContext = expandStart ? Math.min(requestedStart, RANGE_LEADING_CONTEXT_LINES) : 0;
2423
2431
  const trailingContext = expandEnd ? RANGE_TRAILING_CONTEXT_LINES : 0;
2424
2432
  const startLine = requestedStart - leadingContext;
@@ -2469,7 +2477,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2469
2477
  // verbatim bytes for paste-back-into-tool workflows. Total byte/line
2470
2478
  // counts in `truncation` keep reflecting the source, not the trimmed
2471
2479
  // view — column truncation surfaces separately via `.limits()`.
2472
- const rawSelector = isRawSelector(parsed);
2473
2480
  const maxColumns = resolveOutputMaxColumns(this.session.settings);
2474
2481
  // Column truncation is display-only. `collectedLines` MUST stay
2475
2482
  // byte-for-byte with the on-disk content so the snapshot recorded
@@ -13,6 +13,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
13
  import { InternalUrlRouter } from "../internal-urls";
14
14
  import { parseInternalUrl } from "../internal-urls/parse";
15
15
  import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "../lsp";
16
+ import { DeferredDiagnostics } from "../lsp/deferred-diagnostics";
16
17
  import { getDiagnosticsLedger } from "../lsp/diagnostics-ledger";
17
18
  import { getLanguageFromPath, highlightCode, type Theme } from "../modes/theme/theme";
18
19
  import writeDescription from "../prompts/tools/write.md" with { type: "text" };
@@ -317,12 +318,15 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
317
318
  }
318
319
 
319
320
  readonly #writethrough: WritethroughCallback;
321
+ readonly #deferredDiagnostics: DeferredDiagnostics | undefined;
320
322
 
321
323
  constructor(private readonly session: ToolSession) {
322
324
  const enableLsp = session.enableLsp ?? true;
323
325
  const enableFormat = enableLsp && session.settings.get("lsp.formatOnWrite");
324
326
  const enableDiagnostics = enableLsp && session.settings.get("lsp.diagnosticsOnWrite");
325
327
  const dedup = enableDiagnostics && session.settings.get("lsp.diagnosticsDeduplicate");
328
+ this.#deferredDiagnostics =
329
+ enableDiagnostics && session.queueDeferredDiagnostics ? new DeferredDiagnostics(session, dedup) : undefined;
326
330
  this.#writethrough = withPathWriteLock(
327
331
  enableLsp
328
332
  ? createLspWritethrough(session.cwd, {
@@ -826,9 +830,13 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
826
830
  }
827
831
  return { content: [{ type: "text", text: resultText }], details: {} };
828
832
  }
829
- // Schemes without a `write` hook fall through to existing logic
830
- // (local:// resolves to a backing file via plan-mode-guard) or are
831
- // rejected downstream when no backing file exists.
833
+ if (scheme !== "local") {
834
+ throw new ToolError(
835
+ `${scheme}:// URLs are read-only for write; use the protocol-specific tool for mutations.`,
836
+ );
837
+ }
838
+ // local:// is backed by the session-local artifact sandbox and is
839
+ // resolved by resolvePlanPath below so write/read share the same root.
832
840
  }
833
841
 
834
842
  const conflictUri = parseConflictUri(path);
@@ -927,9 +935,18 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
927
935
  };
928
936
  }
929
937
 
930
- const diagnostics = await this.#writethrough(absolutePath, cleanContent, signal, undefined, batchRequest);
938
+ const diagnostics = await this.#writethrough(
939
+ absolutePath,
940
+ cleanContent,
941
+ signal,
942
+ undefined,
943
+ batchRequest,
944
+ dst => this.#deferredDiagnostics?.begin(dst),
945
+ );
931
946
  invalidateFsScanAfterWrite(absolutePath);
932
- this.session.bumpFileMutationVersion?.(absolutePath);
947
+ if (!this.#deferredDiagnostics || batchRequest?.flush === false) {
948
+ this.session.bumpFileMutationVersion?.(absolutePath);
949
+ }
933
950
  const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, cleanContent);
934
951
 
935
952
  const header = maybeWriteSnapshotHeader(this.session, absolutePath, cleanContent);
@@ -198,6 +198,14 @@ function wrapYieldParameters(dataSchema: Record<string, unknown>): Record<string
198
198
  */
199
199
  const MAX_SCHEMA_RETRIES = 3;
200
200
 
201
+ /**
202
+ * Max consecutive untyped empty-result submissions before the yield tool fails
203
+ * the child explicitly. Some weak tool callers can acknowledge the required
204
+ * wrapper in prose while repeatedly sending `{ result: {} }`; without a hard
205
+ * stop the parent waits forever.
206
+ */
207
+ const MAX_EMPTY_RESULT_RETRIES = 3;
208
+
201
209
  export class YieldTool implements AgentTool<TSchema, YieldDetails> {
202
210
  readonly name = "yield";
203
211
  readonly approval = "read" as const;
@@ -217,6 +225,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
217
225
  #knownSectionLabels: readonly string[] = [];
218
226
  #isKnownSection?: (label: string) => boolean;
219
227
  #schemaValidationFailures = 0;
228
+ #emptyResultFailures = 0;
220
229
 
221
230
  constructor(session: ToolSession) {
222
231
  let validate: ((value: unknown) => JsonSchemaValidationResult) | undefined;
@@ -322,8 +331,26 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
322
331
  throw new Error("result cannot contain both data and error");
323
332
  }
324
333
  if (errorMessage === undefined && data === undefined && yieldType === undefined) {
334
+ this.#emptyResultFailures++;
335
+ if (this.#emptyResultFailures > MAX_EMPTY_RESULT_RETRIES) {
336
+ const attemptCount = this.#emptyResultFailures;
337
+ this.#emptyResultFailures = 0;
338
+ const error =
339
+ `yield result stayed empty after ${attemptCount} consecutive attempt(s); aborting child instead of retrying forever. ` +
340
+ 'Submit success as `{ "result": { "data": <your output> } }` or failure as `{ "result": { "error": "message" } }`.';
341
+ return {
342
+ content: [{ type: "text", text: `Task aborted: ${error}` }],
343
+ details: {
344
+ data: undefined,
345
+ status: "aborted",
346
+ error,
347
+ type: yieldType,
348
+ },
349
+ };
350
+ }
351
+ const remaining = MAX_EMPTY_RESULT_RETRIES - this.#emptyResultFailures;
325
352
  throw new Error(
326
- 'result must contain either `data` or `error`. Use `{result: {data: <your output>}}` for success or `{result: {error: "message"}}` for failure.',
353
+ `result must contain either \`data\` or \`error\`. Use \`{result: {data: <your output>}}\` for success or \`{result: {error: "message"}}\` for failure. Empty untyped result retries remaining before abort: ${remaining}.`,
327
354
  );
328
355
  }
329
356
 
@@ -370,6 +397,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
370
397
  }
371
398
  }
372
399
 
400
+ this.#emptyResultFailures = 0;
373
401
  const responseText =
374
402
  status === "aborted"
375
403
  ? `Task aborted: ${errorMessage}`
@@ -7,6 +7,27 @@ export interface ChangelogEntry {
7
7
  content: string;
8
8
  }
9
9
 
10
+ /** Number of changelog releases shown by automatic and default recent views. */
11
+ export const RECENT_CHANGELOG_ENTRY_LIMIT = 3;
12
+ /** Maximum Markdown source bytes allowed in automatic startup release notes. */
13
+ export const STARTUP_CHANGELOG_MAX_BYTES = 64 * 1024;
14
+ /** Hint appended when automatic startup release notes are truncated. */
15
+ export const STARTUP_CHANGELOG_FULL_HINT = "Use `/changelog full` to view the complete changelog.";
16
+
17
+ /** Markdown generated from selected changelog entries and whether it hit a size cap. */
18
+ export interface RenderedChangelog {
19
+ markdown: string;
20
+ truncated: boolean;
21
+ }
22
+
23
+ /** Automatic startup changelog decision, including whether the marker should advance. */
24
+ export interface StartupChangelogSelection {
25
+ markdown: string | undefined;
26
+ persistCurrentVersion: boolean;
27
+ truncated: boolean;
28
+ selectedEntries: number;
29
+ }
30
+
10
31
  /**
11
32
  * Parse changelog entries from the file at `changelogPath`. Scans for `## [x.y.z]`
12
33
  * headings and collects each block until the next heading or EOF.
@@ -87,19 +108,95 @@ export function compareVersions(v1: ChangelogEntry, v2: ChangelogEntry): number
87
108
  }
88
109
 
89
110
  /**
90
- * Get entries newer than lastVersion
111
+ * Parse an omp changelog marker version into comparable parts.
91
112
  */
92
- export function getNewEntries(entries: ChangelogEntry[], lastVersion: string): ChangelogEntry[] {
93
- // Parse lastVersion
94
- const parts = lastVersion.split(".").map(Number);
95
- const last: ChangelogEntry = {
96
- major: parts[0] || 0,
97
- minor: parts[1] || 0,
98
- patch: parts[2] || 0,
113
+ export function parseChangelogVersion(version: string | undefined): ChangelogEntry | undefined {
114
+ const match = version?.match(/^(\d+)\.(\d+)\.(\d+)$/);
115
+ if (!match) {
116
+ return undefined;
117
+ }
118
+
119
+ return {
120
+ major: Number.parseInt(match[1], 10),
121
+ minor: Number.parseInt(match[2], 10),
122
+ patch: Number.parseInt(match[3], 10),
99
123
  content: "",
100
124
  };
125
+ }
126
+
127
+ /**
128
+ * Get entries newer than lastVersion.
129
+ */
130
+ export function getNewEntries(entries: ChangelogEntry[], lastVersion: string): ChangelogEntry[] {
131
+ const parsedLastVersion = parseChangelogVersion(lastVersion);
132
+ if (!parsedLastVersion) {
133
+ return [];
134
+ }
135
+
136
+ return entries.filter(entry => compareVersions(entry, parsedLastVersion) > 0);
137
+ }
101
138
 
102
- return entries.filter(entry => compareVersions(entry, last) > 0);
139
+ /**
140
+ * Render changelog entries oldest-first by default and optionally cap the Markdown source size.
141
+ */
142
+ export function renderChangelogEntries(
143
+ entries: ChangelogEntry[],
144
+ options: { maxBytes?: number; truncationHint?: string; oldestFirst?: boolean } = {},
145
+ ): RenderedChangelog {
146
+ const orderedEntries = options.oldestFirst === false ? entries : [...entries].reverse();
147
+ const markdown = orderedEntries.map(entry => entry.content).join("\n\n");
148
+ if (options.maxBytes === undefined || Buffer.byteLength(markdown) <= options.maxBytes) {
149
+ return { markdown, truncated: false };
150
+ }
151
+
152
+ const suffix = `\n\n…\n\n${options.truncationHint ?? STARTUP_CHANGELOG_FULL_HINT}`;
153
+ let low = 0;
154
+ let high = markdown.length;
155
+ while (low < high) {
156
+ const middle = Math.floor((low + high + 1) / 2);
157
+ if (Buffer.byteLength(markdown.slice(0, middle) + suffix) <= options.maxBytes) {
158
+ low = middle;
159
+ } else {
160
+ high = middle - 1;
161
+ }
162
+ }
163
+
164
+ return { markdown: markdown.slice(0, low) + suffix, truncated: true };
165
+ }
166
+
167
+ /**
168
+ * Select bounded release notes for interactive startup.
169
+ */
170
+ export function selectStartupChangelog(
171
+ entries: ChangelogEntry[],
172
+ lastVersion: string | undefined,
173
+ currentVersion: string,
174
+ ): StartupChangelogSelection {
175
+ const parsedLastVersion = parseChangelogVersion(lastVersion);
176
+ if (!parsedLastVersion) {
177
+ return { markdown: undefined, persistCurrentVersion: true, truncated: false, selectedEntries: 0 };
178
+ }
179
+ const markerVersion = lastVersion ?? "";
180
+ if (markerVersion === currentVersion) {
181
+ return { markdown: undefined, persistCurrentVersion: false, truncated: false, selectedEntries: 0 };
182
+ }
183
+
184
+ const newEntries = getNewEntries(entries, markerVersion).slice(0, RECENT_CHANGELOG_ENTRY_LIMIT);
185
+ if (newEntries.length === 0) {
186
+ return { markdown: undefined, persistCurrentVersion: false, truncated: false, selectedEntries: 0 };
187
+ }
188
+
189
+ const rendered = renderChangelogEntries(newEntries, {
190
+ maxBytes: STARTUP_CHANGELOG_MAX_BYTES,
191
+ truncationHint: STARTUP_CHANGELOG_FULL_HINT,
192
+ oldestFirst: false,
193
+ });
194
+ return {
195
+ markdown: rendered.markdown,
196
+ persistCurrentVersion: true,
197
+ truncated: rendered.truncated,
198
+ selectedEntries: newEntries.length,
199
+ };
103
200
  }
104
201
 
105
202
  // Re-export getChangelogPath from paths.ts for convenience
package/src/utils/git.ts CHANGED
@@ -197,7 +197,6 @@ const GIT_NON_INTERACTIVE_ENV = {
197
197
  GIT_ASKPASS: "true",
198
198
  GIT_EDITOR: "true",
199
199
  GIT_TERMINAL_PROMPT: "0",
200
- GPG_TTY: "not a tty",
201
200
  SSH_ASKPASS: "/usr/bin/false",
202
201
  } satisfies Record<string, string>;
203
202
  const GH_NON_INTERACTIVE_ENV = {
@@ -4,6 +4,7 @@
4
4
  import * as path from "node:path";
5
5
 
6
6
  import { type Api, type AssistantMessage, completeSimple, type Model, type Tool } from "jeopi-ai";
7
+ import { StreamMarkupHealing } from "jeopi-ai/utils/stream-markup-healing";
7
8
  import { isTerminalHeadless, logger, prompt } from "jeopi-utils";
8
9
  import type { ModelRegistry } from "../config/model-registry";
9
10
 
@@ -45,7 +46,12 @@ const setTitleTool: Tool = {
45
46
  };
46
47
 
47
48
  /** Matches the title a tool-choice-less model wraps in `<title>...</title>`. */
48
- const TITLE_MARKER_RE = /<title>([\s\S]*?)<\/title>/i;
49
+ const TITLE_MARKER_GLOBAL_RE = /<title>([\s\S]*?)<\/title>/gi;
50
+ const TITLE_VISIBILITY_SENTINEL = "\uE000jeopi-title-visible\uE000";
51
+ const THINKING_TAG_ENVELOPE_RE = /<(think|thinking|reasoning)>\s*[\s\S]*?<\/\1>/gi;
52
+ const THINKING_FENCE_ENVELOPE_RE = /```(?:thinking|reasoning)\b[\s\S]*?```/gi;
53
+ const LEADING_THINKING_TAG_RE = /^\s*<(think|thinking|reasoning)>\s*[\s\S]*?<\/\1>\s*/i;
54
+ const LEADING_THINKING_FENCE_RE = /^\s*```(?:thinking|reasoning)\b[\s\S]*?```\s*/i;
49
55
 
50
56
  /**
51
57
  * Whether the model honors a forced `tool_choice` so the `set_title` tool can be
@@ -289,12 +295,67 @@ function extractGeneratedTitle(contentBlocks: AssistantMessage["content"]): stri
289
295
  }
290
296
  }
291
297
  // Tool-choice-less models are asked to wrap the title in <title>...</title>,
292
- // but stay lenient: prefer the marker when the model closed it, otherwise
293
- // accept a plain sentence after stripping any stray/unclosed tag fragment
294
- // (e.g. output truncated before the closing tag).
295
- const marker = TITLE_MARKER_RE.exec(textTitle);
296
- if (marker) return marker[1].trim();
297
- return textTitle.replace(/<\/?title>/gi, "").trim();
298
+ // but stay lenient: prefer the first closed title marker in visible text,
299
+ // then fall back to a plain sentence after stripping only known leading
300
+ // leaked thinking envelopes plus any stray/unclosed title tag fragment.
301
+ const markedTitle = extractVisibleMarkedTitle(textTitle);
302
+ if (markedTitle !== undefined) return markedTitle;
303
+ return stripLeadingLeakedThinkingMarkup(textTitle)
304
+ .replace(/<\/?title>/gi, "")
305
+ .trim();
306
+ }
307
+
308
+ function extractVisibleMarkedTitle(text: string): string | undefined {
309
+ TITLE_MARKER_GLOBAL_RE.lastIndex = 0;
310
+ let marker: RegExpExecArray | null = TITLE_MARKER_GLOBAL_RE.exec(text);
311
+ while (marker !== null) {
312
+ const title = marker[1];
313
+ if (title !== undefined && isVisibleTitleMarker(text, marker.index)) return title.trim();
314
+ marker = TITLE_MARKER_GLOBAL_RE.exec(text);
315
+ }
316
+ return undefined;
317
+ }
318
+
319
+ function isVisibleTitleMarker(text: string, markerIndex: number): boolean {
320
+ if (isInsideKnownThinkingEnvelope(text, markerIndex)) return false;
321
+ return stripLeakedThinkingMarkup(`${text.slice(0, markerIndex)}${TITLE_VISIBILITY_SENTINEL}`).endsWith(
322
+ TITLE_VISIBILITY_SENTINEL,
323
+ );
324
+ }
325
+
326
+ function isInsideKnownThinkingEnvelope(text: string, index: number): boolean {
327
+ return (
328
+ isInsideEnvelopeMatchedBy(THINKING_TAG_ENVELOPE_RE, text, index) ||
329
+ isInsideEnvelopeMatchedBy(THINKING_FENCE_ENVELOPE_RE, text, index)
330
+ );
331
+ }
332
+
333
+ function isInsideEnvelopeMatchedBy(pattern: RegExp, text: string, index: number): boolean {
334
+ pattern.lastIndex = 0;
335
+ let marker = pattern.exec(text);
336
+ while (marker !== null) {
337
+ const start = marker.index;
338
+ const end = start + marker[0].length;
339
+ if (index > start && index < end) return true;
340
+ if (start > index) return false;
341
+ marker = pattern.exec(text);
342
+ }
343
+ return false;
344
+ }
345
+
346
+ function stripLeadingLeakedThinkingMarkup(text: string): string {
347
+ let current = text;
348
+ while (true) {
349
+ const withoutTag = current.replace(LEADING_THINKING_TAG_RE, "");
350
+ const withoutFence = withoutTag.replace(LEADING_THINKING_FENCE_RE, "");
351
+ if (withoutFence === current) return current;
352
+ current = withoutFence;
353
+ }
354
+ }
355
+
356
+ function stripLeakedThinkingMarkup(text: string): string {
357
+ const healer = new StreamMarkupHealing({ pattern: "thinking" });
358
+ return healer.feed(text) + healer.flushPending();
298
359
  }
299
360
 
300
361
  /**
@@ -224,12 +224,20 @@ export async function runSearchQuery(
224
224
  params: SearchQueryParams,
225
225
  options: { authStorage?: AuthStorage; sessionId?: string; signal?: AbortSignal } = {},
226
226
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
227
- const authStorage = options.authStorage ?? (await discoverAuthStorage());
228
- return executeSearch("cli-web-search", params, {
229
- authStorage,
230
- sessionId: options.sessionId,
231
- signal: options.signal,
232
- });
227
+ const createdAuthStorage = options.authStorage ? undefined : await discoverAuthStorage();
228
+ const authStorage = options.authStorage ?? createdAuthStorage;
229
+ if (!authStorage) {
230
+ throw new Error("Failed to initialize authentication storage");
231
+ }
232
+ try {
233
+ return await executeSearch("cli-web-search", params, {
234
+ authStorage,
235
+ sessionId: options.sessionId,
236
+ signal: options.signal,
237
+ });
238
+ } finally {
239
+ createdAuthStorage?.close();
240
+ }
233
241
  }
234
242
 
235
243
  /**