@phenx-inc/ctlsurf 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/out/headless/index.mjs +339 -2
  2. package/out/headless/index.mjs.map +4 -4
  3. package/out/main/index.js +441 -20
  4. package/out/preload/index.js +9 -0
  5. package/out/renderer/assets/{cssMode-BQN8v2ok.js → cssMode-BbCVOTaJ.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-DbxGYYVp.js → freemarker2-DryLywQv.js} +1 -1
  7. package/out/renderer/assets/{handlebars-3auU1CAd.js → handlebars-CdUMtSdr.js} +1 -1
  8. package/out/renderer/assets/{html-D8xFiRmI.js → html-Cwkf2GMH.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-M3MApZ4n.js → htmlMode-C7PzhXxH.js} +3 -3
  10. package/out/renderer/assets/{index-B-iM7dFC.css → index-BIceGcz3.css} +52 -0
  11. package/out/renderer/assets/{index---H6cxNl.js → index-Dg1vMspf.js} +274 -244
  12. package/out/renderer/assets/{javascript-BO_ViZM5.js → javascript-Cy7ROXJl.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-CKp2zvZu.js → jsonMode-BtZtt3QW.js} +3 -3
  14. package/out/renderer/assets/{liquid-C1eHcrht.js → liquid-xReAQeYs.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-CHWJx_Tl.js → lspLanguageFeatures-Dmx0L0Bw.js} +1 -1
  16. package/out/renderer/assets/{mdx-Qqdtk7fL.js → mdx-YMNFO1Zz.js} +1 -1
  17. package/out/renderer/assets/{python-DKu7rNbs.js → python-BExaDVxv.js} +1 -1
  18. package/out/renderer/assets/{razor-BOMpCo6z.js → razor-HL8ANmQ4.js} +1 -1
  19. package/out/renderer/assets/{transformers.web-DtSCnG36.js → transformers.web-_PuZoIho.js} +824 -19
  20. package/out/renderer/assets/{tsMode-yAjlPR-D.js → tsMode-Dqd8vdLE.js} +1 -1
  21. package/out/renderer/assets/{typescript-BiJRCUcL.js → typescript-Dysz1PTX.js} +1 -1
  22. package/out/renderer/assets/{xml-D4PvYeQq.js → xml-BHp5Z9nc.js} +1 -1
  23. package/out/renderer/assets/{yaml-BeHVkmnS.js → yaml-CPDHxVup.js} +1 -1
  24. package/out/renderer/index.html +64 -2
  25. package/out/repro-toggle.mjs +305 -0
  26. package/package.json +1 -1
  27. package/src/main/agentState.ts +329 -0
  28. package/src/main/index.ts +107 -19
  29. package/src/main/orchestrator.ts +34 -2
  30. package/src/main/transcripts.ts +134 -0
  31. package/src/preload/index.ts +11 -0
  32. package/src/renderer/App.tsx +48 -13
  33. package/src/renderer/components/FloatingMic.tsx +48 -0
  34. package/src/renderer/components/VoiceInput.tsx +36 -149
  35. package/src/renderer/index.html +62 -0
  36. package/src/renderer/lib/localWhisper.ts +46 -2
  37. package/src/renderer/lib/speech.ts +18 -0
  38. package/src/renderer/styles.css +52 -0
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Agent State Monitor
3
+ *
4
+ * Detects, in real time, whether a coding agent is BUSY (actively working) or
5
+ * AWAITING the user — either waiting for the next prompt (`awaiting_input`) or
6
+ * blocked on an approval/permission decision (`awaiting_decision`).
7
+ *
8
+ * This is the cross-platform "PTY backbone": it watches the raw terminal byte
9
+ * stream (which every agent produces regardless of OS) and infers state from
10
+ * the TUI's own affordances — the busy spinner (`esc to interrupt`) and
11
+ * approval prompts. It is deliberately independent of chat logging and of the
12
+ * ConversationBridge, whose transcript cleaner actively *discards* the spinner
13
+ * lines this monitor needs.
14
+ *
15
+ * Agents with native hooks (Claude Code, Codex) can deliver an authoritative
16
+ * state via `applyHookEvent()`, which overrides the heuristic for a short
17
+ * window. See agentHooks.ts.
18
+ */
19
+
20
+ export type AgentState = 'idle' | 'busy' | 'awaiting_input' | 'awaiting_decision'
21
+
22
+ export type AgentStateSink = (info: {
23
+ tabId: string
24
+ state: AgentState
25
+ /** True when any tracked tab is awaiting the user (drives the dock/taskbar). */
26
+ anyAwaiting: boolean
27
+ }) => void
28
+
29
+ // The spinner ticks roughly once per second; allow a couple of missed ticks
30
+ // before we stop considering the agent busy.
31
+ const BUSY_GRACE_MS = 2_500
32
+ // Output must be quiet this long after the spinner stops before we declare the
33
+ // agent is awaiting the user (avoids flagging mid-stream pauses).
34
+ const SETTLE_MS = 1_200
35
+ // How long a seen approval prompt keeps a tab in `awaiting_decision`.
36
+ const DECISION_TTL_MS = 4_000
37
+ // Evaluation cadence.
38
+ const TICK_MS = 400
39
+ // A hook-delivered state is authoritative for this long, suppressing the
40
+ // heuristic for that tab.
41
+ const HOOK_AUTHORITY_MS = 6_000
42
+ // Only the trailing slice of each tab's output is kept, enough to catch a
43
+ // marker split across two PTY chunks.
44
+ const TAIL_KEEP_CHARS = 240
45
+
46
+ // Claude Code's working spinner: a cycling star glyph followed by a gerund that
47
+ // ends in an ellipsis, e.g. "✶ Tempering…" or "✳ Cogitating...". The completion
48
+ // line ("✻ Worked for 2s") has the glyph but no ellipsis, so it doesn't match —
49
+ // letting the agent settle to `awaiting_input`. Newer Claude builds (Opus 4.8)
50
+ // don't always print "esc to interrupt" for fast turns, so this is the primary
51
+ // Claude signal; the older hints are kept for other builds and Codex.
52
+ const CLAUDE_SPINNER = /[✶✻✳✺✢✷✸✹❋][^\n]{0,60}(?:…|\.\.\.)/
53
+
54
+ /** Busy markers — the spinner/working hints a TUI shows while actively working. */
55
+ function hasBusyMarker(text: string): boolean {
56
+ return (
57
+ /esc to interrupt/i.test(text) ||
58
+ /\bWorking \(\d+s/i.test(text) ||
59
+ CLAUDE_SPINNER.test(text)
60
+ )
61
+ }
62
+
63
+ /**
64
+ * Approval / permission prompts. Conservative on purpose: a false positive only
65
+ * mislabels the sub-state (the dock fires on either awaiting state anyway), but
66
+ * we still avoid obviously-noisy matches.
67
+ */
68
+ const APPROVAL_PATTERNS: RegExp[] = [
69
+ /\bDo you want to (proceed|continue|create|make this edit|run)\b/i, // Claude Code
70
+ /❯\s*\d+\.\s*Yes\b/i, // Claude numbered-choice prompt
71
+ /\bAllow command\b/i, // Codex
72
+ /\bRun this command\?/i, // Codex
73
+ /\bApprove\b[^\n]*\?/i,
74
+ /\(y\/n\)/i,
75
+ /\[y\/N\]/i,
76
+ ]
77
+
78
+ function hasApprovalMarker(text: string): boolean {
79
+ return APPROVAL_PATTERNS.some((re) => re.test(text))
80
+ }
81
+
82
+ /** Strip CSI/OSC escape sequences so markers aren't hidden behind color codes. */
83
+ function stripAnsi(str: string): string {
84
+ return str
85
+ // eslint-disable-next-line no-control-regex
86
+ .replace(/\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g, '')
87
+ // eslint-disable-next-line no-control-regex
88
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
89
+ // eslint-disable-next-line no-control-regex
90
+ .replace(/\x1b/g, '')
91
+ }
92
+
93
+ interface TabRecord {
94
+ /** Agent id (e.g. 'claude', 'codex'); reserved for future per-agent tuning. */
95
+ agentId: string
96
+ cwd: string
97
+ state: AgentState
98
+ /** True once a busy spinner has been seen since the last user input. */
99
+ everBusy: boolean
100
+ lastBusyAt: number
101
+ lastOutputAt: number
102
+ lastDecisionAt: number
103
+ /** Authoritative state from a hook, and when it was delivered. */
104
+ hookState: AgentState | null
105
+ hookAt: number
106
+ /** Trailing output slice, to catch markers split across PTY chunks. */
107
+ tail: string
108
+ }
109
+
110
+ export class AgentStateMonitor {
111
+ private readonly sink: AgentStateSink
112
+ private readonly now: () => number
113
+ private tabs = new Map<string, TabRecord>()
114
+ private timer: ReturnType<typeof setInterval> | null = null
115
+ private lastAnyAwaiting = false
116
+
117
+ constructor(sink: AgentStateSink, now: () => number = Date.now) {
118
+ this.sink = sink
119
+ this.now = now
120
+ }
121
+
122
+ start(): void {
123
+ if (this.timer) return
124
+ this.timer = setInterval(() => this.tick(), TICK_MS)
125
+ }
126
+
127
+ stop(): void {
128
+ if (this.timer) {
129
+ clearInterval(this.timer)
130
+ this.timer = null
131
+ }
132
+ this.tabs.clear()
133
+ this.lastAnyAwaiting = false
134
+ }
135
+
136
+ /** Register a tab so its raw PTY output can be classified. */
137
+ trackTab(tabId: string, agentId: string, cwd: string): void {
138
+ const now = this.now()
139
+ this.tabs.set(tabId, {
140
+ agentId,
141
+ cwd,
142
+ state: 'idle',
143
+ everBusy: false,
144
+ lastBusyAt: 0,
145
+ lastOutputAt: now,
146
+ lastDecisionAt: 0,
147
+ hookState: null,
148
+ hookAt: 0,
149
+ tail: '',
150
+ })
151
+ }
152
+
153
+ removeTab(tabId: string): void {
154
+ if (this.tabs.delete(tabId)) this.emitAggregate()
155
+ }
156
+
157
+ /** Feed a raw PTY output chunk for a tab. */
158
+ feed(tabId: string, data: string): void {
159
+ const rec = this.tabs.get(tabId)
160
+ if (!rec) return
161
+ const now = this.now()
162
+ rec.lastOutputAt = now
163
+
164
+ const text = rec.tail + stripAnsi(data)
165
+ rec.tail = text.length > TAIL_KEEP_CHARS ? text.slice(-TAIL_KEEP_CHARS) : text
166
+
167
+ if (hasBusyMarker(text)) {
168
+ rec.lastBusyAt = now
169
+ rec.everBusy = true
170
+ }
171
+ if (hasApprovalMarker(text)) {
172
+ rec.lastDecisionAt = now
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Note input written to a tab's PTY. Only a *submit* (data containing a
178
+ * newline) counts as the user engaging — clearing any awaiting state and
179
+ * resetting the "has worked" flag so a leftover idle box doesn't re-flag.
180
+ *
181
+ * Individual keystrokes and, crucially, the terminal's own auto-responses
182
+ * (cursor-position / device-attribute replies that TUIs constantly trigger,
183
+ * which xterm.js writes straight back to the PTY) are ignored — otherwise
184
+ * the state would churn busy↔idle on every control-sequence handshake.
185
+ */
186
+ noteInput(tabId: string, data: string): void {
187
+ if (!/[\r\n]/.test(data)) return
188
+ const rec = this.tabs.get(tabId)
189
+ if (!rec) return
190
+ const now = this.now()
191
+ rec.everBusy = false
192
+ rec.lastBusyAt = now // a submit means work is coming → busy, not awaiting
193
+ rec.lastDecisionAt = 0
194
+ rec.hookState = null
195
+ this.setState(rec, tabId, 'busy')
196
+ }
197
+
198
+ /**
199
+ * Apply an authoritative state from an agent hook, addressed by cwd. Overrides
200
+ * the heuristic for HOOK_AUTHORITY_MS. `kind`:
201
+ * 'awaiting_input' — turn done / idle prompt (Stop, idle_prompt, agent-turn-complete)
202
+ * 'awaiting_decision' — permission/approval prompt (PermissionRequest, permission_prompt)
203
+ * 'busy' — user submitted a prompt (UserPromptSubmit)
204
+ */
205
+ applyHookEvent(cwd: string, kind: AgentState): void {
206
+ const target = stripTrailingSep(cwd)
207
+ const now = this.now()
208
+ for (const [tabId, rec] of this.tabs) {
209
+ if (stripTrailingSep(rec.cwd) !== target) continue
210
+ rec.hookState = kind
211
+ rec.hookAt = now
212
+ if (kind === 'busy') {
213
+ rec.everBusy = false
214
+ rec.lastBusyAt = now
215
+ }
216
+ this.setState(rec, tabId, kind)
217
+ }
218
+ }
219
+
220
+ private tick(): void {
221
+ const now = this.now()
222
+ for (const [tabId, rec] of this.tabs) {
223
+ // Hook state wins while fresh.
224
+ if (rec.hookState && now - rec.hookAt < HOOK_AUTHORITY_MS) {
225
+ this.setState(rec, tabId, rec.hookState)
226
+ continue
227
+ }
228
+
229
+ // `> 0` guards the "never seen" sentinel so a small/zero clock value
230
+ // doesn't read as recent activity.
231
+ const busyRecently = rec.lastBusyAt > 0 && now - rec.lastBusyAt < BUSY_GRACE_MS
232
+ const settled = now - rec.lastOutputAt >= SETTLE_MS
233
+ const decisionRecent = rec.lastDecisionAt > 0 && now - rec.lastDecisionAt < DECISION_TTL_MS
234
+
235
+ let next: AgentState | null = null
236
+ if (busyRecently) {
237
+ next = 'busy'
238
+ } else if (settled && rec.everBusy) {
239
+ // Awaiting the user. Keep the sub-state stable: once we're showing
240
+ // awaiting_decision it sticks (and awaiting_input may upgrade to it),
241
+ // but we never flap back down — the agent's screen repaints make an
242
+ // approval marker match only intermittently, which otherwise flickers
243
+ // the indicator between amber and red.
244
+ next = rec.state === 'awaiting_decision' || decisionRecent
245
+ ? 'awaiting_decision'
246
+ : 'awaiting_input'
247
+ } else if (settled && !rec.everBusy) {
248
+ next = 'idle'
249
+ }
250
+ // next === null → output still streaming after spinner stopped; hold the
251
+ // current state until it settles.
252
+ if (next) this.setState(rec, tabId, next)
253
+ }
254
+ this.emitAggregate()
255
+ }
256
+
257
+ private setState(rec: TabRecord, tabId: string, state: AgentState): void {
258
+ if (rec.state === state) return
259
+ rec.state = state
260
+ this.sink({ tabId, state, anyAwaiting: this.computeAnyAwaiting() })
261
+ this.lastAnyAwaiting = this.computeAnyAwaiting()
262
+ }
263
+
264
+ private emitAggregate(): void {
265
+ const anyAwaiting = this.computeAnyAwaiting()
266
+ if (anyAwaiting === this.lastAnyAwaiting) return
267
+ this.lastAnyAwaiting = anyAwaiting
268
+ // Re-emit with a representative tab so the dock/aggregate listener updates
269
+ // even when no single tab's state label changed this tick.
270
+ const tabId = this.firstAwaitingTabId() ?? ''
271
+ const state = tabId ? (this.tabs.get(tabId)!.state) : 'idle'
272
+ this.sink({ tabId, state, anyAwaiting })
273
+ }
274
+
275
+ private computeAnyAwaiting(): boolean {
276
+ for (const rec of this.tabs.values()) {
277
+ if (rec.state === 'awaiting_input' || rec.state === 'awaiting_decision') return true
278
+ }
279
+ return false
280
+ }
281
+
282
+ private firstAwaitingTabId(): string | null {
283
+ for (const [tabId, rec] of this.tabs) {
284
+ if (rec.state === 'awaiting_input' || rec.state === 'awaiting_decision') return tabId
285
+ }
286
+ return null
287
+ }
288
+ }
289
+
290
+ function stripTrailingSep(p: string): string {
291
+ return p.length > 1 ? p.replace(/[/\\]+$/, '') : p
292
+ }
293
+
294
+ /**
295
+ * Decides whether the dock/taskbar "awaiting input" attention (blink + bounce)
296
+ * should currently be active. Pure boolean logic, separated from the Electron
297
+ * bindings in index.ts so it can be tested without a window.
298
+ *
299
+ * Rules:
300
+ * - Attention is active only while an agent is awaiting AND the window is
301
+ * unfocused AND the current awaiting episode hasn't been acknowledged.
302
+ * - Focusing the window during an episode acknowledges it; so does already
303
+ * being focused at the moment the episode begins (you saw it happen).
304
+ * - Acknowledgement lasts for the whole episode: blurring again does NOT
305
+ * re-nag. A fresh episode (agent works, then waits again) starts unack'd.
306
+ */
307
+ export class AttentionController {
308
+ private awaiting = false
309
+ private focused = false
310
+ private acknowledged = false
311
+
312
+ /** Update awaiting state; returns whether attention should be active. */
313
+ setAwaiting(on: boolean): boolean {
314
+ if (on && !this.awaiting) this.acknowledged = this.focused // new episode
315
+ this.awaiting = on
316
+ return this.active()
317
+ }
318
+
319
+ /** Update window focus; returns whether attention should be active. */
320
+ setFocused(focused: boolean): boolean {
321
+ this.focused = focused
322
+ if (focused && this.awaiting) this.acknowledged = true
323
+ return this.active()
324
+ }
325
+
326
+ private active(): boolean {
327
+ return this.awaiting && !this.focused && !this.acknowledged
328
+ }
329
+ }
package/src/main/index.ts CHANGED
@@ -24,6 +24,7 @@ function log(...args: unknown[]): void {
24
24
  import { AgentConfig, getDefaultAgent, getAvailableAgents } from './agents'
25
25
  import { getSettingsDir } from './settingsDir'
26
26
  import { Orchestrator } from './orchestrator'
27
+ import { AttentionController } from './agentState'
27
28
 
28
29
  let mainWindow: BrowserWindow | null = null
29
30
 
@@ -50,43 +51,47 @@ function getProjectAbbrev(cwdPath: string): string {
50
51
  return word.slice(0, 2).toUpperCase()
51
52
  }
52
53
 
53
- function createOverlayIcon(text: string): Electron.NativeImage {
54
- // Create a 32x32 overlay with text for Windows/Linux
54
+ // Draw a 32x32 filled circle overlay. `red` switches between the project-badge
55
+ // blue and the "awaiting input" red dot.
56
+ function createDotOverlay(red: boolean): Electron.NativeImage {
55
57
  const size = 32
56
58
  const canvas = Buffer.alloc(size * size * 4, 0) // RGBA
57
-
58
- // Fill with a semi-transparent dark background circle
59
+ const [r, g, b] = red ? [235, 64, 52] : [122, 162, 247]
59
60
  for (let y = 0; y < size; y++) {
60
61
  for (let x = 0; x < size; x++) {
61
62
  const cx = x - size / 2
62
63
  const cy = y - size / 2
63
- const dist = Math.sqrt(cx * cx + cy * cy)
64
- if (dist <= size / 2) {
64
+ if (Math.sqrt(cx * cx + cy * cy) <= size / 2) {
65
65
  const idx = (y * size + x) * 4
66
- canvas[idx] = 122 // R (blue-ish)
67
- canvas[idx + 1] = 162 // G
68
- canvas[idx + 2] = 247 // B
69
- canvas[idx + 3] = 220 // A
66
+ canvas[idx] = r
67
+ canvas[idx + 1] = g
68
+ canvas[idx + 2] = b
69
+ canvas[idx + 3] = 235
70
70
  }
71
71
  }
72
72
  }
73
-
74
73
  return nativeImage.createFromBuffer(canvas, { width: size, height: size })
75
74
  }
76
75
 
77
- function updateProjectBadge(cwdPath: string | null): void {
78
- if (!cwdPath) return
79
- const abbrev = getProjectAbbrev(cwdPath)
76
+ // Last project abbreviation, so the steady badge can be restored after the
77
+ // "awaiting input" indicator clears.
78
+ let projectAbbrev = ''
80
79
 
81
- // macOS: dock badge shows text directly
80
+ function applySteadyBadge(): void {
82
81
  if (process.platform === 'darwin' && app.dock) {
83
- app.dock.setBadge(abbrev)
82
+ app.dock.setBadge(projectAbbrev)
84
83
  }
85
-
86
- // Windows: overlay icon on taskbar
87
84
  if (process.platform === 'win32' && mainWindow) {
88
- mainWindow.setOverlayIcon(createOverlayIcon(abbrev), abbrev)
85
+ mainWindow.setOverlayIcon(projectAbbrev ? createDotOverlay(false) : null, projectAbbrev)
89
86
  }
87
+ }
88
+
89
+ function updateProjectBadge(cwdPath: string | null): void {
90
+ if (!cwdPath) return
91
+ projectAbbrev = getProjectAbbrev(cwdPath)
92
+
93
+ // Don't stomp the awaiting-input blink; it restores the steady badge itself.
94
+ if (!blinkTimer) applySteadyBadge()
90
95
 
91
96
  // All platforms: update window title
92
97
  if (mainWindow) {
@@ -95,6 +100,76 @@ function updateProjectBadge(cwdPath: string | null): void {
95
100
  }
96
101
  }
97
102
 
103
+ // ─── "Awaiting input" attention indicator (cross-platform) ──────────
104
+ //
105
+ // The detection is OS-independent; only the visual differs:
106
+ // macOS — blink a red dot in the dock badge + one bounce
107
+ // Windows — red overlay icon on the taskbar button + flash the button
108
+ // Linux — badge count where supported + flash (window urgency hint)
109
+ // While the window is focused the dock/taskbar nag is suppressed (the in-app
110
+ // dot is enough); the renderer always gets `agent:state` regardless of OS.
111
+
112
+ const attention = new AttentionController()
113
+ let blinkTimer: ReturnType<typeof setInterval> | null = null
114
+ let blinkOn = false
115
+ let bounceId = -1
116
+
117
+ function setAwaitingIndicator(on: boolean): void {
118
+ applyAttention(attention.setAwaiting(on))
119
+ }
120
+
121
+ function applyAttention(active: boolean): void {
122
+ if (active) startAttention()
123
+ else stopAttention()
124
+ }
125
+
126
+ function startAttention(): void {
127
+ if (blinkTimer) return
128
+ log('[agent-state] attention START (blink + bounce)')
129
+
130
+ if (process.platform === 'darwin' && app.dock) {
131
+ bounceId = app.dock.bounce('informational')
132
+ }
133
+ if (process.platform === 'win32' && mainWindow) {
134
+ mainWindow.setOverlayIcon(createDotOverlay(true), 'Awaiting input')
135
+ mainWindow.flashFrame(true)
136
+ }
137
+ if (process.platform === 'linux') {
138
+ if (typeof app.badgeCount === 'number') app.badgeCount = 1
139
+ mainWindow?.flashFrame(true)
140
+ }
141
+
142
+ // Blink the macOS dock badge — the whole red pill (project initials and all)
143
+ // toggles on/off, so the project letters are preserved on the "on" frames
144
+ // rather than being replaced by a dot.
145
+ blinkOn = true
146
+ blinkTimer = setInterval(() => {
147
+ blinkOn = !blinkOn
148
+ if (process.platform === 'darwin' && app.dock) {
149
+ app.dock.setBadge(blinkOn ? (projectAbbrev || '●') : '')
150
+ }
151
+ }, 600)
152
+ }
153
+
154
+ function stopAttention(): void {
155
+ if (blinkTimer) {
156
+ clearInterval(blinkTimer)
157
+ blinkTimer = null
158
+ log('[agent-state] attention STOP')
159
+ }
160
+ if (process.platform === 'darwin' && app.dock) {
161
+ if (bounceId >= 0) { app.dock.cancelBounce(bounceId); bounceId = -1 }
162
+ }
163
+ if (process.platform === 'win32' && mainWindow) {
164
+ mainWindow.flashFrame(false)
165
+ }
166
+ if (process.platform === 'linux') {
167
+ if (typeof app.badgeCount === 'number') app.badgeCount = 0
168
+ mainWindow?.flashFrame(false)
169
+ }
170
+ applySteadyBadge()
171
+ }
172
+
98
173
  // ─── Orchestrator (shared logic) ──────────────────
99
174
 
100
175
  const orchestrator = new Orchestrator(
@@ -107,6 +182,11 @@ const orchestrator = new Orchestrator(
107
182
  onWorkerRegistered: (data) => mainWindow?.webContents.send('worker:registered', data),
108
183
  onProjectChanged: (name) => mainWindow?.webContents.send('app:projectChanged', name),
109
184
  onAgentMessage: (text) => mainWindow?.webContents.send('agent:message', text),
185
+ onAgentStateChange: (tabId, state, anyAwaiting) => {
186
+ log(`[agent-state] tab=${tabId} state=${state} anyAwaiting=${anyAwaiting}`)
187
+ mainWindow?.webContents.send('agent:state', tabId, state, anyAwaiting)
188
+ setAwaitingIndicator(anyAwaiting)
189
+ },
110
190
  onCwdChanged: () => {
111
191
  mainWindow?.webContents.send('app:cwdChanged')
112
192
  updateProjectBadge(orchestrator.cwd)
@@ -146,6 +226,12 @@ function createWindow(): void {
146
226
  mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
147
227
  }
148
228
 
229
+ // Focusing the window means the user is here — drop the dock/taskbar nag (the
230
+ // in-app dot still shows the state). Re-arm it if the agent is still awaiting
231
+ // when focus is lost again.
232
+ mainWindow.on('focus', () => applyAttention(attention.setFocused(true)))
233
+ mainWindow.on('blur', () => applyAttention(attention.setFocused(false)))
234
+
149
235
  mainWindow.on('closed', () => {
150
236
  mainWindow = null
151
237
  })
@@ -420,6 +506,8 @@ ipcMain.handle('speak:set', (_event, enabled: boolean) => {
420
506
  orchestrator.setSpeakReplies(!!enabled)
421
507
  return { enabled: orchestrator.speakRepliesEnabled }
422
508
  })
509
+ // Latest assistant reply from the active tab's transcript, for on-demand TTS.
510
+ ipcMain.handle('agent:getLastReply', () => orchestrator.getLastReply())
423
511
 
424
512
  // ─── Tracking IPC ─────────────────────────────────
425
513
 
@@ -6,7 +6,8 @@ import { PtyManager } from './pty'
6
6
  import { AgentConfig, isCodingAgent } from './agents'
7
7
  import { CtlsurfApi } from './ctlsurfApi'
8
8
  import { ConversationBridge } from './bridge'
9
- import { TranscriptTailer, supportsTranscriptLogging } from './transcripts'
9
+ import { TranscriptTailer, supportsTranscriptLogging, readLatestAssistantReply } from './transcripts'
10
+ import { AgentStateMonitor, type AgentState } from './agentState'
10
11
  import { WorkerWsClient, type WorkerWsStatus, type IncomingMessage } from './workerWs'
11
12
  import { TimeTracker } from './timeTracker'
12
13
  import { TicketStore, type TicketInput } from './ticketStore'
@@ -51,6 +52,10 @@ export interface OrchestratorEvents {
51
52
  // text-to-speech. Optional and wired only by the Electron entry — the
52
53
  // headless build leaves it unset, so replies are never spoken in a terminal.
53
54
  onAgentMessage?: (text: string) => void
55
+ // Real-time agent activity state (busy vs awaiting the user). `anyAwaiting`
56
+ // is true when any tab is waiting on the user, and drives the dock/taskbar
57
+ // attention indicator. Optional — only the Electron entry wires it.
58
+ onAgentStateChange?: (tabId: string, state: AgentState, anyAwaiting: boolean) => void
54
59
  }
55
60
 
56
61
  interface TabState {
@@ -102,11 +107,17 @@ export class Orchestrator {
102
107
  private noProjectPollCwd: string | null = null
103
108
  private currentProjectName: string | null = null
104
109
  private transcriptTailer: TranscriptTailer | null = null
110
+ private readonly stateMonitor: AgentStateMonitor
105
111
 
106
112
  constructor(settingsDir: string, events: OrchestratorEvents) {
107
113
  this.settingsDir = settingsDir
108
114
  this.events = events
109
115
 
116
+ this.stateMonitor = new AgentStateMonitor(({ tabId, state, anyAwaiting }) => {
117
+ this.events.onAgentStateChange?.(tabId, state, anyAwaiting)
118
+ })
119
+ this.stateMonitor.start()
120
+
110
121
  this.workerWs = new WorkerWsClient({
111
122
  onStatusChange: (status: WorkerWsStatus) => {
112
123
  log(`[worker-ws] Status: ${status}`)
@@ -119,9 +130,10 @@ export class Orchestrator {
119
130
 
120
131
  if (message.type === 'prompt' || message.type === 'task_dispatch') {
121
132
  const activeTab = this.activeTabId ? this.tabs.get(this.activeTabId) : null
122
- if (activeTab) {
133
+ if (activeTab && this.activeTabId) {
123
134
  activeTab.ptyManager.write(message.content + '\r')
124
135
  this.bridge.feedInput(message.content + '\r')
136
+ this.stateMonitor.noteInput(this.activeTabId, message.content + '\r')
125
137
  }
126
138
  }
127
139
  },
@@ -159,6 +171,18 @@ export class Orchestrator {
159
171
  return !!this.settings.speakReplies
160
172
  }
161
173
 
174
+ // Latest assistant reply from a tab's transcript, for on-demand "read aloud".
175
+ // Defaults to the active tab. Returns null when the tab's agent has no native
176
+ // transcript (only Claude Code / Codex do) or nothing has been said yet. Reads
177
+ // the transcript directly, so it works regardless of the speakReplies setting.
178
+ getLastReply(tabId?: string): string | null {
179
+ const id = tabId || this.activeTabId
180
+ if (!id) return null
181
+ const tab = this.tabs.get(id)
182
+ if (!tab || !supportsTranscriptLogging(tab.agent.id)) return null
183
+ return readLatestAssistantReply({ agentId: tab.agent.id, cwd: tab.cwd })
184
+ }
185
+
162
186
  setLogChat(enabled: boolean): void {
163
187
  this.settings.logChat = enabled
164
188
  this.saveSettings()
@@ -451,10 +475,14 @@ export class Orchestrator {
451
475
  const ptyManager = new PtyManager(agent, cwd)
452
476
  const tab: TabState = { ptyManager, agent, cwd, termStreamBuffer: '', termStreamTimer: null }
453
477
  this.tabs.set(tabId, tab)
478
+ // Track all tabs (not just the active one): a background agent finishing is
479
+ // exactly when the dock indicator is most useful.
480
+ this.stateMonitor.trackTab(tabId, agent.id, cwd)
454
481
 
455
482
  ptyManager.onData((data: string) => {
456
483
  this.events.onPtyData(tabId, data)
457
484
  this.timeTracker.recordActivity(tabId)
485
+ this.stateMonitor.feed(tabId, data)
458
486
  if (tabId === this.activeTabId) {
459
487
  this.bridge.feedOutput(data)
460
488
  this.streamTerminalData(tabId, data)
@@ -473,6 +501,7 @@ export class Orchestrator {
473
501
  // Clean up tab state
474
502
  const t = this.tabs.get(tabId)
475
503
  if (t?.termStreamTimer) clearTimeout(t.termStreamTimer)
504
+ this.stateMonitor.removeTab(tabId)
476
505
  })
477
506
 
478
507
  this.startChatLogging(agent, cwd)
@@ -499,6 +528,7 @@ export class Orchestrator {
499
528
 
500
529
  writePty(tabId: string, data: string): void {
501
530
  this.tabs.get(tabId)?.ptyManager.write(data)
531
+ this.stateMonitor.noteInput(tabId, data)
502
532
  if (tabId === this.activeTabId) {
503
533
  this.bridge.feedInput(data)
504
534
  }
@@ -519,6 +549,7 @@ export class Orchestrator {
519
549
  await this.timeTracker.endSession(tabId)
520
550
  tab.ptyManager.kill()
521
551
  this.tabs.delete(tabId)
552
+ this.stateMonitor.removeTab(tabId)
522
553
  if (tabId === this.activeTabId) {
523
554
  this.stopChatLogging()
524
555
  if (isCodingAgent(tab.agent)) {
@@ -690,6 +721,7 @@ export class Orchestrator {
690
721
  async shutdown(): Promise<void> {
691
722
  this.stopNoProjectPolling()
692
723
  this.stopChatLogging()
724
+ this.stateMonitor.stop()
693
725
  await this.timeTracker.endAll()
694
726
  for (const [, tab] of this.tabs) {
695
727
  if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer)