mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
* feat(agent-status): show Claude subagent child rows and gate premature done A Claude pane that spawned background subagents/teammates showed a green done check the moment the lead's turn ended, even while a background review loop was still running. Orca now tracks the pane's live children from Claude hook events and: - keeps the pane 'working' while at least one child is working (Stop is gated; Claude wakes the lead when a child finishes, so the pane resolves to done on the follow-up Stop with an empty roster) - renders the children as indented child rows under the pane's sidebar row (name/type + working/idle dot), reusing the existing lineage UI Tracking is lifecycle-primary: SubagentStart/SubagentStop/TeammateIdle (newly registered hooks) plus child-origin tool events (they carry agent_id) own the roster. Stop's background_tasks is folded only where unambiguous — verified live on Claude Code 2.1.207 that teammates report status "running" while idle-alive and their task ids never match lifecycle agent_ids, so the list cannot decide teammate working-ness. Child-origin events no longer overwrite the lead's tool/prompt caches (a live AskUserQuestion card survives child churn); a child's own PermissionRequest records waitingAgentId so only that child's progress or death clears the wait. The interrupted flag survives the gated window, inferred interrupts sync the lead record and refuse while a child works, and hydration reseeds the roster after a restart. * fix(agent-status): drop identity icon on subagent child rows The child's agentType carries its NAME (e.g. "pr-reviewer"), which is not an iconable agent and rendered the unknown "?" glyph. Nesting under the parent row already conveys identity. * fix(agent-status): restore displaced lead state and reconcile phantom subagents Four review findings from the adversarial pass on the subagent child-row feature: - Stash the lead state a child-induced wait displaces (ClaudeLeadTurnState.stateBeforeWait) and restore it when the wait clears, instead of inventing 'working' — a lead that had already stopped left the pane spinning forever after the roster drained, since the done-gate only ever downgrades done → working. - Tag snapshot-seeded and background_tasks-recreated roster entries (backgroundTasksAuthoritative) and demote them when a PRESENT background_tasks list omits their id. A phantom child seeded before a restart could otherwise gate the pane 'working' indefinitely in teams sessions, whose task list is never empty. Live activity clears the tag so lifecycle-tracked teammates keep their state. - Match teammate ids with a hyphen-free suffix after `a<name>-` so TeammateIdle for "lane" cannot idle "lane-hooks"'s rows or clear its pending permission wait. - Route turn-boundary events (Stop/StopFailure/UserPromptSubmit) that carry a KNOWN child agent_id through the child-driven re-emit instead of adopting them as lead state, and tie the prompt-cache new-turn reset to lead-origin events so child refreshes can't blank the prompt label. --------- Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
214 lines
7.5 KiB
TypeScript
214 lines
7.5 KiB
TypeScript
// ─── Agent status field normalization ───────────────────────────────────────
|
|
// String normalizers shared by every agent-status payload field: trim/fold to
|
|
// a single line for previews, preserve structure for multiline bodies, and
|
|
// truncate without splitting surrogate pairs. Extracted from
|
|
// agent-status-types.ts, which owns the payload shapes and per-field caps.
|
|
|
|
import {
|
|
compactDispatchPromptForStatus,
|
|
isOrcaDispatchStatusPrompt
|
|
} from './orca-dispatch-status-prompt'
|
|
|
|
/** Maximum character length for the prompt field. Truncated on parse. */
|
|
export const AGENT_STATUS_MAX_FIELD_LENGTH = 200
|
|
|
|
const SINGLE_LINE_FIELD_SCAN_OVERHEAD = 64
|
|
const SINGLE_LINE_FIELD_SCAN_MULTIPLIER = 8
|
|
|
|
// Why: when truncation lands mid surrogate-pair (emoji / astral chars), the
|
|
// high surrogate would be left dangling and render as the Unicode replacement
|
|
// glyph. Drop the lone high surrogate so the result is always a valid UTF-16
|
|
// sequence. Shared by the single-line and multiline normalizers so the
|
|
// protection can't drift between them.
|
|
function truncatePreservingSurrogates(value: string, maxLength: number): string {
|
|
if (value.length < maxLength) {
|
|
return value
|
|
}
|
|
let truncated = value.length === maxLength ? value : value.slice(0, maxLength)
|
|
const lastCode = truncated.charCodeAt(truncated.length - 1)
|
|
if (lastCode >= 0xd800 && lastCode <= 0xdbff) {
|
|
truncated = truncated.slice(0, -1)
|
|
}
|
|
return truncated
|
|
}
|
|
|
|
/** Normalize a status field: trim, collapse to single line, truncate. */
|
|
function normalizeField(value: unknown, maxLength: number = AGENT_STATUS_MAX_FIELD_LENGTH): string {
|
|
if (typeof value !== 'string') {
|
|
return ''
|
|
}
|
|
return normalizeSingleLinePreview(value, maxLength)
|
|
}
|
|
|
|
/** Normalize the agent prompt field, compacting Orca dispatch preambles. */
|
|
export function normalizePromptField(value: unknown): string {
|
|
if (typeof value !== 'string') {
|
|
return ''
|
|
}
|
|
if (isOrcaDispatchStatusPrompt(value)) {
|
|
return compactDispatchPromptForStatus(
|
|
value,
|
|
AGENT_STATUS_MAX_FIELD_LENGTH,
|
|
normalizeSingleLinePreview
|
|
)
|
|
}
|
|
return normalizeSingleLinePreview(value, AGENT_STATUS_MAX_FIELD_LENGTH)
|
|
}
|
|
|
|
function normalizeSingleLinePreview(value: string, maxLength: number): string {
|
|
// Why: hook prompt/tool fields are previews. Bound the source scan before
|
|
// folding line breaks so paste-sized status text cannot run a full regex
|
|
// replacement just to keep a small dashboard label.
|
|
const scanEnd = Math.min(
|
|
value.length,
|
|
maxLength * SINGLE_LINE_FIELD_SCAN_MULTIPLIER + SINGLE_LINE_FIELD_SCAN_OVERHEAD
|
|
)
|
|
let index = 0
|
|
while (index < scanEnd && isEcmaTrimWhitespace(value.charCodeAt(index))) {
|
|
index++
|
|
}
|
|
|
|
let normalized = ''
|
|
let lineSeparatorRun = false
|
|
while (index < scanEnd && normalized.length < maxLength) {
|
|
const code = value.charCodeAt(index)
|
|
if (isSingleLineSeparator(code)) {
|
|
if (code === 13 && value.charCodeAt(index + 1) === 10) {
|
|
index++
|
|
}
|
|
if (!lineSeparatorRun) {
|
|
normalized += ' '
|
|
}
|
|
lineSeparatorRun = true
|
|
index++
|
|
continue
|
|
}
|
|
|
|
normalized += value[index]
|
|
lineSeparatorRun = false
|
|
index++
|
|
}
|
|
|
|
if (normalized.length < maxLength) {
|
|
normalized = trimTrailingWhitespace(normalized)
|
|
}
|
|
return truncatePreservingSurrogates(normalized, maxLength)
|
|
}
|
|
|
|
// Why: assistant messages are a multi-paragraph "what did the agent say"
|
|
// body that the dashboard renders with `whitespace-pre-wrap`. Collapsing
|
|
// newlines here would erase structure the UI is designed to show. Still
|
|
// normalize `\r\n` → `\n` and cap paragraph gaps at one blank line to keep
|
|
// the bound meaningful, but otherwise preserve line breaks.
|
|
function normalizeMultilineField(value: unknown, maxLength: number): string {
|
|
if (typeof value !== 'string') {
|
|
return ''
|
|
}
|
|
// Why: fold Unicode line/paragraph separators (U+2028, U+2029) into ordinary
|
|
// `\n` before the blank-line-run cap. These code points render as real line
|
|
// breaks under `whitespace-pre-wrap`, so leaving them untouched would let a
|
|
// buggy/malicious agent bypass the `\n{3,}` → `\n\n` safeguard by spamming
|
|
// arbitrarily many U+2029 paragraph breaks. Matches the single-line
|
|
// normalizer's treatment of the same code points, keeping the two paths in
|
|
// sync. Step order preserved: `\r\n` → `\n`, bare `\r` → `\n`,
|
|
// U+2028/U+2029 → `\n`, then collapse blank-line runs.
|
|
const { start, end } = getTrimmedStringBounds(value)
|
|
let normalized = ''
|
|
let newlineRun = 0
|
|
for (let index = start; index < end && normalized.length < maxLength; index++) {
|
|
const code = value.charCodeAt(index)
|
|
if (code === 13 || code === 10 || code === 0x2028 || code === 0x2029) {
|
|
if (code === 13 && value.charCodeAt(index + 1) === 10) {
|
|
index++
|
|
}
|
|
if (newlineRun < 2) {
|
|
normalized += '\n'
|
|
}
|
|
newlineRun++
|
|
continue
|
|
}
|
|
|
|
normalized += value[index]
|
|
newlineRun = 0
|
|
}
|
|
return truncatePreservingSurrogates(normalized, maxLength)
|
|
}
|
|
|
|
function getTrimmedStringBounds(value: string): { start: number; end: number } {
|
|
let start = 0
|
|
let end = value.length
|
|
while (start < end && isEcmaTrimWhitespace(value.charCodeAt(start))) {
|
|
start++
|
|
}
|
|
while (end > start && isEcmaTrimWhitespace(value.charCodeAt(end - 1))) {
|
|
end--
|
|
}
|
|
return { start, end }
|
|
}
|
|
|
|
function trimTrailingWhitespace(value: string): string {
|
|
let end = value.length
|
|
while (end > 0 && isEcmaTrimWhitespace(value.charCodeAt(end - 1))) {
|
|
end--
|
|
}
|
|
return end === value.length ? value : value.slice(0, end)
|
|
}
|
|
|
|
function isSingleLineSeparator(code: number): boolean {
|
|
return code === 13 || code === 10 || code === 0x2028 || code === 0x2029
|
|
}
|
|
|
|
function isEcmaTrimWhitespace(code: number): boolean {
|
|
return (
|
|
code === 0x20 ||
|
|
(code >= 0x09 && code <= 0x0d) ||
|
|
code === 0xa0 ||
|
|
code === 0x1680 ||
|
|
(code >= 0x2000 && code <= 0x200a) ||
|
|
code === 0x2028 ||
|
|
code === 0x2029 ||
|
|
code === 0x202f ||
|
|
code === 0x205f ||
|
|
code === 0x3000 ||
|
|
code === 0xfeff
|
|
)
|
|
}
|
|
|
|
// Why: tool/assistant fields are optional on the entry (absence = "no update
|
|
// for this field"). We only surface them when the caller actually provided a
|
|
// string value so a missing field doesn't overwrite the prior cached state.
|
|
// Why: interactivePrompt carries raw JSON (`{ questions: [...] }`) that clients
|
|
// JSON.parse to render a structured card. Unlike the other normalizers we must
|
|
// NOT trim, collapse newlines, or fold blank-line runs — any of those would
|
|
// corrupt the JSON or alter option text inside it. Only guard the length cap
|
|
// (preserving surrogate pairs) and drop empty strings to undefined.
|
|
export function normalizeInteractivePromptField(
|
|
value: unknown,
|
|
maxLength: number
|
|
): string | undefined {
|
|
if (typeof value !== 'string' || value.length === 0) {
|
|
return undefined
|
|
}
|
|
const truncated = truncatePreservingSurrogates(value, maxLength)
|
|
return truncated.length > 0 ? truncated : undefined
|
|
}
|
|
|
|
export function normalizeOptionalField(value: unknown, maxLength: number): string | undefined {
|
|
if (typeof value !== 'string') {
|
|
return undefined
|
|
}
|
|
const normalized = normalizeField(value, maxLength)
|
|
return normalized.length > 0 ? normalized : undefined
|
|
}
|
|
|
|
export function normalizeOptionalMultilineField(
|
|
value: unknown,
|
|
maxLength: number
|
|
): string | undefined {
|
|
if (typeof value !== 'string') {
|
|
return undefined
|
|
}
|
|
const normalized = normalizeMultilineField(value, maxLength)
|
|
return normalized.length > 0 ? normalized : undefined
|
|
}
|