fix(story-mode): no input shape blocks the server: linear tag and commit scans

Every string Story Mode matches is agent-written (and an agent can be
prompt-injected), and one regex call blocks the single-threaded server.
Timing each helper at its own input cap with crafted shapes found five
that were quadratic or worse:

- A lazy `<tag>[\s\S]*?</tag>` rescans to the end for every opener that
  never closes: system reminders, teammate messages and task-notification
  fields took 1.5 to 29 s on 0.5 to 1 MB of prompt text. They are found
  with indexOf now (withoutBlocks, blocks, blockText1).
- The commit message regex backtracked over many `git commit` tokens
  (290 ms at 4,000 characters, growing x8.7 per doubling) and ran on the
  whole command. It is a bounded line-by-line scan with the same rules.
- The "This command modified …" note was searched with a lazy match per
  repeat of the phrase (7 s on 1 MB of output). It is one anchored try
  at its last occurrence, where Claude Code appends it.
- firstSentence and planFromText now read only the start they need.

The worst shape is now under 10 ms at 1 MB. A test times five of them at
sizes where the old code took a second or more.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 14:11:01 +02:00
1 parent 67595ea980
commit 21de40309d
2 files changed
+128 -20

No files matched your search

+103 -20
View File
@@ -278,7 +278,8 @@ export function closingText(text: string): string {
/** The first sentence of a message, without markdown, for a speech balloon. */
export function firstSentence(text: string, max = 160): string {
const spans: string[] = [];
const plain = plainMarkdown(text, spans).replace(/\s+/g, ' ').trim();
// Only the start can hold the first sentence; the rest (a helper's whole report) is never scanned.
const plain = plainMarkdown(text.slice(0, 4000), spans).replace(/\s+/g, ' ').trim();
const match = SENTENCE_END.exec(plain);
return clip(restoreSpans(match ? match[1] : plain, spans).replace(/\s+/g, ' '), max);
}
@@ -291,13 +292,15 @@ export function planFromText(text: string): StoryTodo[] | null {
const items: StoryTodo[] = [];
let numbered = 0;
let boxes = 0;
for (const line of text.split('\n')) {
for (const line of text.slice(0, 20_000).split('\n')) {
const m = /^\s{0,3}(?:(\d+)[.)]|[-*+])\s+(\[[ xX]\]\s+)?(\S.*)$/.exec(line);
if (!m || (!m[1] && !m[2])) continue;
if (m[2]) boxes++;
else numbered++;
const spans: string[] = [];
const plain = restoreSpans(plainMarkdown(m[3], spans), spans).replace(/\s+/g, ' ').trim();
const plain = restoreSpans(plainMarkdown(m[3].slice(0, 400), spans), spans)
.replace(/\s+/g, ' ')
.trim();
if (plain) items.push({ text: clip(plain, 120), done: /x/i.test(m[2] ?? '') });
}
return numbered >= 3 || boxes >= 2 ? items.slice(0, STORY_LIMITS.todoItems) : null;
@@ -472,14 +475,37 @@ function echoWrites(command: string, cwd?: string): StoryEdit[] {
* here-document a `git commit -F -` reads.
*/
function commitSubject(command: string): string | undefined {
// `-m`, a combined `-am` / `-qm`, or `--message`; a trailer (`Co-Authored-By:`) is never the subject.
const m = /\bgit\b[^\n;&|]*\bcommit\b[^\n;&|]*?\s-(?:[a-zA-Z]*m|-message=?)\s*(["'])((?:(?!\1)[\s\S])*)\1/.exec(
command
);
const first = m?.[2].split('\n')[0].trim();
if (first && !/^[\w-]+-By:/i.test(first)) return clip(first, 100);
const f = /\bgit\b[^\n;&|]*\bcommit\b[^\n]*\s-F\s*-[^\n]*<<-?\s*(['"]?)(\w+)\1[^\n]*\n([^\n]*)/.exec(command);
return f && f[3].trim() && f[3].trim() !== f[2] ? clip(f[3].trim(), 100) : undefined;
// Line by line with plain searches, never one regex over the command: a command is agent-written,
// and `[^;]*commit[^;]*?-m` over many `git commit` tokens backtracks (seconds on a long one-liner).
const lines = command.slice(0, 8000).split('\n');
for (let i = 0; i < lines.length && i < 400; i++) {
const line = lines[i];
const g = line.search(/\bgit\b/);
if (g < 0) continue;
const c = line.slice(g).search(/\bcommit\b/);
if (c < 0) continue;
const rest = line.slice(g + c + 'commit'.length);
// Its own options only: the part before the next `;`, `&` or `|`.
const stop = rest.search(/[;&|]/);
const end = stop < 0 ? rest.length : stop;
// `-m`, a combined `-am` / `-qm`, or `--message`; a trailer (`Co-Authored-By:`) is never the subject.
const m = /\s-(?:[a-zA-Z]{0,8}m|-message=?)\s*(["'])/.exec(rest);
if (m && m.index < end) {
const from = m.index + m[0].length;
const close = rest.indexOf(m[1], from);
const first = (close < 0 ? rest.slice(from) : rest.slice(from, close)).trim();
if (first && !/^[\w-]+-By:/i.test(first)) return clip(first, 100);
continue;
}
// `git commit -F - <<'EOF'`: the here-document's first line.
const f = /\s-F\s*-(?=\s|$)/.exec(rest);
const h = /<<-?\s*(['"]?)(\w+)\1/.exec(rest);
if (f && f.index < end && h) {
const body = lines[i + 1]?.trim();
if (body && body !== h[2]) return clip(body, 100);
}
}
return undefined;
}
/** A here-document's body is data, not commands: `cat > a.py <<'EOF' ... EOF` keeps only its first line. */
@@ -745,7 +771,57 @@ export function parseTestCounts(output: string): StoryTests | null {
// ========== Stage 1: reduce one transcript line ==========
const SYSTEM_REMINDER_RE = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
/*
* Tagged blocks in prompt text are found with indexOf, never a lazy `<a>[\s\S]*?</a>` regex: that
* one rescans to the end for every opener without a closer (seconds on a crafted megabyte, and a
* teammate's or a helper's message is another agent's writing).
*/
/** The text with every `<open>…<close>` block taken out; an opener never closed stays. */
function withoutBlocks(text: string, open: string, close: string): string {
let out = '';
let from = 0;
for (;;) {
const at = text.indexOf(open, from);
if (at < 0) break;
const end = text.indexOf(close, at + open.length);
if (end < 0) break;
out += text.slice(from, at);
from = end + close.length;
}
return out + text.slice(from);
}
/** `<name attrs>inner</name>` blocks in order (`name` followed by a space or `>`), at most `limit`. */
function blocks(text: string, name: string, limit = 50): Array<{ attrs: string; inner: string }> {
const out: Array<{ attrs: string; inner: string }> = [];
const open = `<${name}`;
const close = `</${name}>`;
let from = 0;
while (out.length < limit) {
const at = text.indexOf(open, from);
if (at < 0) break;
const next = text[at + open.length];
if (next !== '>' && next !== undefined && !/\s/.test(next)) {
from = at + open.length;
continue;
}
const gt = text.indexOf('>', at + open.length);
if (gt < 0) break;
const end = text.indexOf(close, gt + 1);
if (end < 0) break;
out.push({ attrs: text.slice(at + open.length, gt), inner: text.slice(gt + 1, end) });
from = end + close.length;
}
return out;
}
/** The inner text of the first `<name>…</name>`. */
function blockText1(text: string, name: string): string | undefined {
const at = text.indexOf(`<${name}>`);
if (at < 0) return undefined;
const end = text.indexOf(`</${name}>`, at);
return end < 0 ? undefined : text.slice(at + name.length + 2, end);
}
/** Wrappers around things the user did locally (shell `!`, slash command output): never a turn. */
const LOCAL_WRAPPER_RE =
/^\s*<(local-command-caveat|local-command-stdout|local-command-stderr|bash-input|bash-stdout|bash-stderr)>/;
@@ -756,12 +832,12 @@ const LOCAL_WRAPPER_RE =
*/
function readTeammateMessages(text: string): StoryTeammateReport[] {
const reports: StoryTeammateReport[] = [];
for (const m of text.matchAll(/<teammate-message\b([^>]*)>([\s\S]*?)<\/teammate-message>/g)) {
const from = /teammate_id="([^"]*)"/.exec(m[1])?.[1]?.split('@')[0];
for (const m of blocks(text, 'teammate-message')) {
const from = /teammate_id="([^"]*)"/.exec(m.attrs)?.[1]?.split('@')[0];
if (!from) continue;
const rawSummary = /summary="([^"]*)"/.exec(m[1])?.[1];
const rawSummary = /summary="([^"]*)"/.exec(m.attrs)?.[1];
const summary = rawSummary === undefined ? undefined : unescapeXml(rawSummary);
let body: string | undefined = unescapeXml(m[2].trim());
let body: string | undefined = unescapeXml(m.inner.trim());
if (body.startsWith('{')) {
try {
const note: unknown = JSON.parse(body);
@@ -794,8 +870,7 @@ function readPrompt(raw: string): {
task?: StoryTaskReport;
teammates?: StoryTeammateReport[];
} | null {
const text = raw
.replace(SYSTEM_REMINDER_RE, '')
const text = withoutBlocks(raw, '<system-reminder>', '</system-reminder>')
.replace(/<\/?pasted_content\b[^>]*>/g, '') // Claude Code's wrapper around pasted text
.replace(/^\s*Another Claude session sent a message:\s*/, '')
.trim();
@@ -824,7 +899,7 @@ function readPrompt(raw: string): {
}
if (/^<task-notification>/.test(text)) {
const tag = (name: string) => {
const value = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(text)?.[1]?.trim();
const value = blockText1(text, name)?.trim();
return value === undefined ? undefined : unescapeXml(value);
};
const count = (name: string) => {
@@ -1098,7 +1173,15 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean
if (tests) result.tests = tests;
const intent = call?.input.intent;
if ((intent === 'build' || intent === 'test') && reportsProblems(tail.text)) result.problems = true;
const note = /This command modified \d+ files? you've previously read: ([^\]\n]+?)\.? Call Read/.exec(text);
// Claude Code appends this note: one anchored try at its last occurrence, never a scan
// of the whole output (a lazy match per repeat of the phrase is quadratic).
const noteAt = text.lastIndexOf('This command modified ');
const note =
noteAt < 0
? null
: /^This command modified \d+ files? you've previously read: ([^\]\n]{1,2000}?)\.? Call Read/.exec(
text.slice(noteAt, noteAt + 2400)
);
if (note) {
const cwd = str(entry.cwd);
result.modified = note[1]
+25
View File
@@ -971,6 +971,31 @@ describe('buildStoryPage', () => {
expect(pageOf([prompt('c'), ...bash('b1', 'git commit -qm x', 'Commit', ''), end(1000)]).events[1].commits).toBe(1);
});
it('crafted agent text never takes seconds: every helper stays linear on its worst shapes', () => {
// Each shape took one to several seconds before (a lazy regex rescanning per opener, or a
// backtracking one over a long one-liner); a single call blocks the whole server.
const rep = (str: string, n: number) => str.repeat(Math.ceil(n / str.length)).slice(0, n);
const ms = (fn: () => unknown) => {
const start = performance.now();
fn();
return performance.now() - start;
};
const prompt1 = (content: string) => () => buildStoryTurns(entriesOf([prompt(content), said('ok'), end(1000)]));
const bashOut = (command: string, output: string) => () =>
pageOf([prompt('go'), ...bash('b1', command, 'x', output), end(1000)]);
const shapes: Array<[string, () => unknown]> = [
['teammate openers', prompt1(rep('<teammate-message teammate_id="a">x ', 400_000))],
['system-reminder openers', prompt1('hi ' + rep('<system-reminder>x ', 400_000))],
['task-notification field openers', prompt1('<task-notification>\n' + rep('<summary>x ', 400_000))],
['git commit tokens', bashOut(rep('git commit ', 8000), 'ok')],
[
'modified-note phrases',
bashOut('python3 x.py', rep("This command modified 1 file you've previously read: a", 400_000)),
],
];
for (const [name, fn] of shapes) expect([name, ms(fn) < 250]).toEqual([name, true]);
});
it('a python or node script fed by a here-document: the files it opens for writing, never guessed', () => {
const files = (command: string) => heredocWrites(command, '/w').map((d) => d.file);
expect(