@sagentlab/navarch-runtime 0.1.140 → 0.1.142

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/README.md CHANGED
@@ -592,7 +592,7 @@ unchanged across the deployment.
592
592
  | `NAVARCH_MCP_CONFIG_PATH` | — | Path to the platform MCP config passed as `--mcp-config`. |
593
593
  | `NAVARCH_WORKTREE_GUARD` | on | Host-mode sessions get an adapter-native per-session worktree boundary guard (see below). Set `off` to disable it for every runtime on the machine — required to run OpenCode or Muse on an already-isolated host. |
594
594
  | `NAVARCH_NAVI_CONVERSATIONS` | on | Serve read-only Navi repository conversations on this machine. Only the `claude-code` adapter on the host sandbox with the worktree guard on is supported; `navarch-runtime doctor` prints the `navi_chat:` state and reason. Set `off` to stop polling for conversations. |
595
- | `NAVARCH_NAVI_WORKSPACE_CHAT` | off | Explicitly enable writable Navi chat on a supported Claude Code host runtime. Owners can choose **Edit and run commands**; automatic permission review and the worktree guard remain active. Commands run on this host, not in a hard sandbox. Changes stay under `<workspaceRoot>/chat-workspaces/<conversationId>/repo` until the operator removes them. See [workspace chat](../docs/navarch/navi-workspace-chat.md). |
595
+ | `NAVARCH_NAVI_WORKSPACE_CHAT` | off | Explicitly enable writable Navi chat on a supported Claude Code host runtime. Generated Claude Code host connect and supervise commands include it; remove it from both lines to keep chat read-only. Owners can choose **Edit and run commands**; automatic permission review and the worktree guard remain active. Commands run on this host, not in a hard sandbox. Changes stay under `<workspaceRoot>/chat-workspaces/<conversationId>/repo` until the operator removes them. See [workspace chat](../docs/navarch/navi-workspace-chat.md). |
596
596
  | `NAVARCH_CONVERSATION_POLL_MS` | `5000` | Idle poll interval for queued conversation turns (1000–30000). |
597
597
  | `NAVARCH_CONVERSATION_MIRROR` | on | Seed new conversation checkouts from a per-repository mirror under `<workspaceRoot>/conversation-mirrors/` (fetch + local clone with independent object files) instead of a full network clone. Set `off` to always clone directly. |
598
598
  | `NAVARCH_CONVERSATION_TURN_TIMEOUT_MS` | `540000` | Time limit for one conversation turn's agent process (at most nine minutes). |
@@ -26,7 +26,10 @@
26
26
  * standard system prefixes (/usr, /etc, ...) so toolchains keep working.
27
27
  * - Bash commands are screened lexically: absolute, `~`/$HOME, and
28
28
  * `..`-traversal path references must land inside the allowed roots or
29
- * the system prefixes. This cannot catch every obfuscated escape (shell
29
+ * the system prefixes. Slash-led text that cannot name an existing root
30
+ * entry (routes, regex, sed addresses) and bare root text inside heredoc
31
+ * or quoted program bodies are not paths (extractBashPathCandidates).
32
+ * This cannot catch every obfuscated escape (shell
30
33
  * is Turing-complete) — it is a strong guardrail, not a security
31
34
  * boundary. Operators needing a hard boundary should use
32
35
  * NAVARCH_SANDBOX_MODE=docker (sandbox.cts).
@@ -311,23 +314,417 @@ function isCommentOrMarkupToken(command, start, token) {
311
314
  return token === "//" && /\S/.test(command.slice(end));
312
315
  }
313
316
 
317
+ let rootEntries;
318
+
319
+ /** Entry names at the filesystem root, or null when they cannot be listed. */
320
+ function listRootEntries() {
321
+ if (rootEntries === undefined) {
322
+ try {
323
+ rootEntries = fs.readdirSync(path.parse(process.cwd()).root);
324
+ } catch {
325
+ rootEntries = null;
326
+ }
327
+ }
328
+ return rootEntries;
329
+ }
330
+
331
+ /**
332
+ * Glob segment to a case-insensitive regex source, as a superset of what the
333
+ * shell could match: `*` is `.*`, `?` and any bracket expression are `.`, and
334
+ * a backslash makes the next character literal.
335
+ */
336
+ function globSegmentToRegexSource(segment) {
337
+ let source = "";
338
+ for (let i = 0; i < segment.length; i++) {
339
+ const ch = segment[i];
340
+ if (ch === "\\" && i + 1 < segment.length) {
341
+ source += segment[++i].replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
342
+ } else if (ch === "*") {
343
+ source += ".*";
344
+ } else if (ch === "?") {
345
+ source += ".";
346
+ } else if (ch === "[" && segment.indexOf("]", i + 2) !== -1) {
347
+ source += ".";
348
+ i = segment.indexOf("]", i + 2);
349
+ } else {
350
+ source += ch.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
351
+ }
352
+ }
353
+ return source;
354
+ }
355
+
356
+ /**
357
+ * False only when an absolute token cannot reach any existing location: its
358
+ * first segment neither names nor glob-matches an entry at the filesystem
359
+ * root. URL routes (`/dev-preview/knowledge`), regex literals (`/^export/`,
360
+ * `/v[0-9]+`) and sed addresses (`/pattern/,/^}/p`) fall here, while
361
+ * `/Users/...`, `/home/...`, `/*` and the bare root stay candidates. A first
362
+ * segment that expansion could change (`$`, `{`, `(` outside the quotes that
363
+ * make them literal, `.`/`..` segments) stays a candidate, and so does one
364
+ * that ends in a backslash, which can continue the word onto the next line.
365
+ * When a quote or backtick follows the token, the shell word may continue, so
366
+ * an unterminated first segment is matched as a prefix. `quote` is the plain
367
+ * quote character around the token when a text or program command reads the
368
+ * word, or "" otherwise.
369
+ */
370
+ function mayNameRootEntry(token, nextChar, quote) {
371
+ const segment = token.replace(/^\/+/, "").split("/")[0];
372
+ if (segment === "") return true;
373
+ const expanding = quote === "'" ? null : quote === '"' ? /\$/ : /[$({]/;
374
+ if (expanding && expanding.test(segment)) return true;
375
+ if (/(^|[^\\])(\\\\)*\\$/.test(segment)) return true;
376
+ if (/(^|\/)\.\.?(\/|$)/.test(token)) return true;
377
+ const entries = listRootEntries();
378
+ if (!entries) return true;
379
+ const terminated = token.replace(/^\/+/, "").includes("/");
380
+ const continues = nextChar === "'" || nextChar === '"' || nextChar === "`";
381
+ const anchor = new RegExp(
382
+ `^${globSegmentToRegexSource(segment)}${terminated || !continues ? "$" : ""}`,
383
+ "i",
384
+ );
385
+ return entries.some((entry) => anchor.test(entry));
386
+ }
387
+
388
+ /**
389
+ * Commands that only read, filter or print text and never run it. Bare root
390
+ * text (`/`, `//`, `/*`, `/**`) in their heredoc or quoted argument is prose,
391
+ * a comment, or an operator rather than a path argument.
392
+ */
393
+ const TEXT_COMMANDS = new Set([
394
+ "base64",
395
+ "cat",
396
+ "diff",
397
+ "egrep",
398
+ "envsubst",
399
+ "fgrep",
400
+ "grep",
401
+ "head",
402
+ "jq",
403
+ "rg",
404
+ "sort",
405
+ "tail",
406
+ "tee",
407
+ "uniq",
408
+ "wc",
409
+ "yq",
410
+ ]);
411
+
412
+ /**
413
+ * Commands that may share a command with an exempted text without running
414
+ * it. Their own quoted arguments get no exemption, so `echo '.a // x'` and
415
+ * `ls "/"` stay screened.
416
+ */
417
+ const CHAINABLE_COMMANDS = new Set([...TEXT_COMMANDS, "cd", "echo", "ls", "mkdir", "printf", "pwd", "true"]);
418
+
419
+ /**
420
+ * Commands whose single-quoted arguments are literal patterns, so `$`, `(` and
421
+ * `{` there do not expand (`sed '/^$/d'`, `awk '/^$/'`). This affects only the
422
+ * root-entry test; these commands get no bare-root text exemption.
423
+ */
424
+ const LITERAL_PATTERN_COMMANDS = new Set([...CHAINABLE_COMMANDS, "sed", "awk", "gawk", "mawk", "nawk"]);
425
+
426
+ /**
427
+ * Non-shell interpreters whose program body may hold bare root text as a
428
+ * comment or operator (`a // b`, `/* x *\/`, `"/".join`). A body that can
429
+ * start a process (PROGRAM_SHELL_RE) gets no exemption. Languages that pipe
430
+ * to commands in plain syntax (awk, perl, ruby, psql, sqlite3) are excluded.
431
+ */
432
+ const PROGRAM_COMMANDS = new Set(["bun", "deno", "node", "python", "python3", "tsx"]);
433
+
434
+ const PROGRAM_SHELL_RE = /system|subprocess|popen|exec|spawn|child_process|shell|pty|command|\.run\b|`/i;
435
+
436
+ /**
437
+ * git and gh run aliases, hooks and extensions through a shell, so only the
438
+ * message text of message subcommands is exempt: a quoted message-flag value,
439
+ * or a heredoc read through `--body-file`/`-F`/`--file`.
440
+ */
441
+ const MESSAGE_SUBCOMMAND_RE =
442
+ /^\s*(?:git\s+(?:commit|tag|notes|merge)|gh\s+(?:pr|issue|release)\s+(?:create|edit|comment|review|close))\b/;
443
+ const MESSAGE_FLAG_RE = /\s(?:-m|--message|-t|--title|-b|--body|--notes)(?:=|\s+)$/;
444
+ const MESSAGE_FILE_RE = /\s(?:--body-file|-F|--file)(?:=|\s)/;
445
+
446
+ const ASSIGNMENTS_RE = /^\s*!?\s*(?:[A-Za-z_]\w*=\S*\s+)*/;
447
+ const LEADING_REDIRECTS_RE = /^(?:\s*\d*(?:<<?|>>?|&>>?)&?\s*[^\s;|&<>()]+)*\s*/;
448
+
449
+ /** Command word of a simple-command prefix, without assignments or a directory. */
450
+ function commandWordOf(segment) {
451
+ const rest = segment.replace(ASSIGNMENTS_RE, "");
452
+ const word = /^[^\s;|&<>()'"`]+/.exec(rest);
453
+ if (!word) return "";
454
+ return path.posix.basename(word[0]).replace(/^(python3)\.\d+$/, "$1");
455
+ }
456
+
457
+ /**
458
+ * Classifies the simple command starting at `start`: "empty" (nothing runs),
459
+ * "text" (a CHAINABLE_COMMANDS command), "active" (a PROGRAM_COMMANDS interpreter
460
+ * or a git/gh message subcommand), or "unsafe" (anything else, including a
461
+ * command word that starts with a quote, `$` or a backtick).
462
+ */
463
+ function classifySegment(command, start) {
464
+ const end = command.slice(start).search(/[;|&\n()`]/);
465
+ const text = command.slice(start, end === -1 ? command.length : start + end);
466
+ const rest = text.replace(ASSIGNMENTS_RE, "").replace(LEADING_REDIRECTS_RE, "");
467
+ if (rest === "" || /^[)]/.test(command.slice(start).replace(ASSIGNMENTS_RE, ""))) return "empty";
468
+ const word = commandWordOf(rest);
469
+ if (CHAINABLE_COMMANDS.has(word)) return "text";
470
+ if (PROGRAM_COMMANDS.has(word)) return "active";
471
+ if (MESSAGE_SUBCOMMAND_RE.test(rest) && !/\s-c\s|alias/.test(command)) return "active";
472
+ return "unsafe";
473
+ }
474
+
475
+ /** Text of the simple command starting at `start`, up to its first separator. */
476
+ function segmentText(command, start) {
477
+ const end = command.slice(start).search(/[;|&\n()`]/);
478
+ return command.slice(start, end === -1 ? command.length : start + end);
479
+ }
480
+
481
+ /**
482
+ * True when nothing else in the command can run text that an exempted body or
483
+ * argument supplies. Every command must be chainable text or empty, and no
484
+ * text may name a git or husky hook location. An active command must be the
485
+ * only command, without leading assignments (`GIT_EDITOR=sh`) or an editor
486
+ * flag. A git/gh message command may additionally take `cat`, `echo` or
487
+ * `printf` output through `$(...)` when those write no file
488
+ * (`--body "$(cat <<'EOF' ... )"`).
489
+ */
490
+ function exemptionContextIsSafe(command, segments) {
491
+ if (/\.git\/|\.husky|hooks/i.test(command)) return false;
492
+ const classes = segments.map((segment) => ({ ...segment, kind: classifySegment(command, segment.at) }));
493
+ if (classes.some(({ kind }) => kind === "unsafe")) return false;
494
+ const active = classes.filter(({ kind }) => kind === "active");
495
+ if (active.length === 0) return true;
496
+ if (active.length > 1) return false;
497
+ const activeText = segmentText(command, active[0].at);
498
+ if (activeText.replace(ASSIGNMENTS_RE, "") !== activeText.trimStart()) return false;
499
+ const isMessage = MESSAGE_SUBCOMMAND_RE.test(activeText);
500
+ // git accepts unique long-option prefixes, so `--ed` also opens the editor.
501
+ if (isMessage && /\s(?:-e|--ed(?:i(?:t(?:or)?)?)?)(?:[\s=]|$)/.test(activeText)) return false;
502
+ return classes.every(({ at, kind, nested }) => {
503
+ if (kind === "empty" || kind === "active") return true;
504
+ if (!isMessage || !nested) return false;
505
+ const text = segmentText(command, at).replace(ASSIGNMENTS_RE, "");
506
+ return /^(?:cat|echo|printf)\b/.test(text) && !/>/.test(text);
507
+ });
508
+ }
509
+
510
+ /** True for root-only text: `/`, `//`, `/*`, `/**`, `/=`, `//=`. */
511
+ function isBareRootText(token) {
512
+ return /^\/+\**=?$/.test(token);
513
+ }
514
+
515
+ /**
516
+ * Heredoc bodies as [{start, end, word, segment, quoted}], every simple
517
+ * command outside them as [{at, nested}] (nested inside `$(`, a backtick or a
518
+ * subshell), and `ok`. A small lexer tracks single and double quotes, `$(`,
519
+ * backticks, subshells and shell comments to find each `<<` operator and its
520
+ * body. Any construct it cannot follow (`$'`, arithmetic `((`, process
521
+ * substitution `<(`/`>(`, a delimiter with `$` or an unbalanced quote, an
522
+ * unterminated quote, substitution or heredoc, an unbalanced `)`) returns
523
+ * `ok: false`, so nothing is exempted on a misread.
524
+ */
525
+ function scanHeredocs(command) {
526
+ const failed = { ok: false, bodies: [], segments: [] };
527
+ const bodies = [];
528
+ const segments = [{ at: 0, nested: false }];
529
+ const stack = [];
530
+ let pending = [];
531
+ let segmentStart = 0;
532
+ const startSegment = (at) => {
533
+ segmentStart = at;
534
+ segments.push({ at, nested: stack.some((frame) => frame !== "dq") });
535
+ };
536
+ let i = 0;
537
+ const n = command.length;
538
+ while (i < n) {
539
+ const ch = command[i];
540
+ const top = stack[stack.length - 1];
541
+ if (top === "dq") {
542
+ if (ch === "\\") i += 2;
543
+ else if (ch === '"') {
544
+ stack.pop();
545
+ i++;
546
+ } else if (ch === "$" && command[i + 1] === "(") {
547
+ if (command[i + 2] === "(") return failed;
548
+ stack.push("sub");
549
+ i += 2;
550
+ startSegment(i);
551
+ } else if (ch === "`") {
552
+ stack.push("bt");
553
+ i++;
554
+ startSegment(i);
555
+ } else i++;
556
+ continue;
557
+ }
558
+ if (ch === "\\") {
559
+ i += 2;
560
+ continue;
561
+ }
562
+ if (ch === "'") {
563
+ const close = command.indexOf("'", i + 1);
564
+ if (close === -1) return failed;
565
+ i = close + 1;
566
+ continue;
567
+ }
568
+ if (ch === '"') {
569
+ stack.push("dq");
570
+ i++;
571
+ continue;
572
+ }
573
+ if (ch === "$" && command[i + 1] === "'") return failed;
574
+ if (ch === "$" && command[i + 1] === "(") {
575
+ if (command[i + 2] === "(") return failed;
576
+ stack.push("sub");
577
+ i += 2;
578
+ startSegment(i);
579
+ continue;
580
+ }
581
+ if (ch === "(") {
582
+ if (command[i + 1] === "(" || command[i - 1] === "<" || command[i - 1] === ">") return failed;
583
+ stack.push("sub");
584
+ startSegment(++i);
585
+ continue;
586
+ }
587
+ if (ch === ")") {
588
+ if (top !== "sub") return failed;
589
+ stack.pop();
590
+ i++;
591
+ continue;
592
+ }
593
+ if (ch === "`") {
594
+ if (top === "bt") stack.pop();
595
+ else stack.push("bt");
596
+ startSegment(++i);
597
+ continue;
598
+ }
599
+ if (ch === "#" && (i === 0 || /[\s;|&()`]/.test(command[i - 1]))) {
600
+ const newline = command.indexOf("\n", i);
601
+ i = newline === -1 ? n : newline;
602
+ continue;
603
+ }
604
+ if (ch === ";" || ch === "|" || ch === "&") {
605
+ startSegment(++i);
606
+ continue;
607
+ }
608
+ if (ch === "<" && command[i + 1] === "<") {
609
+ if (command[i + 2] === "<") {
610
+ i += 3;
611
+ continue;
612
+ }
613
+ let j = i + 2;
614
+ const stripTabs = command[j] === "-";
615
+ if (stripTabs) j++;
616
+ while (command[j] === " " || command[j] === "\t") j++;
617
+ const word = /^[^\s;|&<>()]+/.exec(command.slice(j));
618
+ if (!word) return failed;
619
+ const delimiter = word[0].replace(/['"\\]/g, "");
620
+ if (!delimiter || delimiter.includes("$")) return failed;
621
+ if ((word[0].split("'").length - 1) % 2 !== 0) return failed;
622
+ if ((word[0].split('"').length - 1) % 2 !== 0) return failed;
623
+ const lineEnd = command.indexOf("\n", i);
624
+ pending.push({
625
+ delimiter,
626
+ stripTabs,
627
+ quoted: /['"\\]/.test(word[0]),
628
+ word: commandWordOf(command.slice(segmentStart, i)),
629
+ segment: command.slice(segmentStart, lineEnd === -1 ? n : lineEnd),
630
+ });
631
+ i = j + word[0].length;
632
+ continue;
633
+ }
634
+ if (ch === "\n") {
635
+ i++;
636
+ for (const doc of pending) {
637
+ const start = i;
638
+ let end = -1;
639
+ while (i <= n) {
640
+ const newline = command.indexOf("\n", i);
641
+ const lineEnd = newline === -1 ? n : newline;
642
+ let line = command.slice(i, lineEnd);
643
+ if (doc.stripTabs) line = line.replace(/^\t+/, "");
644
+ const lineStart = i;
645
+ i = newline === -1 ? n + 1 : newline + 1;
646
+ if (line === doc.delimiter) {
647
+ end = lineStart;
648
+ break;
649
+ }
650
+ if (newline === -1) break;
651
+ }
652
+ if (end === -1) return failed;
653
+ bodies.push({ start, end, word: doc.word, segment: doc.segment, quoted: doc.quoted });
654
+ }
655
+ if (i > n) i = n;
656
+ pending = [];
657
+ startSegment(i);
658
+ continue;
659
+ }
660
+ i++;
661
+ }
662
+ if (stack.length > 0 || pending.length > 0) return failed;
663
+ return { ok: true, bodies, segments };
664
+ }
665
+
666
+ /**
667
+ * True when the command in `segment` reads `text` as plain text, as a
668
+ * non-shell program that cannot start a process, or as a git/gh message.
669
+ */
670
+ function readsAsText(segment, text, isMessage) {
671
+ const word = commandWordOf(segment);
672
+ if (TEXT_COMMANDS.has(word)) return true;
673
+ if (PROGRAM_COMMANDS.has(word)) return !PROGRAM_SHELL_RE.test(text);
674
+ return MESSAGE_SUBCOMMAND_RE.test(segment.replace(ASSIGNMENTS_RE, "")) && isMessage;
675
+ }
676
+
677
+ /**
678
+ * True when bare root text sits in a heredoc body or quoted argument that a
679
+ * text command, a non-shell program, or a git/gh message reads: a
680
+ * `// comment` or `a / b` prose in `cat <<'EOF'`,
681
+ * `gh pr create --body-file - <<'EOF'` or `python3 -c "print(a // b)"`. The
682
+ * quoted argument must hold more than the token itself, so `ls "/"` stays
683
+ * screened. An unquoted-delimiter heredoc qualifies only without `$(` or
684
+ * backtick expansion in its body. No exemption applies unless the rest of the
685
+ * command cannot run that text (exemptionContextIsSafe).
686
+ */
687
+ function isProgramTextRootToken(command, start, token, heredocs) {
688
+ if (!isBareRootText(token)) return false;
689
+ const { ok, bodies, segments } = heredocs();
690
+ if (!ok || !exemptionContextIsSafe(command, segments)) return false;
691
+ const body = bodies.find((doc) => start >= doc.start && start < doc.end);
692
+ if (body) {
693
+ const text = command.slice(body.start, body.end);
694
+ if (!body.quoted && /`|\$\(/.test(text)) return false;
695
+ return readsAsText(body.segment, text, MESSAGE_FILE_RE.test(body.segment));
696
+ }
697
+ const span = quotedSpanAt(command, start);
698
+ if (!span) return false;
699
+ const text = command.slice(span[0] + 1, span[1]);
700
+ if (text.trim() === token) return false;
701
+ const prefix = command.slice(span[2], span[0]);
702
+ return readsAsText(prefix, text, MESSAGE_FLAG_RE.test(prefix));
703
+ }
704
+
314
705
  /**
315
706
  * Pulls path-like references out of a shell command: absolute paths,
316
707
  * home-anchored paths (`~...`, `$HOME/...`), and relative `..` traversals.
317
708
  * Lexical by design — see the file header for why this is a guardrail, not
318
709
  * a parser. URLs survive untouched (the char before `//` in `https://` is
319
710
  * `:`, which is not a path delimiter here). Comment and markup text that
320
- * merely starts with "/" (`/** doc *\/`, `// note`, `</p>`) is not a path.
711
+ * merely starts with "/" (`/** doc *\/`, `// note`, `</p>`) is not a path,
712
+ * nor is a token whose first segment names nothing at the filesystem root
713
+ * (routes, regex and sed text), nor bare root text in a heredoc or quoted
714
+ * program body (isProgramTextRootToken).
321
715
  */
322
716
  function extractBashPathCandidates(command) {
323
717
  const candidates = [];
324
718
  const seen = new Set();
719
+ const clean = (raw) => raw.replace(/[)\]}"'`,]+$/, "");
325
720
  const add = (raw) => {
326
- const cleaned = raw.replace(/[)\]}"'`,]+$/, "");
721
+ const cleaned = clean(raw);
327
722
  if (!cleaned || seen.has(cleaned)) return;
328
723
  seen.add(cleaned);
329
724
  candidates.push(cleaned);
330
725
  };
726
+ let heredocs;
727
+ const getHeredocs = () => (heredocs ??= scanHeredocs(command));
331
728
 
332
729
  const absoluteRe = /(?:^|[\s'"`=(<>;|&])(\/[^\s'"`<>;|&)]*)/g;
333
730
  const homeRe = /(?:^|[\s'"`=(<>;|&])((?:~|\$\{?HOME\}?)[^\s'"`<>;|&)]*)/g;
@@ -335,6 +732,14 @@ function extractBashPathCandidates(command) {
335
732
  while ((match = absoluteRe.exec(command))) {
336
733
  const start = match.index + match[0].length - match[1].length;
337
734
  if (isCommentOrMarkupToken(command, start, match[1])) continue;
735
+ const token = clean(match[1]);
736
+ // Quotes keep `$`, `(` and `{` literal only for a command that does not
737
+ // hand the word to a shell, such as `sed '/^$/d'`; `sh -c '... /$D'` expands.
738
+ const span = quotedSpanAt(command, start);
739
+ const word = span ? commandWordOf(command.slice(span[2], span[0])) : "";
740
+ const quote = LITERAL_PATTERN_COMMANDS.has(word) ? command[span[0]] : "";
741
+ if (!mayNameRootEntry(token, command[start + match[1].length], quote)) continue;
742
+ if (isProgramTextRootToken(command, start, token, getHeredocs)) continue;
338
743
  add(match[1]);
339
744
  }
340
745
  while ((match = homeRe.exec(command))) add(match[1]);
@@ -97,6 +97,7 @@ function createLocalBrowser(options) {
97
97
  await context?.close().catch(() => undefined);
98
98
  context = undefined;
99
99
  page = undefined;
100
+ notes = [];
100
101
  try {
101
102
  browser ??= await options.launch();
102
103
  }
@@ -159,14 +160,20 @@ function createLocalBrowser(options) {
159
160
  }
160
161
  if (tree.length > MAX_SNAPSHOT_CHARS)
161
162
  tree = `${tree.slice(0, MAX_SNAPSHOT_CHARS)}\n… (truncated)`;
163
+ const title = await active.title().catch(() => "");
164
+ // Take the notes only after the last await, so a tab that opens while
165
+ // this snapshot is being built is reported here (its body may still show
166
+ // the previous tab) instead of being cleared unreported.
167
+ const reported = notes;
168
+ notes = [];
162
169
  const lines = [
163
170
  ...(heading ? [heading] : []),
164
- ...notes,
171
+ ...reported,
165
172
  ...(active.url().startsWith("chrome-error://")
166
173
  ? ["The last navigation failed or tried to leave this machine and was blocked. Use local_browser_open to continue."]
167
174
  : []),
168
175
  `URL: ${active.url()}`,
169
- `Title: ${await active.title().catch(() => "")}`,
176
+ `Title: ${title}`,
170
177
  `Viewport: ${viewport}`,
171
178
  ...(logs.length ? ["Recent console/page errors:", ...logs.map((line) => ` ${line}`)] : []),
172
179
  ...(blocked.size
@@ -175,7 +182,6 @@ function createLocalBrowser(options) {
175
182
  "",
176
183
  tree,
177
184
  ];
178
- notes = [];
179
185
  return [{ type: "text", text: lines.join("\n") }];
180
186
  }
181
187
  /** Runs tool calls one at a time; a lease ending stops further use. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.140",
3
+ "version": "0.1.142",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through local coding agents.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",