jeo-code 0.7.30 → 0.7.32

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 (47) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.ja.md +5 -3
  3. package/README.ko.md +5 -3
  4. package/README.md +5 -3
  5. package/README.zh.md +5 -3
  6. package/package.json +3 -3
  7. package/scripts/install.sh +19 -0
  8. package/scripts/uninstall.sh +2 -2
  9. package/src/agent/AGENTS.md +12 -0
  10. package/src/agent/ast-edit-tool.ts +123 -0
  11. package/src/agent/ast-file-scan.ts +62 -0
  12. package/src/agent/ast-grep-tool.ts +107 -0
  13. package/src/agent/ast-match.ts +241 -0
  14. package/src/agent/compaction.ts +6 -19
  15. package/src/agent/debug-session.ts +435 -0
  16. package/src/agent/debug-tool.ts +89 -0
  17. package/src/agent/engine.ts +103 -18
  18. package/src/agent/goal-tool.ts +57 -0
  19. package/src/agent/irc-tool.ts +71 -0
  20. package/src/agent/job-registry.ts +163 -0
  21. package/src/agent/job-tool.ts +106 -0
  22. package/src/agent/lsp-rename-tool.ts +108 -0
  23. package/src/agent/lsp-tool.ts +177 -0
  24. package/src/agent/memory-graph.ts +6 -1
  25. package/src/agent/memory.ts +73 -52
  26. package/src/agent/session.ts +27 -0
  27. package/src/agent/subagent-registry.ts +40 -4
  28. package/src/agent/subagent-tool.ts +27 -7
  29. package/src/agent/subagents.ts +1 -1
  30. package/src/agent/task-tool.ts +27 -5
  31. package/src/agent/tools.ts +100 -5
  32. package/src/agent/ts-language-service.ts +173 -0
  33. package/src/ai/model-manager.ts +58 -26
  34. package/src/ai/sse.ts +22 -7
  35. package/src/ai/think-tags.ts +10 -1
  36. package/src/commands/deep-interview.ts +49 -1
  37. package/src/commands/launch/agents-slash.ts +489 -0
  38. package/src/commands/launch/flags.ts +27 -9
  39. package/src/commands/launch/model-slash.ts +290 -0
  40. package/src/commands/launch/session-slash.ts +305 -0
  41. package/src/commands/launch/slash-handlers.ts +6 -0
  42. package/src/commands/launch/system-slash.ts +81 -0
  43. package/src/commands/launch/tmux.ts +32 -0
  44. package/src/commands/launch.ts +227 -750
  45. package/src/tui/app.ts +32 -12
  46. package/src/tui/components/evolution.ts +23 -3
  47. package/src/tui/components/width.ts +11 -6
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * `subagent` control tool (#9) — the parent's handle on DETACHED subagents launched
3
3
  * via `task {detached:true}`. Mirrors gjc's `subagent`/`job` control surface, scoped
4
- * to an in-process registry: list, inspect, await (optionally bounded), and cancel.
4
+ * to an in-process registry: list, inspect, await (optionally bounded), cancel, and
5
+ * steer (live peer messaging into a single running subagent).
5
6
  *
6
- * Out of scope here (separate subsystems, not stubbed): live peer messaging (IRC)
7
- * and pause/resume — a step-budget loop has no safe mid-step checkpoint to resume
8
- * from, so those are intentionally absent rather than faked.
7
+ * Out of scope here (a separate subsystem, not stubbed): pause/resume — a step-budget
8
+ * loop has no safe mid-step checkpoint to resume from, so that remains intentionally
9
+ * absent rather than faked. Live peer messaging to/among ALL running subagents (not
10
+ * just one target id) is the `irc` tool, built on the same registry.steer().
9
11
  */
10
12
  import type { ToolHandler } from "./engine";
11
13
  import type { ToolResult } from "./tools";
@@ -13,9 +15,10 @@ import type { SubagentRegistry, SubagentRecord } from "./subagent-registry";
13
15
 
14
16
  /** One-line protocol description appended to the launch system prompt. */
15
17
  export const SUBAGENT_TOOL_PROTOCOL_LINE =
16
- `subagent {action:"list"|"inspect"|"await"|"cancel", ids?, timeoutMs?} — control DETACHED ` +
18
+ `subagent {action:"list"|"inspect"|"await"|"cancel"|"steer", ids?, id?, message?, timeoutMs?} — control DETACHED ` +
17
19
  `subagents started with task{detached:true}. 'await' blocks (optionally up to timeoutMs ms) and ` +
18
- `returns their reports; 'inspect' shows status + result; 'cancel' aborts them. Omit ids to target all running.`;
20
+ `returns their reports; 'inspect' shows status + result; 'cancel' aborts them; 'steer' sends {id, message} ` +
21
+ `to inject a live instruction into that ONE running subagent, picked up before its next step. Omit ids to target all running (list/inspect/await/cancel).`;
19
22
 
20
23
  function elapsed(rec: SubagentRecord): string {
21
24
  const end = rec.finishedAt ?? Date.now();
@@ -84,6 +87,23 @@ export function createSubagentTool(registry: SubagentRegistry): ToolHandler {
84
87
  return { success: true, output: `Cancelled ${recs.length} subagent(s):\n${recs.map(rowLine).join("\n")}` };
85
88
  }
86
89
 
87
- return { success: false, output: "", error: `Unknown subagent action '${action}'. Use list | inspect | await | cancel.` };
90
+ if (action === "steer") {
91
+ const targetIds = Array.isArray(args.id) ? [] : args.id !== undefined ? [String(args.id)] : [];
92
+ const message = typeof args.message === "string" ? args.message : typeof args.text === "string" ? args.text : "";
93
+ if (targetIds.length !== 1) {
94
+ return { success: false, output: "", error: `subagent 'steer' requires exactly one target 'id' (a single string, not 'ids'). Got: ${JSON.stringify(args.id)}.` };
95
+ }
96
+ const id = targetIds[0]!;
97
+ if (!message.trim()) {
98
+ return { success: false, output: "", error: "subagent 'steer' requires a non-empty 'message' (or 'text')." };
99
+ }
100
+ const ok = registry.steer(id, message);
101
+ if (!ok) {
102
+ return { success: false, output: "", error: `Cannot steer '${id}': no running subagent with that id.` };
103
+ }
104
+ return { success: true, output: `Steered ${id}: ${message.trim()}` };
105
+ }
106
+
107
+ return { success: false, output: "", error: `Unknown subagent action '${action}'. Use list | inspect | await | cancel | steer.` };
88
108
  };
89
109
  }
@@ -252,7 +252,7 @@ export function bashCommandAllowed(command: string, prefixes: string[]): boolean
252
252
  */
253
253
  export function subagentToolset(role: SubagentRole): Record<string, ToolHandler> {
254
254
  if (role.readOnly) {
255
- const MUTATING = new Set(["write", "edit", "bash", "mkdir", "delete"]);
255
+ const MUTATING = new Set(["write", "edit", "bash", "mkdir", "delete", "ast_edit", "computer", "lsp_rename", "debug"]);
256
256
  const ro: Record<string, ToolHandler> = {};
257
257
  for (const [name, handler] of Object.entries(DEFAULT_TOOLS)) {
258
258
  if (MUTATING.has(name)) continue;
@@ -78,6 +78,27 @@ const MAX_FANOUT = 4;
78
78
  * parent turn. Split larger efforts into sequential task calls. */
79
79
  const MAX_SERIAL_EXECUTOR = 6;
80
80
 
81
+ /** Minimum distinct, non-filler words a spawn-gate justification must contain.
82
+ * A bare length check (`.length >= 20`) previously accepted any 20-char string
83
+ * ("xxxxxxxxxxxxxxxxxxxx", "asdf asdf asdf asdf"), which defeated the intent of
84
+ * the gate (force the model to actually justify the parallelism, not just pay a
85
+ * cheap toll). This does not attempt real NLP — it only rejects the trivially
86
+ * degenerate cases a model would use to rubber-stamp past the gate. */
87
+ const JUSTIFICATION_MIN_DISTINCT_WORDS = 4;
88
+
89
+ /** True when `text` reads as an actual justification rather than filler padding
90
+ * used only to clear the ≥20-char length bar. */
91
+ function isMeaningfulJustification(text: string): boolean {
92
+ const trimmed = text.trim();
93
+ if (trimmed.length < 20) return false;
94
+ // Reject strings that are one character (or one repeated short token) padded out —
95
+ // e.g. "xxxxxxxxxxxxxxxxxxxx" or "aaaa aaaa aaaa aaaa".
96
+ if (/^(.)\1*$/.test(trimmed.replace(/\s+/g, ""))) return false;
97
+ const words = trimmed.toLowerCase().match(/[a-z0-9']+/g) ?? [];
98
+ const distinct = new Set(words);
99
+ return distinct.size >= JUSTIFICATION_MIN_DISTINCT_WORDS;
100
+ }
101
+
81
102
  /** Broadcast steering hub for a fan-out batch. Each concurrent worker registers
82
103
  * ONCE and then sees every parent steer message exactly once (append-only log +
83
104
  * per-worker cursor), so a mid-batch redirect reaches all running subagents
@@ -320,13 +341,13 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
320
341
  // stays bounded at MAX_FANOUT (read-only) or 1 (mutating) regardless.
321
342
  if (items.length > MAX_FANOUT) {
322
343
  const justification = typeof args.justification === "string" ? args.justification.trim() : "";
323
- if (justification.length < 20) {
344
+ if (!isMeaningfulJustification(justification)) {
324
345
  return {
325
346
  success: false,
326
347
  output: "",
327
348
  error:
328
349
  `Fan-out of ${items.length} tasks exceeds the default gate of ${MAX_FANOUT}. ` +
329
- `Either reduce the batch, or resend with a "justification" string (≥20 chars) explaining why these tasks are independent and must run in one batch.`,
350
+ `Either reduce the batch, or resend with a "justification" string (≥20 chars, ≥${JUSTIFICATION_MIN_DISTINCT_WORDS} distinct words, not filler) explaining why these tasks are independent and must run in one batch.`,
330
351
  };
331
352
  }
332
353
  }
@@ -371,10 +392,11 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
371
392
  }
372
393
  // Detached form (#9): register a background run and return immediately so the
373
394
  // parent can keep working, then list/inspect/await/cancel via the `subagent`
374
- // tool. Steering is not forwarded to a detached run (no single active drainer).
395
+ // tool. Live peer messaging (steer/irc) DOES reach a detached run — it drains
396
+ // its own registry inbox (registry.steerDrainFor(id)) between its own steps.
375
397
  if (args.detached === true && opts.registry) {
376
- const rec = opts.registry.launch(role.id, taskText, signal =>
377
- runOne(role, taskText, ctx(args.context), cwd, { signal }),
398
+ const rec = opts.registry.launch(role.id, taskText, (signal, id) =>
399
+ runOne(role, taskText, ctx(args.context), cwd, { signal, steer: opts.registry!.steerDrainFor(id) }),
378
400
  );
379
401
  return {
380
402
  success: true,
@@ -226,7 +226,7 @@ function excerptForRecovery(content: string, centerLine?: number): string {
226
226
  * Refreshes the snapshot to CURRENT so the immediate retry (model just saw fresh content) passes. */
227
227
  async function staleReadError(absPath: string, filePath: string, verb: string): Promise<ToolResult | null> {
228
228
  const snap = lastReadSnapshots.get(absPath);
229
- if (!snap) return null; // never read → no guard (back-compat; read-first is advisory)
229
+ if (!snap) return null; // never read → no guard here (see the narrow blind-replace guard in editTool)
230
230
  const st = await fs.stat(absPath).catch(() => null);
231
231
  if (!st || !st.isFile()) return null; // deleted/replaced-by-dir: let the op surface its own error
232
232
  if (st.mtimeMs === snap.mtimeMs && st.size === snap.size) return null;
@@ -509,6 +509,24 @@ export async function editTool(
509
509
  error: `Invalid edit range ${startLine}..${endLine}: out of bounds or reversed (file has ${lines.length} lines)`,
510
510
  };
511
511
  }
512
+ // Narrow blind-replace guard: a ≔A..B REPLACE with NO anchors on EITHER
513
+ // bound carries zero content verification (SEARCH/REPLACE self-verifies via
514
+ // the search text matching; an anchored ≔ self-verifies via the hash). If the
515
+ // agent never read this file THIS SESSION, it has nothing to base a blind
516
+ // line-range replace on — reject instead of silently overwriting unseen lines.
517
+ // (Insert/append directives above are unaffected: they don't destroy content.
518
+ // Checked AFTER the bounds check so an out-of-range request still reports as
519
+ // out-of-range rather than being masked by this guard.)
520
+ if (!aStart && !aEnd && !lastReadSnapshots.has(absPath)) {
521
+ return {
522
+ success: false,
523
+ output: "",
524
+ error:
525
+ `Edit rejected: ${filePath} — a line-range replace with no anchors and no prior read this session ` +
526
+ `has nothing to verify against. Read the file first, then retry (or include the hashline anchors ` +
527
+ `from read output, or use a <<<<<<< SEARCH block instead).`,
528
+ };
529
+ }
512
530
  const mismatch = anchorMismatch(s, aStart) ?? anchorMismatch(e, aEnd);
513
531
  if (mismatch) return mismatch;
514
532
  lines.splice(s - 1, e - s + 1, payload);
@@ -599,26 +617,95 @@ export async function editTool(
599
617
  * can cancel() to force the in-flight read to settle — essential when a SIGKILLed
600
618
  * process leaves a backgrounded grandchild holding the pipe's write end open, so it
601
619
  * never hits EOF (proven by scripts/subproc-probe.ts ABANDON mode). An optional
602
- * (caller-throttled) onChunk drives the live output view. */
620
+ * (caller-throttled) onChunk drives the live output view.
621
+ *
622
+ * Memory bound: `bashTool` truncates the final output to MAX_OUTPUT chars anyway, so
623
+ * accumulating past the cap only risked RAM (a chatty command could hold hundreds of
624
+ * MB for the whole timeout window). Once the head is full we stop growing it and keep
625
+ * a small rolling TAIL purely for the live view — the persisted result (head + cut
626
+ * notice) is byte-identical to the old behaviour. onChunk likewise receives a BOUNDED
627
+ * string (head while small, rolling tail once capped) instead of the full cumulative
628
+ * buffer, which made every downstream sanitize/wrap pass O(n²) over stream length. */
629
+ const DRAIN_HEAD_CAP = 100_000 + 4_096; // MAX_OUTPUT + slack so the final cut still fires
630
+ const DRAIN_LIVE_TAIL = 16 * 1024; // matches FRAME_WRAP_TAIL_CHARS — the TUI renders only this window
603
631
  async function drainPipe(
604
632
  r: ReadableStreamDefaultReader<Uint8Array>,
605
633
  onChunk?: (partial: string) => void,
606
634
  ): Promise<string> {
607
635
  const dec = new TextDecoder();
608
636
  let out = "";
637
+ let tail = ""; // rolling live-view window once `out` is capped
638
+ let capped = false;
609
639
  try {
610
640
  for (;;) {
611
641
  const { done, value } = await r.read();
612
642
  if (done) break;
613
- out += dec.decode(value, { stream: true });
643
+ const text = dec.decode(value, { stream: true });
644
+ if (!capped) {
645
+ out += text;
646
+ if (out.length >= DRAIN_HEAD_CAP) {
647
+ capped = true;
648
+ tail = out.slice(-DRAIN_LIVE_TAIL);
649
+ }
650
+ onChunk?.(out);
651
+ } else {
652
+ tail = (tail + text).slice(-DRAIN_LIVE_TAIL);
653
+ onChunk?.(tail);
654
+ }
655
+ }
656
+ const last = dec.decode();
657
+ if (!capped) {
658
+ out += last;
614
659
  onChunk?.(out);
660
+ } else if (last) {
661
+ tail = (tail + last).slice(-DRAIN_LIVE_TAIL);
662
+ onChunk?.(tail);
615
663
  }
616
- out += dec.decode();
617
- onChunk?.(out);
618
664
  } catch { /* cancelled reader surfaces here; return what we have */ }
619
665
  return out;
620
666
  }
621
667
 
668
+ // ── Destructive-bash hard block (applies to EVERY bash call, main agent and
669
+ // subagent alike — including the bundled `executor` role, whose bash is
670
+ // otherwise unconstrained since `bashAllowedPrefixes` is an opt-in allowlist
671
+ // no bundled role sets). This is a narrow denylist for unambiguously
672
+ // catastrophic, effectively-irreversible commands, not a general policy
673
+ // filter — everyday destructive-but-scoped commands (rm -rf node_modules,
674
+ // git reset --hard, force-push) stay unconstrained by design. ──
675
+ function stripLeadingSudo(tokens: string[]): string[] {
676
+ return tokens[0] === "sudo" ? tokens.slice(1) : tokens;
677
+ }
678
+
679
+ /** True for `rm -rf /`, `rm -fr ~`, `sudo rm -Rf /`, etc. — a recursive,
680
+ * forced delete whose target normalizes to the filesystem root or the home
681
+ * directory. Scoped deletes (`rm -rf ./dist`, `rm -rf /tmp/x`) are untouched. */
682
+ function isRootOrHomeWipe(command: string): boolean {
683
+ for (const raw of command.split(/(?:&&|\|\||[;|])/)) {
684
+ const tokens = stripLeadingSudo(raw.trim().split(/\s+/).filter(Boolean));
685
+ if (tokens.length === 0) continue;
686
+ const cmd = tokens[0].replace(/^.*\//, "");
687
+ if (cmd !== "rm") continue;
688
+ const flagTokens = tokens.slice(1).filter(t => t.startsWith("-"));
689
+ const flags = flagTokens.join("");
690
+ if (!/r/i.test(flags) || !/f/i.test(flags)) continue;
691
+ const targets = tokens.slice(1).filter(t => !t.startsWith("-"));
692
+ for (const t of targets) {
693
+ const norm = t.replace(/\/+$/, "");
694
+ if (norm === "" || norm === "/" || norm === "~" || norm === "$HOME" || norm === "${HOME}") return true;
695
+ }
696
+ }
697
+ return false;
698
+ }
699
+
700
+ /** Returns a short human-readable reason when `command` matches a catastrophic,
701
+ * effectively-irreversible pattern; null when it does not. */
702
+ function bashDestructiveReason(command: string): string | null {
703
+ if (isRootOrHomeWipe(command)) return "a recursive force-delete of the filesystem root or home directory (rm -rf / or ~)";
704
+ if (/:\(\)\s*\{\s*:\s*\|\s*:&?\s*;?\s*\}\s*;\s*:/.test(command)) return "a fork-bomb pattern";
705
+ if (/\bdd\b[^\n]*\bof=\/dev\/(disk|[rs]?[hsv]d[a-z]?|nvme\w*|xvd\w*)/i.test(command)) return "a raw disk-device overwrite (dd ... of=/dev/...)";
706
+ if (/\bmkfs(?:\.\w+)?\s+.*\/dev\//i.test(command)) return "a filesystem format on a raw device (mkfs)";
707
+ return null;
708
+ }
622
709
  export async function bashTool(
623
710
  command: string,
624
711
  cwd: string = process.cwd(),
@@ -632,6 +719,14 @@ export async function bashTool(
632
719
  const fx = applyBashFixups(command);
633
720
  command = fx.command;
634
721
  }
722
+ const destructive = bashDestructiveReason(command);
723
+ if (destructive) {
724
+ return {
725
+ success: false,
726
+ output: "",
727
+ error: `bash rejected: this command looks like ${destructive}. If this is genuinely intended, ask the user to run it manually — it is not permitted from the agent.`,
728
+ };
729
+ }
635
730
  try {
636
731
  // The mutation lock is keyed on the PROJECT cwd, not the run subdir.
637
732
  await assertBashAllowed(cwd);
@@ -0,0 +1,173 @@
1
+ /**
2
+ * In-process TypeScript "language service" host (gjc `lsp` parity for TS/JS/TSX/JSX
3
+ * ONLY — no external `typescript-language-server` process, no other languages).
4
+ * Built directly on the `typescript` compiler API's `ts.LanguageService`, which is
5
+ * the exact engine `tsserver`/most TS editor integrations run on top of — so
6
+ * definition/references/hover/rename here match what a real TS language server
7
+ * would report, just without the LSP wire protocol or multi-language support.
8
+ *
9
+ * One `ts.LanguageService` is cached per project root (`cwd`) and reused across
10
+ * calls within a turn. `getScriptVersion` stats the file on every call (no manual
11
+ * invalidation needed) so edits made by `write`/`edit`/`ast_edit` earlier in the
12
+ * same turn are picked up on the next `lsp` call. A file outside the project's
13
+ * `tsconfig.json` "include" set is added to the tracked root set on first use.
14
+ */
15
+ import * as ts from "typescript";
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import { resolveTargetFiles } from "./ast-file-scan";
19
+
20
+ interface CachedService {
21
+ service: ts.LanguageService;
22
+ extraFiles: Set<string>;
23
+ }
24
+
25
+ const serviceCache = new Map<string, CachedService>();
26
+
27
+ function fileVersion(absPath: string): string {
28
+ try {
29
+ const st = fs.statSync(absPath);
30
+ return `${st.mtimeMs}:${st.size}`;
31
+ } catch {
32
+ return "missing";
33
+ }
34
+ }
35
+
36
+ const DEFAULT_COMPILER_OPTIONS: ts.CompilerOptions = {
37
+ target: ts.ScriptTarget.Latest,
38
+ module: ts.ModuleKind.ESNext,
39
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
40
+ jsx: ts.JsxEmit.ReactJSX,
41
+ allowJs: true,
42
+ checkJs: false,
43
+ esModuleInterop: true,
44
+ resolveJsonModule: true,
45
+ strict: false,
46
+ };
47
+
48
+ function loadProjectFiles(cwd: string): { options: ts.CompilerOptions; fileNames: string[] } {
49
+ const configPath = ts.findConfigFile(cwd, ts.sys.fileExists, "tsconfig.json");
50
+ if (configPath) {
51
+ const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
52
+ if (!configFile.error) {
53
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath));
54
+ if (parsed.fileNames.length > 0) return { options: parsed.options, fileNames: parsed.fileNames };
55
+ }
56
+ }
57
+ return { options: DEFAULT_COMPILER_OPTIONS, fileNames: [] };
58
+ }
59
+
60
+ /** Get (or lazily create) the cached `ts.LanguageService` for a project root. */
61
+ async function getCached(cwd: string): Promise<CachedService> {
62
+ const existing = serviceCache.get(cwd);
63
+ if (existing) return existing;
64
+
65
+ const fromConfig = loadProjectFiles(cwd);
66
+ // No tsconfig.json (or it named zero files) — scan the whole project tree for
67
+ // supported TS/JS files instead of starting with an EMPTY root set. Without this,
68
+ // "references"/"rename" starting from a declaration would only ever see the ONE
69
+ // file explicitly queried: TS's own module resolution pulls in files a root file
70
+ // IMPORTS, never the reverse (files that import a root file), so a project with
71
+ // no tsconfig would silently miss every cross-file usage.
72
+ const fileNames = fromConfig.fileNames.length > 0 ? fromConfig.fileNames : await resolveTargetFiles(["**/*"], cwd);
73
+ const options = fromConfig.options;
74
+ const rootFiles = new Set(fileNames);
75
+ const extraFiles = new Set<string>();
76
+
77
+ const host: ts.LanguageServiceHost = {
78
+ getScriptFileNames: () => [...rootFiles, ...extraFiles],
79
+ getScriptVersion: fileVersion,
80
+ getScriptSnapshot: (fileName) => {
81
+ try {
82
+ return ts.ScriptSnapshot.fromString(fs.readFileSync(fileName, "utf-8"));
83
+ } catch {
84
+ return undefined;
85
+ }
86
+ },
87
+ getCurrentDirectory: () => cwd,
88
+ getCompilationSettings: () => options,
89
+ getDefaultLibFileName: (opts) => ts.getDefaultLibFilePath(opts),
90
+ fileExists: ts.sys.fileExists,
91
+ readFile: ts.sys.readFile,
92
+ readDirectory: ts.sys.readDirectory,
93
+ directoryExists: ts.sys.directoryExists,
94
+ getDirectories: ts.sys.getDirectories,
95
+ };
96
+
97
+ const service = ts.createLanguageService(host, ts.createDocumentRegistry());
98
+ const cached: CachedService = { service, extraFiles };
99
+ serviceCache.set(cwd, cached);
100
+ return cached;
101
+ }
102
+
103
+ /** Get the language service for `cwd`, ensuring `absPath` is tracked (added to the
104
+ * root set if the project's tsconfig didn't already include it). */
105
+ export async function languageServiceFor(cwd: string, absPath: string): Promise<ts.LanguageService> {
106
+ const cached = await getCached(cwd);
107
+ if (!cached.extraFiles.has(absPath)) cached.extraFiles.add(absPath);
108
+ return cached.service;
109
+ }
110
+
111
+ /** Drop a cached language service (tests / long-running sessions that need a
112
+ * clean project graph after large structural changes). */
113
+ export function resetLanguageServiceCache(cwd?: string): void {
114
+ if (cwd) serviceCache.delete(cwd);
115
+ else serviceCache.clear();
116
+ }
117
+
118
+ export interface SymbolSelector { text: string; occurrence: number }
119
+
120
+ /** Parse a loose `"name"` or `"name#N"` selector (1-indexed occurrence, default 1). */
121
+ export function parseSymbolSelector(input: string | undefined): SymbolSelector | undefined {
122
+ if (!input) return undefined;
123
+ const m = /^(.*)#(\d+)$/.exec(input);
124
+ if (m) return { text: m[1]!, occurrence: Math.max(1, parseInt(m[2]!, 10)) };
125
+ return { text: input, occurrence: 1 };
126
+ }
127
+
128
+ export type PositionResult = { position: number } | { error: string };
129
+
130
+ /** Resolve a 1-indexed `line` (+ optional symbol substring selector) to an absolute
131
+ * character offset in `fileText`, using the TS compiler's own line map (so CRLF
132
+ * files are handled correctly, unlike naive `split("\n")` offset arithmetic). */
133
+ export function resolvePosition(fileText: string, line1: number, symbol?: SymbolSelector): PositionResult {
134
+ const sf = ts.createSourceFile("__pos__.tsx", fileText, ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX);
135
+ const lineCount = sf.getLineStarts().length;
136
+ if (line1 < 1 || line1 > lineCount) {
137
+ return { error: `line ${line1} is out of range (file has ${lineCount} lines)` };
138
+ }
139
+ const lineStart = sf.getPositionOfLineAndCharacter(line1 - 1, 0);
140
+ const lineEnd = line1 < lineCount ? sf.getPositionOfLineAndCharacter(line1, 0) : fileText.length;
141
+ const lineText = fileText.slice(lineStart, lineEnd).replace(/\r?\n$/, "");
142
+
143
+ if (!symbol) return { position: lineStart };
144
+
145
+ let idx = -1;
146
+ let from = 0;
147
+ for (let i = 0; i < symbol.occurrence; i++) {
148
+ idx = lineText.indexOf(symbol.text, from);
149
+ if (idx === -1) break;
150
+ from = idx + 1;
151
+ }
152
+ if (idx === -1) {
153
+ return { error: `symbol '${symbol.text}' (occurrence ${symbol.occurrence}) not found on line ${line1}: ${lineText.trim().slice(0, 120)}` };
154
+ }
155
+ return { position: lineStart + idx + Math.max(0, Math.floor(symbol.text.length / 2)) };
156
+ }
157
+
158
+ /** One line of a file, 1-indexed, trimmed for display. */
159
+ export function lineTextAt(fileText: string, line1: number): string {
160
+ const lines = fileText.split(/\r?\n/);
161
+ return (lines[line1 - 1] ?? "").trim();
162
+ }
163
+
164
+ /** Convert a 0-indexed TS `position` in `fileText` to a 1-indexed line number. */
165
+ export function lineOfPosition(fileText: string, position: number): number {
166
+ const sf = ts.createSourceFile("__pos__.tsx", fileText, ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX);
167
+ return sf.getLineAndCharacterOfPosition(position).line + 1;
168
+ }
169
+
170
+ /** A short, uniform `path:line: text` result line. */
171
+ export function formatLocation(relPath: string, line1: number, text: string): string {
172
+ return `${relPath}:${line1}: ${text.length > 160 ? text.slice(0, 159) + "…" : text}`;
173
+ }
@@ -521,18 +521,29 @@ export interface StreamIdleOptions {
521
521
  onIdle?: () => void;
522
522
  }
523
523
 
524
- /** `iter.next()`, racing a per-chunk idle watchdog AND (when set) the overall deadline.
525
- * The watchdog re-arms while reasoning activity (idle.lastActivityAt) keeps advancing,
526
- * so a model that streams thinking tokens for longer than idleMs before emitting visible
527
- * text is NOT mistaken for a stalled stream — only a genuinely silent stream aborts. */
528
- async function nextMaybeIdle(iter: AsyncIterator<string>, idle?: StreamIdleOptions): Promise<IteratorResult<string>> {
529
- if (!idle) return iter.next();
530
- const next = iter.next();
531
- // Baseline: this wait began now; reasoning deltas during it move lastActivityAt forward.
532
- const startedAt = Date.now();
533
- const lastActivity = () => Math.max(startedAt, idle.lastActivityAt?.() ?? 0);
524
+ /** Handle returned by {@link idleWatchdog}: the racing promise, its cleanup, and a
525
+ * `touch()` to call after each successfully received chunk. */
526
+ export interface StreamWatchdogHandle {
527
+ promise: Promise<never>;
528
+ cleanup: () => void;
529
+ touch: () => void;
530
+ }
531
+
532
+ /** One watchdog per stream attempt. Reused across `iter.next()` calls so a busy
533
+ * stream does not allocate a Promise+timer pair per yielded chunk.
534
+ *
535
+ * Idle measurement fallback: when the caller has no `lastActivityAt` (the common
536
+ * case — most providers never wire reasoning-progress tracking), idle time MUST be
537
+ * measured against the last moment a chunk actually arrived, not against `Date.now()`
538
+ * re-sampled on every internal re-arm — the latter makes `now - lastAct` collapse to
539
+ * ~0 forever, so the idle timer can never fire and a genuinely stalled stream hangs
540
+ * forever instead of timing out. `touch()` (called by the consumer after each
541
+ * received chunk) advances this fallback baseline; `lastActivityAt`, when provided,
542
+ * always takes precedence (reasoning-progress tracking is more precise). */
543
+ function idleWatchdog(idle: StreamIdleOptions): StreamWatchdogHandle {
534
544
  let timer: ReturnType<typeof setTimeout> | undefined;
535
- const watchdog = new Promise<never>((_, reject) => {
545
+ let lastChunkAt = Date.now();
546
+ const promise = new Promise<never>((_, reject) => {
536
547
  const arm = () => {
537
548
  const now = Date.now();
538
549
  const overallRemaining = idle.deadlineAt !== undefined ? idle.deadlineAt - now : Infinity;
@@ -541,10 +552,10 @@ async function nextMaybeIdle(iter: AsyncIterator<string>, idle?: StreamIdleOptio
541
552
  reject(new Error(`stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted`));
542
553
  return;
543
554
  }
544
- const idleRemaining = idle.idleMs - (now - lastActivity());
555
+ const lastAct = idle.lastActivityAt ? idle.lastActivityAt() : lastChunkAt;
556
+ const idleRemaining = idle.idleMs - (now - lastAct);
545
557
  const wait = Math.min(idleRemaining, overallRemaining);
546
558
  if (wait <= 0) {
547
- // overallRemaining is > 0 here (pre-checked), so wait<=0 ⟺ the idle window lapsed.
548
559
  idle.onIdle?.();
549
560
  reject(new Error(`stream idle for ${idle.idleMs}ms (no chunk) — provider sent no token within the idle window (load or long thinking); retrying. Raise JEO_STREAM_IDLE_MS or lower the thinking level if this persists.`));
550
561
  return;
@@ -553,11 +564,16 @@ async function nextMaybeIdle(iter: AsyncIterator<string>, idle?: StreamIdleOptio
553
564
  };
554
565
  arm();
555
566
  });
556
- try {
557
- return await Promise.race([next, watchdog]);
558
- } finally {
559
- if (timer) clearTimeout(timer);
560
- }
567
+ return { promise, cleanup: () => clearTimeout(timer), touch: () => { lastChunkAt = Date.now(); } };
568
+ }
569
+
570
+ /** `iter.next()`, racing the stream-attempt watchdog AND (when set) the overall deadline.
571
+ * The watchdog re-arms while reasoning activity (idle.lastActivityAt) keeps advancing,
572
+ * so a model that streams thinking tokens for longer than idleMs before emitting visible
573
+ * text is NOT mistaken for a stalled stream — only a genuinely silent stream aborts. */
574
+ async function nextMaybeIdle(iter: AsyncIterator<string>, watchdog?: Promise<never>): Promise<IteratorResult<string>> {
575
+ if (!watchdog) return iter.next();
576
+ return Promise.race([iter.next(), watchdog]);
561
577
  }
562
578
 
563
579
  /** Opt-in overall stream wall-clock from the environment; undefined = off (default). */
@@ -590,14 +606,30 @@ export async function* retryableStream(
590
606
  retry: RetryOptions,
591
607
  idle?: StreamIdleOptions,
592
608
  ): AsyncGenerator<string> {
593
- const { iter, first } = await withRetry(async () => {
594
- const it = makeIter();
595
- const f = await nextMaybeIdle(it, idle);
596
- return { iter: it, first: f };
597
- }, retry);
598
- if (!first.done) {
599
- yield first.value;
600
- for (let n = await nextMaybeIdle(iter, idle); !n.done; n = await nextMaybeIdle(iter, idle)) yield n.value;
609
+ let cleanupWatchdog = () => {};
610
+ let touchWatchdog = () => {};
611
+ try {
612
+ const { iter, first, watchdog } = await withRetry(async () => {
613
+ cleanupWatchdog();
614
+ const it = makeIter();
615
+ const w = idle ? idleWatchdog(idle) : undefined;
616
+ cleanupWatchdog = w?.cleanup ?? (() => {});
617
+ touchWatchdog = w?.touch ?? (() => {});
618
+ const f = await nextMaybeIdle(it, w?.promise);
619
+ if (!f.done) touchWatchdog();
620
+ return { iter: it, first: f, watchdog: w?.promise };
621
+ }, retry);
622
+ if (!first.done) {
623
+ yield first.value;
624
+ for (;;) {
625
+ const n = await nextMaybeIdle(iter, watchdog);
626
+ if (n.done) break;
627
+ touchWatchdog();
628
+ yield n.value;
629
+ }
630
+ }
631
+ } finally {
632
+ cleanupWatchdog();
601
633
  }
602
634
  }
603
635
 
package/src/ai/sse.ts CHANGED
@@ -5,6 +5,7 @@ export async function* readLines(
5
5
  const reader = stream.getReader();
6
6
  const decoder = new TextDecoder("utf-8");
7
7
  let buffer = "";
8
+ let searchFrom = 0;
8
9
  try {
9
10
  while (true) {
10
11
  const { done, value } = await reader.read();
@@ -19,19 +20,33 @@ export async function* readLines(
19
20
  onActivity?.();
20
21
  }
21
22
  buffer += decoder.decode(value, { stream: true });
22
- const parts = buffer.split("\n");
23
- buffer = parts.pop() ?? "";
24
- for (const part of parts) {
25
- const line = part.endsWith("\r") ? part.slice(0, -1) : part;
23
+ let idx;
24
+ while ((idx = buffer.indexOf("\n", searchFrom)) !== -1) {
25
+ const lineWithCr = buffer.slice(searchFrom, idx);
26
+ const line = lineWithCr.endsWith("\r") ? lineWithCr.slice(0, -1) : lineWithCr;
26
27
  if (line !== "") {
27
28
  yield line;
28
29
  }
30
+ searchFrom = idx + 1;
31
+ }
32
+ if (searchFrom > 0) {
33
+ buffer = buffer.slice(searchFrom);
34
+ searchFrom = 0;
29
35
  }
30
36
  }
31
37
  buffer += decoder.decode();
32
- const parts = buffer.split("\n");
33
- for (const part of parts) {
34
- const line = part.endsWith("\r") ? part.slice(0, -1) : part;
38
+ let idx;
39
+ while ((idx = buffer.indexOf("\n", searchFrom)) !== -1) {
40
+ const lineWithCr = buffer.slice(searchFrom, idx);
41
+ const line = lineWithCr.endsWith("\r") ? lineWithCr.slice(0, -1) : lineWithCr;
42
+ if (line !== "") {
43
+ yield line;
44
+ }
45
+ searchFrom = idx + 1;
46
+ }
47
+ if (searchFrom < buffer.length) {
48
+ const lineWithCr = buffer.slice(searchFrom);
49
+ const line = lineWithCr.endsWith("\r") ? lineWithCr.slice(0, -1) : lineWithCr;
35
50
  if (line !== "") {
36
51
  yield line;
37
52
  }
@@ -19,7 +19,16 @@ const CLOSE = "</think>";
19
19
  function partialTail(s: string, tag: string): number {
20
20
  const max = Math.min(s.length, tag.length - 1);
21
21
  for (let k = max; k > 0; k--) {
22
- if (s.endsWith(tag.slice(0, k))) return k;
22
+ // Check if s.endsWith(tag.slice(0, k)) without allocating substrings.
23
+ let match = true;
24
+ const sStart = s.length - k;
25
+ for (let i = 0; i < k; i++) {
26
+ if (s.charCodeAt(sStart + i) !== tag.charCodeAt(i)) {
27
+ match = false;
28
+ break;
29
+ }
30
+ }
31
+ if (match) return k;
23
32
  }
24
33
  return 0;
25
34
  }