Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ Invariants:
- GPUI polish P0 (Wave 6) — toast stack + level policy (`src/chrome/toast*.tsx` / toast-store); EmptyState icon/CTA; focus chords Cmd+L / Cmd+B / Cmd+, / Cmd+J (HID + gpuix); `docs/wave6-gpui-polish.md`; no CopilotKit / gpui-kit; idle parks intact; package 0.15.0.
- GPUI polish P1 (Wave 6) — Cmd+K command palette lite (sisters / rooms / settings sections / Jobs); visible steer-queue card (remove / send-now); shared `ListRow` density + hover end-slot; `Tip` microcopy on titlebar / rail / Jobs; opaque menuFill overlays; idle parks intact; package 0.15.0.
- GPUI polish P2 (Wave 6) — optional Mac OS notify (background mouth complete / needs approval; Quiet + `AUTOMATON_DISABLE_NOTIFICATIONS`); pulse lease strides (MouthWait / Activity slow cadence on expensive surfaces); feed edge-fade spike; Shortcuts help from live `KEYMAP`; `docs/wave6-gpui-polish.md` + idle-cpu honesty; no CopilotKit / gpui-kit; no version cut in P2.
- Wave 7 P0 OpenBot parity — ToolLine mouth/hop feed cards; `ask_person` judgement exit; mouth silence watchdog + sticky stopped banner (`docs/wave7-p0-openbot-parity.md`); patterns only — no CopilotKit / `@ag-ui/*` / generative UI; package 0.15.0 (no tag in P0 PR).
- Bezel light appearance (Wave 2 P2) — designed light (Settings → Window → Appearance; not invert of graphite); Brand-complete Settings (remaining CARD/FIELD via useChrome; update modal scrim); ToggleGroup, EmptyState, Sheet extracted. Still vendored `@gpuix/react` (Wave 3.2 is 0.6.2 + matched native). Package 0.8.0.

## Safety
Expand Down
6 changes: 4 additions & 2 deletions docs/mouth-stream.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ row lands only on the automaton that ran the tool.
`detail` or any stream row. Same honesty as the action ledger and
`docs/secrets.md`.

Paint is a quiet tertiary line in the feed (`decide · box_shell · shell`),
danger-tinted on `refuse`. No new motion libraries.
Paint is a ToolLine-class card (Wave 7 P0): one-line label + detail,
running pulse, refuse/fail tones, expandable phase disclosure. Consecutive
phases for the same tool+intent collapse at paint. No new motion libraries
(reuse pulse lease). See [Wave 7 P0](wave7-p0-openbot-parity.md).

## Action ledger initiator

Expand Down
68 changes: 68 additions & 0 deletions docs/wave7-p0-openbot-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Wave 7 P0 — OpenBot chat-loop parity (patterns only)

**Status:** Implemented on branch `feat/wave-7-p0-toolline-ask-stall`.
**Policy:** Steal **patterns only** from CopilotKit/OpenBot. No `@ag-ui/*`, no
CopilotKit npm, no generative UI / sandboxed iframes. Mouths = OpenRouter;
Jobs = Puppetmaster. Native `@gpuix/react` only. Sisters stay separate.

Do **not** cut a release tag in the P0 PR (v0.16.0 waits on coordinator).

## P0a — ToolLine-class mouth / hop feed cards

| OpenBot | Automaton |
| --- | --- |
| `ToolLine` one-line rhythm + disclosure | `src/chrome/tool-line.tsx` |
| Running shimmer | Pulse lease opacity (`motion` / Wave 6 pulse) |
| Handoff / escalation reuse ToolLine | Hop relays + `ask_person` stream rows |

- Consecutive `mouth-stream` rows that share `tool` + `intent` collapse at
paint (`mouthStreamSuperseded`) so decide→act→done advances one card.
- Sent / from relays with envelope fields paint expandable Task /
Constraints / Expecting / Outcome (paths & policy only — never secrets,
stdout, or file bytes).
- Tones: running / refused / failed / done.

## P0b — `ask_person` mouth exit

JSON emit:

```json
{ "type": "ask_person", "question": "…", "why": "…" }
```

- Ends the turn; parks a purpose=`ask` widget (`askPersonWidget` —
allowCustom Reply) with `why` as helpText.
- Speaks `Put to you.`; paints mouth-stream decide→act→done for
`ask_person`; durable `action_events` row via `buildActionEvent`.
- Prompt cue (`WIDGET_CUE`): prefer ask_person over hop when no sister can
settle it. Not gated on a hop grant.
- Answer resumes via existing `answerWidget` → `send` path.
- Not an on-call rota / Jobs GATE widget. Auto does not auto-approve when
unattended.

## P0c — Mouth silence watchdog + sticky stopped banner

| OpenBot | Automaton |
| --- | --- |
| `stall-guard` / `turn-watchdog` | `src/runtime/mouth-stall.ts` |
| Sticky stopped sentence | `Thread.stoppedReason` + `StoppedTurnBanner` |

- Watches OpenRouter **response body** silence between chunks (injectable
clock; `AUTOMATON_MOUTH_STALL_MS`, default 45s; `0` disables).
- On stall: `MouthStallError` → `mouthFailSpeak` →
`OpenRouter went quiet mid-reply. Try again.`
- `failMouth` parks sticky `stoppedReason`; next `paintSend` / `send`
clears it. Auth / empty / rate-limit remain distinct sentences.
- Does **not** abort Puppetmaster Jobs. Consumer/UI pause is not Bot silence
(watch is on the OpenRouter body pump).

## Out of scope (P1+)

Interrupt history hygiene, hop `mayAddress` grants, hold cancelled-vs-refused,
computer activity epoch, proposed-sister consent — see
`artifacts/automaton-wave7-openbot-parity-audit.md`.

## Tests

`tests/wave7-p0.test.ts` — ToolLine fold, hop envelope, ask_person parse /
widget resume, injectable stall clock, sticky banner clear.
90 changes: 71 additions & 19 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type Agent,
type ChannelReplyRouting,
type FeedItem,
parseMouthEmit,
type GoalRun,
type JobHandle,
type MouthState,
Expand Down Expand Up @@ -98,7 +99,17 @@ import { ensureLocalDashboard, isDashboardJobId, openDashboardUrl } from './runt
import { githubUrlFromHomeRepo, readExplicitOriginRemote } from './runtime/cloud-origin'
import { displayForMouth } from './runtime/computer'
import { chatComputerOpenRouter, ensureComputerWorker, liveComputerSeams } from './runtime/computer-worker'
import { initiatorFromKickoff, mouthStreamLabel } from './runtime/mouth-stream'
import { buildActionEvent, initiatorFromKickoff, mouthStreamLabel } from './runtime/mouth-stream'
import {
hopRelayDetail,
hopRelayHasDisclosure,
hopRelayLabel,
mouthStreamSuperseded,
mouthStreamTone,
mouthStreamToolLabel,
peerDisplayName,
shouldPaintHopRelay,
} from './runtime/mouth-tool-line'
import { sessionActivityForSister } from './runtime/session-activity'
import { setHumanDriving } from './runtime/driving'
import { quitAutomaton } from './runtime/quit'
Expand Down Expand Up @@ -162,7 +173,7 @@ import {
postOsNotify,
} from './runtime/os-notify'
import { ConfirmCard, QuestionCard, SecretRequestCard } from './cards'
import { ActivityZone, CommandPalette, Composer, EdgeFadeFrame, EmptyState, ListRow, MouthWaitBubble, Sheet, SteerQueueCard, Tip, Titlebar, ToastStack, activityVisible, groupBoxStyle, pushToast, tracesFromJob } from './chrome'
import { ActivityZone, CommandPalette, Composer, EdgeFadeFrame, EmptyState, ListRow, MouthWaitBubble, Sheet, SteerQueueCard, StoppedTurnBanner, Tip, Titlebar, ToastStack, ToolLine, activityVisible, groupBoxStyle, pushToast, tracesFromJob } from './chrome'
import { connectorDisplayName } from './runtime/connectors'
import { Settings } from './settings'
import { motionTransition, pulseStrideFor } from './motion'
Expand Down Expand Up @@ -505,6 +516,7 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) {
void ensureMouth(session, store, {
onComplete: (agentId, spoken) => {
let routing: ChannelReplyRouting | undefined
const emit = parseMouthEmit(spoken)
setSession((current) => {
if (current.threads[agentId]?.mouth === 'intro') markIntroPlayedAt(agentId)
const taken = takePendingChannelReply(current, agentId)
Expand All @@ -514,6 +526,19 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) {
persistIntroIfUserSpoke(next)
return next
})
if (emit?.kind === 'ask_person') {
store.recordAction(
buildActionEvent({
ownerAgentId: agentId,
tool: 'ask_person',
intent: 'ask',
decision: 'permit',
reason: 'ask_person',
path: 'person',
initiatorKind: 'person',
}),
)
}
try {
const name = session.agents.find((a) => a.id === agentId)?.name ?? 'Sister'
if (
Expand Down Expand Up @@ -1248,6 +1273,7 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) {
setSession((current) => sendSteerNow(current, current.activeAgentId, index))
}
/>
{thread?.stoppedReason ? <StoppedTurnBanner reason={thread.stoppedReason} /> : null}
<Composer
value={overlayBusy ? '' : (thread?.draft ?? '')}
agents={session.agents}
Expand Down Expand Up @@ -2040,9 +2066,27 @@ const FeedRelayRow = React.memo(function FeedRelayRow({
item: Extract<FeedItem, { kind: 'relay' }>
agents: Agent[]
}) {
const peerName = peerDisplayName(agents, item.peerId)
const label = hopRelayLabel(item.lane, peerName, item.failed)
const detail = hopRelayDetail(item)
const disclosure = hopRelayHasDisclosure(item)
return (
<div style={feedLane}>
<RelayMark lane={item.lane} peerId={item.peerId} agents={agents} />
<ToolLine
testId={`relay-${item.lane}-${item.peerId}`}
label={label}
detail={detail}
failed={item.failed === true}
>
{disclosure ? (
<>
{item.task?.trim() ? <div>Task: {item.task.trim()}</div> : null}
{item.constraints?.trim() ? <div>Constraints: {item.constraints.trim()}</div> : null}
{item.expecting?.trim() ? <div>Expecting: {item.expecting.trim()}</div> : null}
{item.lane === 'from' && item.text.trim() ? <div>Outcome: {item.text.trim()}</div> : null}
</>
) : null}
</ToolLine>
</div>
)
}, sameFeedRowFingerprint)
Expand Down Expand Up @@ -2106,25 +2150,29 @@ const FeedMouthStreamRow = React.memo(function FeedMouthStreamRow({
fingerprint: string
item: Extract<FeedItem, { kind: 'mouth-stream' }>
}) {
const T = useTokens()
const label = mouthStreamLabel(item.phase, item.tool, item.intent, item.detail, item.bytes)
const tone = item.phase === 'refuse' ? T.danger : T.tertiary
const tone = mouthStreamTone(item.phase)
const label = mouthStreamToolLabel(item.tool, item.intent)
const size =
typeof item.bytes === 'number' && Number.isFinite(item.bytes)
? mouthStreamLabel('done', '_', '_', undefined, item.bytes).split(' · ').pop()
: undefined
const detailLine = [item.detail?.trim(), size].filter(Boolean).join(' · ') || undefined
return (
<div style={feedLane}>
<div
<ToolLine
testId={`mouth-stream-${item.id}`}
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',
gap: T.space.xs,
paddingTop: T.space.xxs,
paddingBottom: T.space.xxs,
}}
label={label}
detail={detailLine}
running={tone.running}
refused={tone.refused}
failed={tone.failed}
>
<div style={{ fontSize: T.type.xs, color: tone }}>{label}</div>
</div>
<div>
{item.phase} · {item.tool} · {item.intent}
{item.detail?.trim() ? ` · ${item.detail.trim()}` : ''}
{size ? ` · ${size}` : ''}
</div>
</ToolLine>
</div>
)
}, sameFeedRowFingerprint)
Expand Down Expand Up @@ -2494,14 +2542,17 @@ export const Feed = forwardRef<FeedApi, {
) : null}
{items.map((item, index) => {
if (item.kind === 'relay') {
if (item.lane === 'from') return null
if (!shouldPaintHopRelay(item)) return null
const peer = agents.find((agent) => agent.id === item.peerId)
const fingerprint = feedRowFingerprint({
kind: 'relay',
id: item.id,
lane: item.lane,
peerId: item.peerId,
peerLabel: peer?.name ?? item.peerId,
text: [item.task, item.constraints, item.expecting, item.failed ? '1' : '']
.filter(Boolean)
.join('|'),
})
return <FeedRelayRow key={item.id} fingerprint={fingerprint} item={item} agents={agents} />
}
Expand Down Expand Up @@ -2544,6 +2595,7 @@ export const Feed = forwardRef<FeedApi, {
)
}
if (item.kind === 'mouth-stream') {
if (mouthStreamSuperseded(items, index)) return null
const fingerprint = feedRowFingerprint({
kind: 'mouth-stream',
id: item.id,
Expand Down
2 changes: 2 additions & 0 deletions src/chrome/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,5 @@ export { Tip } from './tooltip'
export { EdgeFadeFrame, edgeFadeBackground, rgbHex, transparentHex } from './edge-fade'
export { KEYMAP, keymapByGroup, keymapChord, type KeymapEntry } from './keymap'
export { ShortcutsHelp } from './shortcuts-help'

export { ToolLine, StoppedTurnBanner } from './tool-line'
Loading
Loading