[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
208
infrastructure/ai/harness/terminalMonitorGuard.ts
Normal file
208
infrastructure/ai/harness/terminalMonitorGuard.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { compressVerboseText } from '../requestPayloadCompression';
|
||||
|
||||
const MONITOR_LINE_MAX_CHARS = 500;
|
||||
const MONITOR_BATCH_MAX_CHARS = 3_000;
|
||||
const MONITOR_BURST = 10;
|
||||
const MONITOR_REFILL_MS = 2_000;
|
||||
const MONITOR_OVERLOAD_STOP_MS = 30_000;
|
||||
|
||||
interface MonitorState {
|
||||
tokens: number;
|
||||
lastRefillAt: number;
|
||||
overloadedAt?: number;
|
||||
lastSuppressedAt?: number;
|
||||
suppressedCount: number;
|
||||
}
|
||||
|
||||
export type MonitorGuardResult =
|
||||
| { action: 'deliver'; content: string; suppressedCount: number; sourceTruncated: boolean }
|
||||
| { action: 'suppress'; suppressedCount: number }
|
||||
| { action: 'stop'; suppressedCount: number };
|
||||
|
||||
export class TerminalMonitorGuard {
|
||||
private readonly states = new Map<string, MonitorState>();
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: { now?: () => number } = {}) {
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
process(key: string, output: string): MonitorGuardResult {
|
||||
const now = this.now();
|
||||
const state = this.states.get(key) ?? {
|
||||
tokens: MONITOR_BURST,
|
||||
lastRefillAt: now,
|
||||
suppressedCount: 0,
|
||||
};
|
||||
if (state.lastSuppressedAt != null && now - state.lastSuppressedAt > MONITOR_REFILL_MS * 2) {
|
||||
state.overloadedAt = undefined;
|
||||
state.lastSuppressedAt = undefined;
|
||||
state.suppressedCount = 0;
|
||||
}
|
||||
if (state.overloadedAt != null && now - state.overloadedAt >= MONITOR_OVERLOAD_STOP_MS) {
|
||||
this.states.delete(key);
|
||||
return { action: 'stop', suppressedCount: state.suppressedCount + 1 };
|
||||
}
|
||||
|
||||
const refill = Math.floor((now - state.lastRefillAt) / MONITOR_REFILL_MS);
|
||||
if (refill > 0) {
|
||||
state.tokens = Math.min(MONITOR_BURST, state.tokens + refill);
|
||||
state.lastRefillAt += refill * MONITOR_REFILL_MS;
|
||||
}
|
||||
if (state.tokens <= 0) {
|
||||
state.suppressedCount += 1;
|
||||
state.overloadedAt ??= now;
|
||||
state.lastSuppressedAt = now;
|
||||
this.states.set(key, state);
|
||||
return { action: 'suppress', suppressedCount: state.suppressedCount };
|
||||
}
|
||||
|
||||
state.tokens -= 1;
|
||||
const suppressedCount = state.suppressedCount;
|
||||
state.suppressedCount = 0;
|
||||
this.states.set(key, state);
|
||||
const prefix = suppressedCount > 0 ? `[${suppressedCount} monitor batches suppressed]\n` : '';
|
||||
const fitted = fitMonitorBatch(`${prefix}${output}`);
|
||||
return {
|
||||
action: 'deliver',
|
||||
content: fitted.content,
|
||||
suppressedCount,
|
||||
sourceTruncated: fitted.sourceTruncated,
|
||||
};
|
||||
}
|
||||
|
||||
clear(key: string): void {
|
||||
this.states.delete(key);
|
||||
}
|
||||
|
||||
clearPrefix(prefix: string): void {
|
||||
for (const key of this.states.keys()) {
|
||||
if (key.startsWith(prefix)) this.states.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isStreamingMonitorCommand(command: unknown): boolean {
|
||||
if (typeof command !== 'string') return false;
|
||||
return command.split(/[;&|]+/).some((rawSegment) => {
|
||||
const tokens = unwrapMonitorCommandPrefixes(tokenizeCommandSegment(rawSegment));
|
||||
if (tokens.length === 0) return false;
|
||||
if (tokens[0]?.toLowerCase() === 'watch') return true;
|
||||
|
||||
const argsStart = findMonitorArgsStart(tokens);
|
||||
if (argsStart < 0) return false;
|
||||
return tokens.slice(argsStart).some(arg => /^--follow(?:=.+)?$/i.test(arg) || /^-[^-\s]*[fF]/.test(arg));
|
||||
});
|
||||
}
|
||||
|
||||
function findMonitorArgsStart(tokens: string[]): number {
|
||||
const command = tokens[0]?.toLowerCase();
|
||||
if (command === 'tail' || command === 'journalctl') return 1;
|
||||
if (command === 'kubectl') {
|
||||
const logsIndex = skipCommandOptions(tokens, 1, new Set([
|
||||
'-n', '--namespace', '--context', '--kubeconfig', '--cluster', '--user',
|
||||
'--request-timeout', '-s', '--server', '--token', '--as', '--as-group',
|
||||
'--cache-dir', '--certificate-authority', '--client-certificate', '--client-key',
|
||||
'--tls-server-name',
|
||||
]));
|
||||
return tokens[logsIndex]?.toLowerCase() === 'logs' ? logsIndex + 1 : -1;
|
||||
}
|
||||
if (command === 'docker') {
|
||||
const subcommandIndex = skipCommandOptions(tokens, 1, new Set([
|
||||
'--config', '-c', '--context', '-H', '--host', '-l', '--log-level',
|
||||
]));
|
||||
const subcommand = tokens[subcommandIndex]?.toLowerCase();
|
||||
if (subcommand === 'logs') return subcommandIndex + 1;
|
||||
if (subcommand !== 'compose') return -1;
|
||||
const logsIndex = skipCommandOptions(tokens, subcommandIndex + 1, new Set([
|
||||
'-f', '--file', '-p', '--project-name', '--profile', '--project-directory',
|
||||
'--env-file', '--parallel', '--progress', '--ansi',
|
||||
]));
|
||||
return tokens[logsIndex]?.toLowerCase() === 'logs' ? logsIndex + 1 : -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function skipCommandOptions(tokens: string[], start: number, optionsWithValues: ReadonlySet<string>): number {
|
||||
let index = start;
|
||||
while (tokens[index]?.startsWith('-')) {
|
||||
const option = tokens[index]!;
|
||||
index += 1;
|
||||
if (option === '--') break;
|
||||
const optionName = canonicalOptionName(option);
|
||||
if (optionsWithValues.has(optionName) && !option.includes('=') && index < tokens.length) index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function tokenizeCommandSegment(segment: string): string[] {
|
||||
return segment.trim().match(/(?:[^\s"'\\]+|"(?:\\.|[^"])*"|'[^']*')+/g) ?? [];
|
||||
}
|
||||
|
||||
function unwrapMonitorCommandPrefixes(input: string[]): string[] {
|
||||
const tokens = [...input];
|
||||
for (let pass = 0; pass < 8 && tokens.length > 0; pass += 1) {
|
||||
const wrapper = tokens[0]?.toLowerCase();
|
||||
if (wrapper === 'sudo') {
|
||||
tokens.shift();
|
||||
consumeWrapperOptions(tokens, new Set([
|
||||
'-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt',
|
||||
'-C', '--close-from', '-R', '--chroot', '-D', '--chdir', '-T', '--command-timeout',
|
||||
'-r', '--role', '-t', '--type',
|
||||
]));
|
||||
continue;
|
||||
}
|
||||
if (wrapper === 'env') {
|
||||
tokens.shift();
|
||||
consumeWrapperOptions(tokens, new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string']));
|
||||
while (tokens[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens.shift();
|
||||
continue;
|
||||
}
|
||||
if (wrapper === 'stdbuf') {
|
||||
tokens.shift();
|
||||
consumeWrapperOptions(tokens, new Set(['-i', '--input', '-o', '--output', '-e', '--error']));
|
||||
continue;
|
||||
}
|
||||
if (wrapper === 'timeout') {
|
||||
tokens.shift();
|
||||
consumeWrapperOptions(tokens, new Set(['-k', '--kill-after', '-s', '--signal']));
|
||||
if (tokens.length > 0) tokens.shift();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function consumeWrapperOptions(tokens: string[], optionsWithValues: ReadonlySet<string>): void {
|
||||
while (tokens[0]?.startsWith('-')) {
|
||||
const option = tokens.shift()!;
|
||||
if (option === '--') break;
|
||||
const optionName = canonicalOptionName(option);
|
||||
if (optionsWithValues.has(optionName) && !option.includes('=') && tokens.length > 0) tokens.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalOptionName(option: string): string {
|
||||
const optionName = option.split('=', 1)[0]!;
|
||||
return optionName.startsWith('--') ? optionName.toLowerCase() : optionName;
|
||||
}
|
||||
|
||||
function fitMonitorBatch(output: string): { content: string; sourceTruncated: boolean } {
|
||||
const normalized = compressVerboseText(output);
|
||||
let sourceTruncated = false;
|
||||
const lines = normalized.split('\n').map(line => {
|
||||
if (line.length <= MONITOR_LINE_MAX_CHARS) return line;
|
||||
sourceTruncated = true;
|
||||
return `${line.slice(0, MONITOR_LINE_MAX_CHARS - 28)}[... line shortened ...]`;
|
||||
});
|
||||
const content = lines.join('\n');
|
||||
if (content.length <= MONITOR_BATCH_MAX_CHARS) return { content, sourceTruncated };
|
||||
const marker = '\n[... monitor batch shortened ...]';
|
||||
return {
|
||||
content: `${content.slice(0, MONITOR_BATCH_MAX_CHARS - marker.length)}${marker}`,
|
||||
sourceTruncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
export const globalTerminalMonitorGuard = new TerminalMonitorGuard();
|
||||
Reference in New Issue
Block a user