Skip to content

Commit 0ec31de

Browse files
khaliqgantProactive Runtime Botclaudegithub-actions[bot]
authored
fix(workflow-runtime): tighten PTY chrome scrubbing, document idle override, tame stale-state warning (#930)
* fix(workflow-runtime): tighten PTY chrome scrubbing, document idle override, tame stale-state warning Three quality-of-life fixes surfaced by running a multi-CLI workflow end-to-end with interactive PTY agents: 1. **Strengthen scrubForChannel for Claude Code TUI noise.** Interactive-agent step "Output:" blocks were unreadable — the captured PTY stream included Claude Code's bottom status bar ("Opus 4.7 (1M context) ctx:5% $1.45"), vim-style mode indicators ("--INSERT--⏵⏵bypasspermissionson"), no-whitespace UI hints ("pasteagaintoexpand"), and ambient thinking-status fragments ("↓ 13 tokens · thinking with high effort", "Crunched for 32s"). None of these were caught by the existing regexes. Added: claudeFooterRe, vimModeRe, thinkingStatusRe; relaxed uiHintRe to match the no-whitespace TUI rendering variants. New regression test (scrub-pty-chrome.test.ts) covers the patterns from a real capture and guards against over-stripping (prose that mentions model names is preserved). 2. **Document idleThresholdSecs as the per-agent idle escape hatch.** The option is already plumbed end-to-end (AgentOptions → constraints → broker spawn arg) but the JSDoc was one line and didn't mention that `0` disables idle detection — so users hitting "agent exited before the awaited event" had no idea how to fix it. Expanded the doc with when-to-override / when-not-to-override guidance pointing at the skill. 3. **Stop warning about .agent-relay/ on every ephemeral run.** The broker warned "stale .agent-relay/ directory found" whenever an ephemeral run found a .agent-relay/ dir in cwd. But the SDK workflow runner *always* writes .agent-relay/step-outputs/ and .agent-relay/ team/worker-logs/ regardless of broker mode — those are durable artifacts, not broker state. The warning fired on virtually every workflow run as a false positive. Narrowed the check to look for .agent-relay/state.json specifically, which is the only file that indicates actual prior broker state worth warning about. All three are non-breaking. Pre-existing test failures in verification-traceback.test.ts and verification-custom.test.ts are unrelated and present on origin/main. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(broker): match all state-{name}.json files in stale-state check PR feedback from Devin Review on #930: the prior commit checked for .agent-relay/state.json, but ensure_runtime_paths in runtime/paths.rs writes state files as state-{safe_name}.json (where safe_name is the sanitized broker name) — only ensure_ephemeral_paths writes a bare state.json, and that's to a temp dir, never to .agent-relay/. As a result the stale-state warning would never fire for any persist- mode run, silently defeating the intent of the check. Fix: glob .agent-relay/ for any state-*.json entry instead of looking for a single hardcoded name. Surface every match so the user can see exactly what's stale regardless of which broker name produced it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style: auto-format Rust code with cargo fmt * fix: address 3 review finding(s) runner.ts: packages/sdk/src/workflows/runner.ts runner.ts: packages/sdk/src/workflows/runner.ts channel-messenger.ts: packages/sdk/src/workflows/channel-messenger.ts Co-Authored-By: My Senior Dev <dev@myseniordev.com> --------- Co-authored-by: Proactive Runtime Bot <agent@agent-relay.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent f5f62e0 commit 0ec31de

7 files changed

Lines changed: 265 additions & 150 deletions

File tree

.trajectories/index.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"version": 1,
3-
"lastUpdated": "2026-05-20T08:00:25.256Z",
3+
"lastUpdated": "2026-05-20T11:46:17.517Z",
44
"trajectories": {
55
"traj_05xg7j388bc4": {
66
"title": "Add browser workflow step integration",
@@ -1096,6 +1096,13 @@
10961096
"startedAt": "2026-05-19T12:34:36.057Z",
10971097
"completedAt": "2026-05-19T12:47:18.115Z",
10981098
"path": "/home/runner/work/relay/relay/.trajectories/completed/2026-05/traj_gnqvtoxtc8dy.json"
1099+
},
1100+
"traj_af7iew24eiip": {
1101+
"title": "autofix-swarm-Agentworkforce-relay-workflow",
1102+
"status": "completed",
1103+
"startedAt": "2026-05-20T11:36:34.306Z",
1104+
"completedAt": "2026-05-20T11:46:17.506Z",
1105+
"path": "/Users/khaliqgant/Projects/AgentWorkforce/.msd-autofix-1bdf6c0b/.trajectories/completed/2026-05/traj_af7iew24eiip.json"
10991106
}
11001107
}
1101-
}
1108+
}

crates/broker/src/runtime/init.rs

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,43 @@ pub(crate) async fn run_init(cmd: InitCommand, telemetry: TelemetryClient) -> Re
3232
let paths = if cmd.persist || custom_state_dir.is_some() {
3333
ensure_runtime_paths(&runtime_cwd, &resolved_name, custom_state_dir.as_deref())?
3434
} else {
35-
// Warn if a stale .agent-relay/ dir exists from a previous persist run.
36-
// Agents can read files from it directly (logs, state) and get confused.
35+
// Warn only if there is *actual broker state* in .agent-relay/ from a
36+
// prior `--persist` run that could confuse this ephemeral run.
37+
//
38+
// The SDK workflow runner ALWAYS writes .agent-relay/step-outputs/ and
39+
// .agent-relay/team/worker-logs/ regardless of broker mode (those are
40+
// durable artifacts, not broker state), so a bare directory check fires
41+
// on virtually every workflow run — a noisy false positive.
42+
//
43+
// The discriminator is the broker's state file. `ensure_runtime_paths`
44+
// (the persist-mode helper in runtime/paths.rs) writes it as
45+
// `state-{safe_name}.json`, where `safe_name` is the sanitized broker
46+
// name — so the exact filename varies by run. Glob for any
47+
// `state-*.json` entry in `.agent-relay/` and surface every match so
48+
// the user can see exactly what's stale regardless of broker name.
3749
let stale_dir = runtime_cwd.join(".agent-relay");
38-
if stale_dir.exists() {
39-
eprintln!(
40-
"[agent-relay] WARNING: stale .agent-relay/ directory found in {}",
41-
runtime_cwd.display()
42-
);
50+
let stale_state_files: Vec<PathBuf> = std::fs::read_dir(&stale_dir)
51+
.ok()
52+
.into_iter()
53+
.flatten()
54+
.filter_map(|entry| entry.ok())
55+
.filter(|entry| {
56+
let name = entry.file_name();
57+
let name_str = name.to_string_lossy();
58+
name_str.starts_with("state-") && name_str.ends_with(".json")
59+
})
60+
.map(|entry| entry.path())
61+
.collect();
62+
if !stale_state_files.is_empty() {
4363
eprintln!(
44-
"[agent-relay] WARNING: remove it to avoid confusing spawned agents: rm -rf {}",
64+
"[agent-relay] WARNING: this run is ephemeral but {} prior --persist state file(s) remain in {}:",
65+
stale_state_files.len(),
4566
stale_dir.display()
4667
);
68+
for state_file in &stale_state_files {
69+
eprintln!("[agent-relay] WARNING: {}", state_file.display());
70+
}
71+
eprintln!("[agent-relay] WARNING: remove them to avoid confusing spawned agents.");
4772
}
4873
ensure_ephemeral_paths(&runtime_cwd, &resolved_name)?
4974
};

packages/sdk/src/workflows/__tests__/channel-messenger.test.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@ describe('channel messenger helpers', () => {
3030
expect(formatStepOutput('plan', output)).toBe('**[plan] Output:**\n```\nuseful line\n```');
3131
});
3232

33+
it('formatStepOutput strips malformed PTY frames through the shared scrubber', () => {
34+
const output = ['real result', 'qW0 | q0 / ql0 _ qqm ~ lqq = qW0 | q0 / ql0 _ qqm', 'done'].join('\n');
35+
expect(formatStepOutput('plan', output)).toBe('**[plan] Output:**\n```\nreal result\ndone\n```');
36+
});
37+
38+
it('formatStepOutput redacts secrets through the shared scrubber', () => {
39+
const output = 'deploy succeeded\naccess_token=ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ\n';
40+
const formatted = formatStepOutput('deploy', output);
41+
expect(formatted).toContain('[REDACTED]');
42+
expect(formatted).not.toContain('ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ');
43+
});
44+
3345
it('formatError normalizes unknown errors', () => {
3446
expect(formatError('build', new Error('Boom'))).toBe('**[build]** Failed: Boom');
3547
expect(formatError('build', 'bad input')).toBe('**[build]** Failed: bad input');
@@ -47,12 +59,8 @@ describe('ChannelMessenger', () => {
4759

4860
it('lists non-interactive agents with step references', () => {
4961
const messenger = new ChannelMessenger();
50-
const agents = new Map([
51-
['bg-worker', { name: 'bg-worker', cli: 'claude', interactive: false }],
52-
]);
53-
const stepStates = new Map([
54-
['analyze', { row: { agentName: 'bg-worker', status: 'running' } }],
55-
]);
62+
const agents = new Map([['bg-worker', { name: 'bg-worker', cli: 'claude', interactive: false }]]);
63+
const stepStates = new Map([['analyze', { row: { agentName: 'bg-worker', status: 'running' } }]]);
5664
const result = messenger.buildNonInteractiveAwareness(agents as any, stepStates as any);
5765
expect(result).toContain('bg-worker');
5866
expect(result).toContain('{{steps.analyze.output}}');
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* Regression tests for WorkflowRunner.scrubForChannel — the function that
3+
* strips PTY/TUI chrome from interactive-agent step output before it gets
4+
* surfaced in workflow logs and channel messages.
5+
*
6+
* The patterns covered here are taken from a real captured run of a
7+
* multi-turn workflow against Claude Code's PTY: when its TUI footer
8+
* overwrites itself faster than the PTY flushes whitespace, lines like
9+
* `bypasspermissionson`, `--INSERT--⏵⏵`, and `Opus 4.7 (1M context) ctx:5%
10+
* $1.45` end up in the captured stream. Before these regex additions, the
11+
* step "Output:" block was unreadable on interactive-agent steps.
12+
*/
13+
import { describe, it, expect } from 'vitest';
14+
15+
import { WorkflowRunner } from '../runner.js';
16+
17+
// scrubForChannel is `private static` — the cast is the minimal-invasive way
18+
// to exercise it from a test without exporting an internal-only helper.
19+
const scrub = (text: string): string =>
20+
(WorkflowRunner as unknown as { scrubForChannel(t: string): string }).scrubForChannel(text);
21+
22+
describe('WorkflowRunner.scrubForChannel — PTY chrome stripping', () => {
23+
it('strips the Claude Code bottom status bar (model + ctx% + cost)', () => {
24+
const input = [
25+
'real content line',
26+
'workflows git:(main) Opus 4.7 (1M context) ctx:5% $1.45',
27+
'Opus4.7(1Mcontext) ctx:6% $1.54',
28+
'another real line',
29+
].join('\n');
30+
const out = scrub(input);
31+
expect(out).toContain('real content line');
32+
expect(out).toContain('another real line');
33+
expect(out).not.toMatch(/ctx\s*:\s*\d+%/);
34+
expect(out).not.toMatch(/\$\d+\.\d+/);
35+
});
36+
37+
it('strips vim-style mode indicators emitted by the input bar', () => {
38+
const input = [
39+
'pre-mode line',
40+
'--INSERT--',
41+
'--INSERT--⏵⏵bypasspermissionson (shift+tabtocycle)',
42+
'post-mode line',
43+
].join('\n');
44+
const out = scrub(input);
45+
expect(out).toContain('pre-mode line');
46+
expect(out).toContain('post-mode line');
47+
expect(out).not.toMatch(/--INSERT--/);
48+
});
49+
50+
it('strips no-whitespace TUI hint variants (bypasspermissionson, pasteagaintoexpand)', () => {
51+
const input = ['before', 'bypasspermissionson', 'pasteagaintoexpand', 'shifttabto cycle', 'after'].join(
52+
'\n'
53+
);
54+
const out = scrub(input);
55+
expect(out).toContain('before');
56+
expect(out).toContain('after');
57+
expect(out).not.toMatch(/bypasspermissionson/);
58+
expect(out).not.toMatch(/pasteagaintoexpand/);
59+
});
60+
61+
it('strips thinking-status fragments without ellipsis anchors', () => {
62+
const input = [
63+
'meaningful: round 3 codex-player guess=19 feedback=correct',
64+
'thinking with high effort',
65+
'↓ 13 tokens · thinking with high effort',
66+
'Crunched for 32s',
67+
'Sautéed for 4s',
68+
'Gitifying…55',
69+
].join('\n');
70+
const out = scrub(input);
71+
expect(out).toContain('feedback=correct');
72+
expect(out).not.toMatch(/thinking with high effort/);
73+
expect(out).not.toMatch(/Crunched for/);
74+
expect(out).not.toMatch(/Gitifying/);
75+
});
76+
77+
it('strips malformed overwritten q0/qW0 PTY frame runs', () => {
78+
const input = [
79+
'first useful line',
80+
'qW0 | q0 / ql0 _ qqm ~ lqq = qW0 | q0 / ql0 _ qqm',
81+
'summary: kept qW0 | q0 / ql0 _ qqm ~ lqq = qW0 | q0 done',
82+
'last useful line',
83+
].join('\n');
84+
const out = scrub(input);
85+
expect(out).toContain('first useful line');
86+
expect(out).toContain('last useful line');
87+
expect(out).toMatch(/summary: kept\s+done/);
88+
expect(out).not.toMatch(/qW0|ql0|qqm|lqq/);
89+
});
90+
91+
it('redacts secrets in the runner public preview path', () => {
92+
const out = scrub('deploy succeeded\napi_key=sk-abcdefghijklmnopqrstuvwxyz123456\n');
93+
expect(out).toContain('deploy succeeded');
94+
expect(out).toContain('[REDACTED]');
95+
expect(out).not.toContain('sk-abcdefghijklmnopqrstuvwxyz123456');
96+
});
97+
98+
it('preserves real content and OWNER_DECISION signals', () => {
99+
const input = [
100+
'Read 1 file, calling relaycast 2 times',
101+
'Transcript verification reports TRANSCRIPT_OK with all 6 lines well-formed.',
102+
'OWNER_DECISION: COMPLETE',
103+
'REASON: All 6 turns executed, history.log has 6 lines.',
104+
'STEP_COMPLETE: repair-transcript',
105+
].join('\n');
106+
const out = scrub(input);
107+
expect(out).toContain('TRANSCRIPT_OK');
108+
expect(out).toContain('OWNER_DECISION: COMPLETE');
109+
expect(out).toContain('STEP_COMPLETE: repair-transcript');
110+
expect(out).toContain('All 6 turns executed');
111+
});
112+
113+
it('does not strip lines that merely mention model names in prose', () => {
114+
// Guard against the new claudeFooterRe (which looks for `Opus|Sonnet|Haiku <num>
115+
// (...context...) ctx:N%`) being too eager and removing prose that
116+
// mentions a model name.
117+
const input = [
118+
'Compared output from Opus 4.7 against Sonnet 4.6 — both passed.',
119+
'We chose Haiku 4.5 for its latency profile.',
120+
].join('\n');
121+
const out = scrub(input);
122+
expect(out).toContain('Opus 4.7 against Sonnet 4.6');
123+
expect(out).toContain('Haiku 4.5 for its latency profile');
124+
});
125+
});

packages/sdk/src/workflows/builder.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,25 @@ export interface AgentOptions {
4141
maxTokens?: number;
4242
timeoutMs?: number;
4343
retries?: number;
44-
/** Seconds of silence before considering the agent idle (for idle nudging). */
44+
/**
45+
* Seconds of silence on the agent's PTY before the runtime marks it idle and
46+
* tears it down. Default: 30s. Set to `0` to disable idle detection entirely.
47+
*
48+
* When to override (per-agent):
49+
* - You expect long quiet stretches by design — a long-running reviewer
50+
* waiting for downstream verdicts, a grader watching a file that updates
51+
* every few minutes, or a `@-mention` recipient whose triggering event
52+
* may arrive >30s after spawn. Setting `0` (or a generous N) prevents
53+
* the runtime from killing the agent before the awaited event arrives.
54+
*
55+
* When NOT to override:
56+
* - One-shot worker steps. The default is right; idle-as-complete is what
57+
* makes `OWNER_DECISION: COMPLETE` + clean exit fast.
58+
*
59+
* See the `writing-agent-relay-workflows` skill ("Idle detection beats
60+
* 'wait for X' prompts") for the trade-offs around long-running interactive
61+
* agents and the Per-turn interactive spawn alternative.
62+
*/
4563
idleThresholdSecs?: number;
4664
/** When false, the agent runs as a non-interactive subprocess (no PTY, no relay messaging).
4765
* Default: true. */

packages/sdk/src/workflows/channel-messenger.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,23 @@ const CLAUDE_HEADER_RE =
7171
/^(?:[\s\u2580-\u259f*·]+\s*)?(?:Claude\s+Code(?:\s+v?[\d.]+)?|(?:Sonnet|Haiku|Opus)\s*[\d.]+|claude-(?:sonnet|haiku|opus)-[\w.-]+|Running\s+on\s+claude)/iu;
7272
const DIR_BREADCRUMB_RE = /^\s*~[\\/]/u;
7373
const UI_HINT_RE =
74-
/\b(?:Press\s+up\s+to\s+edit|tab\s+to\s+queue|bypass\s+permissions|esc\s+to\s+interrupt)\b/iu;
74+
/\b(?:Press\s*up\s*to\s*edit|tab\s*to\s*queue|bypass\s*permissions|esc\s*to\s*interrupt|paste\s*again\s*to\s*expand|shift\s*[+]?\s*tab\s*to\s*cycle|running\s+stop\s+hook|fan\s+out\s+subagents)/iu;
75+
const VIM_MODE_RE =
76+
/^[-\s]*--?(?:INSERT|NORMAL|VISUAL|REPLACE)--?[-\s]*$|--?(?:INSERT|NORMAL|VISUAL|REPLACE)--/u;
77+
const CLAUDE_FOOTER_RE =
78+
/(?:Opus|Sonnet|Haiku)\s*\d[\d.]*\s*\(?(?:1M\s*context|context)?\)?\s*ctx\s*:\s*\d+%/iu;
7579
const THINKING_LINE_RE = new RegExp(`^[\\s${SPINNER}]*\\s*\\w[\\w\\s]*\\u2026\\s*$`, 'u');
80+
const THINKING_STATUS_RE =
81+
/\b(?:thinking\s+(?:with\s+\w+\s+effort|more\s+with|harder)|\s*\d+\s*tokens?\b|\s*\d+\s*tokens?\b|crunched\s+for\s+\d|sautéed\s+for\s+\d|befuddl|flibbertigib|gitifying|flowing\s*)/iu;
7682
const CURSOR_ONLY_RE = /^[\s»·]+$/u;
7783
const CURSOR_AGENT_RE =
7884
/^(?:Cursor Agent|[\s]*Generating[.\s]|\[Pasted text|Auto-run all|Add a follow-up|ctrl\+c to stop|shift\+tab|Auto$|\/\s*commands|@\s*files|!\s*shell|follow-ups?\s|The user ha)/iu;
7985
const SLASH_COMMAND_RE = /^\/\w+\s*$/u;
8086
const MCP_JSON_KV_RE =
8187
/^\s*"(?:type|method|params|result|id|jsonrpc|tool|name|arguments|content|role|metadata)"\s*:/u;
8288
const MEANINGFUL_CONTENT_RE = /[a-zA-Z0-9]/u;
89+
const MALFORMED_PTY_FRAME_RUN_RE = /(?:(?:qW0|q[A-Za-z]?0|[lmjkx]q{2,}|q{2,}[lmjkx]?)[\s|/_=\-~]*){4,}/giu;
90+
const MALFORMED_PTY_FRAME_ONLY_RE = /^[\s|/_=\-~lmjkxqtwuvn0W]{12,}$/iu;
8391

8492
export function scrubSecrets(text: string): string {
8593
let result = text;
@@ -89,6 +97,15 @@ export function scrubSecrets(text: string): string {
8997
return result;
9098
}
9199

100+
function stripMalformedPtyFrameGarbage(line: string): string {
101+
const strippedRuns = line.replace(MALFORMED_PTY_FRAME_RUN_RE, ' ');
102+
const compact = strippedRuns.replace(SPINNER_RE, '').replace(/\s+/g, '');
103+
if (compact.length >= 12 && MALFORMED_PTY_FRAME_ONLY_RE.test(compact)) {
104+
return '';
105+
}
106+
return strippedRuns;
107+
}
108+
92109
export function scrubForChannel(text: string): string {
93110
// Strip system-reminder blocks (closed or unclosed) iteratively to avoid
94111
// polynomial backtracking (ReDoS) with [\s\S]*? on adversarial input.
@@ -130,29 +147,33 @@ export function scrubForChannel(text: string): string {
130147
let jsonDepth = 0;
131148

132149
for (const line of lines) {
133-
const trimmed = line.trim();
150+
const cleanedLine = stripMalformedPtyFrameGarbage(line);
151+
const trimmed = cleanedLine.trim();
134152

135153
if (jsonDepth > 0) {
136-
jsonDepth += countJsonDepth(line);
154+
jsonDepth += countJsonDepth(cleanedLine);
137155
if (jsonDepth <= 0) jsonDepth = 0;
138156
continue;
139157
}
140158

141159
if (trimmed.length === 0) continue;
142160

143161
if (trimmed.startsWith('{') || /^\[\s*\{/.test(trimmed)) {
144-
jsonDepth = Math.max(countJsonDepth(line), 0);
162+
jsonDepth = Math.max(countJsonDepth(cleanedLine), 0);
145163
continue;
146164
}
147165

148-
if (MCP_JSON_KV_RE.test(line)) continue;
166+
if (MCP_JSON_KV_RE.test(cleanedLine)) continue;
149167
if (SPINNER_CLASS_RE.test(trimmed)) continue;
150168
if (BOX_DRAWING_ONLY_RE.test(trimmed)) continue;
151169
if (BROKER_LOG_RE.test(trimmed)) continue;
152170
if (CLAUDE_HEADER_RE.test(trimmed)) continue;
153171
if (DIR_BREADCRUMB_RE.test(trimmed)) continue;
154172
if (UI_HINT_RE.test(trimmed)) continue;
173+
if (VIM_MODE_RE.test(trimmed)) continue;
174+
if (CLAUDE_FOOTER_RE.test(trimmed)) continue;
155175
if (THINKING_LINE_RE.test(trimmed)) continue;
176+
if (THINKING_STATUS_RE.test(trimmed)) continue;
156177
if (CURSOR_ONLY_RE.test(trimmed)) continue;
157178
if (CURSOR_AGENT_RE.test(trimmed)) continue;
158179
if (SLASH_COMMAND_RE.test(trimmed)) continue;
@@ -161,7 +182,7 @@ export function scrubForChannel(text: string): string {
161182
const alphanum = trimmed.replace(SPINNER_RE, '').replace(/\s+/g, '');
162183
if (alphanum.replace(/[^a-zA-Z0-9]/g, '').length <= 3) continue;
163184

164-
meaningful.push(line);
185+
meaningful.push(cleanedLine);
165186
}
166187

167188
return meaningful

0 commit comments

Comments
 (0)