@phenx-inc/ctlsurf 0.9.0 → 0.11.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 (31) hide show
  1. package/out/headless/index.mjs +196 -2
  2. package/out/headless/index.mjs.map +4 -4
  3. package/out/main/index.js +326 -24
  4. package/out/preload/index.js +7 -0
  5. package/out/renderer/assets/{cssMode-BdYp0fp9.js → cssMode-Bmzy9hKX.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-Cya-9zr-.js → freemarker2-CDAsExTa.js} +1 -1
  7. package/out/renderer/assets/{handlebars-EJs9W5Ub.js → handlebars-CvpBYG1L.js} +1 -1
  8. package/out/renderer/assets/{html-Ex4qy2lh.js → html-CNhEQqr8.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-CCm0aJ6C.js → htmlMode-DD7YPgga.js} +3 -3
  10. package/out/renderer/assets/{index-Cj7K9-Ba.js → index-BDlCPzCG.js} +153 -146
  11. package/out/renderer/assets/{index-nvsNYP5c.css → index-CGHeDwZa.css} +23 -46
  12. package/out/renderer/assets/{javascript-B2niCouI.js → javascript-CsGLsswK.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-BgCUHaFo.js → jsonMode-CTuGgRvC.js} +3 -3
  14. package/out/renderer/assets/{liquid-DOQmSo4G.js → liquid-BrrN4uul.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-8SIcRAMh.js → lspLanguageFeatures-Bhdn2Eqy.js} +1 -1
  16. package/out/renderer/assets/{mdx-DuKdGNKE.js → mdx-DJo-gax4.js} +1 -1
  17. package/out/renderer/assets/{python-BUa4XFG-.js → python-DYHeg5b2.js} +1 -1
  18. package/out/renderer/assets/{razor-BUw8jho0.js → razor-Cqcks14K.js} +1 -1
  19. package/out/renderer/assets/{tsMode-8FLRnobw.js → tsMode-YTLgCayh.js} +1 -1
  20. package/out/renderer/assets/{typescript-rdwAjCi4.js → typescript-oxBn_Vcw.js} +1 -1
  21. package/out/renderer/assets/{xml-HOzT59Qg.js → xml-BEqGMCTc.js} +1 -1
  22. package/out/renderer/assets/{yaml-B81JfLsW.js → yaml-BGSrk5HC.js} +1 -1
  23. package/out/renderer/index.html +2 -2
  24. package/package.json +1 -1
  25. package/src/main/agentState.ts +329 -0
  26. package/src/main/index.ts +135 -20
  27. package/src/main/orchestrator.ts +21 -1
  28. package/src/preload/index.ts +8 -0
  29. package/src/renderer/App.tsx +32 -9
  30. package/src/renderer/components/TicketPanel.tsx +20 -25
  31. package/src/renderer/styles.css +23 -46
@@ -1,5 +1,5 @@
1
- import { conf as conf$1, language as language$1 } from "./typescript-rdwAjCi4.js";
2
- import "./index-Cj7K9-Ba.js";
1
+ import { conf as conf$1, language as language$1 } from "./typescript-oxBn_Vcw.js";
2
+ import "./index-BDlCPzCG.js";
3
3
  const conf = conf$1;
4
4
  const language = {
5
5
  // Set defaultToken to invalid to see what you do not tokenize yet
@@ -1,6 +1,6 @@
1
- import { c as createWebWorker, l as languages, e as editor } from "./index-Cj7K9-Ba.js";
2
- import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-8SIcRAMh.js";
3
- import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-8SIcRAMh.js";
1
+ import { c as createWebWorker, l as languages, e as editor } from "./index-BDlCPzCG.js";
2
+ import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-Bhdn2Eqy.js";
3
+ import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-Bhdn2Eqy.js";
4
4
  const STOP_WHEN_IDLE_FOR = 2 * 60 * 1e3;
5
5
  class WorkerManager {
6
6
  constructor(defaults) {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Cj7K9-Ba.js";
1
+ import { l as languages } from "./index-BDlCPzCG.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-Cj7K9-Ba.js";
1
+ import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-BDlCPzCG.js";
2
2
  var DocumentUri;
3
3
  (function(DocumentUri2) {
4
4
  function is(value) {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Cj7K9-Ba.js";
1
+ import { l as languages } from "./index-BDlCPzCG.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["{/*", "*/}"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Cj7K9-Ba.js";
1
+ import { l as languages } from "./index-BDlCPzCG.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#",
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Cj7K9-Ba.js";
1
+ import { l as languages } from "./index-BDlCPzCG.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-Cj7K9-Ba.js";
1
+ import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-BDlCPzCG.js";
2
2
  class WorkerManager {
3
3
  constructor(_modeId, _defaults) {
4
4
  this._modeId = _modeId;
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Cj7K9-Ba.js";
1
+ import { l as languages } from "./index-BDlCPzCG.js";
2
2
  const conf = {
3
3
  wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,
4
4
  comments: {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Cj7K9-Ba.js";
1
+ import { l as languages } from "./index-BDlCPzCG.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["<!--", "-->"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Cj7K9-Ba.js";
1
+ import { l as languages } from "./index-BDlCPzCG.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#"
@@ -49,8 +49,8 @@
49
49
  color: #565f89;
50
50
  }
51
51
  </style>
52
- <script type="module" crossorigin src="./assets/index-Cj7K9-Ba.js"></script>
53
- <link rel="stylesheet" crossorigin href="./assets/index-nvsNYP5c.css">
52
+ <script type="module" crossorigin src="./assets/index-BDlCPzCG.js"></script>
53
+ <link rel="stylesheet" crossorigin href="./assets/index-CGHeDwZa.css">
54
54
  </head>
55
55
  <body>
56
56
  <div id="boot-splash">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phenx-inc/ctlsurf",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Agent-agnostic terminal and desktop app for ctlsurf — run Claude Code, Codex, or any coding agent with live session logging and remote control",
5
5
  "main": "out/main/index.js",
6
6
  "bin": {
@@ -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,85 @@ 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 (Windows taskbar). `ready` switches between
55
+ // the project-badge blue and the green "ready for you" dot.
56
+ function createDotOverlay(ready: 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] = ready ? [52, 199, 89] : [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
+ // The plain dock icon (macOS), loaded once at startup. Reused to composite the
77
+ // green "ready" dot on top, and to restore the clean icon when it clears.
78
+ let baseDockIcon: Electron.NativeImage | null = null
79
+
80
+ // Composite a green "ready for you" dot (with a soft dark ring for contrast) into
81
+ // the lower-right of the dock icon. Reads/writes the bitmap as a matched
82
+ // toBitmap()/createFromBitmap() pair, which share the same (BGRA) byte order.
83
+ function dockIconWithReadyDot(base: Electron.NativeImage): Electron.NativeImage {
84
+ const { width, height } = base.getSize()
85
+ if (!width || !height) return base
86
+ const bmp = base.toBitmap()
87
+ const r = Math.round(Math.min(width, height) * 0.17) // dot radius
88
+ const ring = Math.max(2, Math.round(r * 0.22)) // ring thickness
89
+ const cx = width - r - Math.round(width * 0.05)
90
+ const cy = height - r - Math.round(height * 0.05)
91
+ const put = (x: number, y: number, b: number, g: number, rr: number) => {
92
+ const idx = (y * width + x) * 4
93
+ bmp[idx] = b; bmp[idx + 1] = g; bmp[idx + 2] = rr; bmp[idx + 3] = 255
94
+ }
95
+ for (let y = cy - r - ring; y <= cy + r + ring; y++) {
96
+ if (y < 0 || y >= height) continue
97
+ for (let x = cx - r - ring; x <= cx + r + ring; x++) {
98
+ if (x < 0 || x >= width) continue
99
+ const d = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2)
100
+ if (d <= r) put(x, y, 89, 199, 52) // green #34c759 (BGRA)
101
+ else if (d <= r + ring) put(x, y, 24, 26, 31) // dark ring #1f1a18
102
+ }
103
+ }
104
+ return nativeImage.createFromBitmap(bmp, { width, height })
105
+ }
106
+
107
+ // Show/clear the green "ready" dot on the macOS dock icon.
108
+ function setDockReadyDot(on: boolean): void {
109
+ if (process.platform !== 'darwin' || !app.dock || !baseDockIcon) return
110
+ app.dock.setIcon(on ? dockIconWithReadyDot(baseDockIcon) : baseDockIcon)
111
+ }
80
112
 
81
- // macOS: dock badge shows text directly
113
+ // Last project abbreviation, so the steady badge can be restored after the
114
+ // "awaiting input" indicator clears.
115
+ let projectAbbrev = ''
116
+
117
+ function applySteadyBadge(): void {
82
118
  if (process.platform === 'darwin' && app.dock) {
83
- app.dock.setBadge(abbrev)
119
+ app.dock.setBadge(projectAbbrev)
84
120
  }
85
-
86
- // Windows: overlay icon on taskbar
87
121
  if (process.platform === 'win32' && mainWindow) {
88
- mainWindow.setOverlayIcon(createOverlayIcon(abbrev), abbrev)
122
+ mainWindow.setOverlayIcon(projectAbbrev ? createDotOverlay(false) : null, projectAbbrev)
89
123
  }
124
+ }
125
+
126
+ function updateProjectBadge(cwdPath: string | null): void {
127
+ if (!cwdPath) return
128
+ projectAbbrev = getProjectAbbrev(cwdPath)
129
+
130
+ // The project badge and the "ready" dot are now independent surfaces (badge
131
+ // vs dock icon), so the badge can always update.
132
+ applySteadyBadge()
90
133
 
91
134
  // All platforms: update window title
92
135
  if (mainWindow) {
@@ -95,6 +138,66 @@ function updateProjectBadge(cwdPath: string | null): void {
95
138
  }
96
139
  }
97
140
 
141
+ // ─── "Ready for you" attention indicator (cross-platform) ──────────
142
+ //
143
+ // The detection is OS-independent; only the visual differs:
144
+ // macOS — steady green dot composited on the dock icon + one bounce
145
+ // Windows — green overlay icon on the taskbar button + flash the button
146
+ // Linux — badge count where supported + flash (window urgency hint)
147
+ // While the window is focused the dock/taskbar nag is suppressed (the in-app
148
+ // dot is enough); the renderer always gets `agent:state` regardless of OS.
149
+
150
+ const attention = new AttentionController()
151
+ let attentionActive = false
152
+ let bounceId = -1
153
+
154
+ function setAwaitingIndicator(on: boolean): void {
155
+ applyAttention(attention.setAwaiting(on))
156
+ }
157
+
158
+ function applyAttention(active: boolean): void {
159
+ if (active) startAttention()
160
+ else stopAttention()
161
+ }
162
+
163
+ function startAttention(): void {
164
+ if (attentionActive) return
165
+ attentionActive = true
166
+ log('[agent-state] attention START (green ready dot + bounce)')
167
+
168
+ if (process.platform === 'darwin' && app.dock) {
169
+ setDockReadyDot(true)
170
+ bounceId = app.dock.bounce('informational')
171
+ }
172
+ if (process.platform === 'win32' && mainWindow) {
173
+ mainWindow.setOverlayIcon(createDotOverlay(true), 'Ready for you')
174
+ mainWindow.flashFrame(true)
175
+ }
176
+ if (process.platform === 'linux') {
177
+ if (typeof app.badgeCount === 'number') app.badgeCount = 1
178
+ mainWindow?.flashFrame(true)
179
+ }
180
+ }
181
+
182
+ function stopAttention(): void {
183
+ if (!attentionActive) return
184
+ attentionActive = false
185
+ log('[agent-state] attention STOP')
186
+
187
+ if (process.platform === 'darwin' && app.dock) {
188
+ setDockReadyDot(false)
189
+ if (bounceId >= 0) { app.dock.cancelBounce(bounceId); bounceId = -1 }
190
+ }
191
+ if (process.platform === 'win32' && mainWindow) {
192
+ mainWindow.flashFrame(false)
193
+ }
194
+ if (process.platform === 'linux') {
195
+ if (typeof app.badgeCount === 'number') app.badgeCount = 0
196
+ mainWindow?.flashFrame(false)
197
+ }
198
+ applySteadyBadge()
199
+ }
200
+
98
201
  // ─── Orchestrator (shared logic) ──────────────────
99
202
 
100
203
  const orchestrator = new Orchestrator(
@@ -107,6 +210,11 @@ const orchestrator = new Orchestrator(
107
210
  onWorkerRegistered: (data) => mainWindow?.webContents.send('worker:registered', data),
108
211
  onProjectChanged: (name) => mainWindow?.webContents.send('app:projectChanged', name),
109
212
  onAgentMessage: (text) => mainWindow?.webContents.send('agent:message', text),
213
+ onAgentStateChange: (tabId, state, anyAwaiting) => {
214
+ log(`[agent-state] tab=${tabId} state=${state} anyAwaiting=${anyAwaiting}`)
215
+ mainWindow?.webContents.send('agent:state', tabId, state, anyAwaiting)
216
+ setAwaitingIndicator(anyAwaiting)
217
+ },
110
218
  onCwdChanged: () => {
111
219
  mainWindow?.webContents.send('app:cwdChanged')
112
220
  updateProjectBadge(orchestrator.cwd)
@@ -146,6 +254,12 @@ function createWindow(): void {
146
254
  mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
147
255
  }
148
256
 
257
+ // Focusing the window means the user is here — drop the dock/taskbar nag (the
258
+ // in-app dot still shows the state). Re-arm it if the agent is still awaiting
259
+ // when focus is lost again.
260
+ mainWindow.on('focus', () => applyAttention(attention.setFocused(true)))
261
+ mainWindow.on('blur', () => applyAttention(attention.setFocused(false)))
262
+
149
263
  mainWindow.on('closed', () => {
150
264
  mainWindow = null
151
265
  })
@@ -496,7 +610,8 @@ app.whenReady().then(() => {
496
610
  if (process.platform === 'darwin' && app.dock) {
497
611
  const iconPath = path.join(__dirname, '../../resources/icon.png')
498
612
  try {
499
- app.dock.setIcon(nativeImage.createFromPath(iconPath))
613
+ baseDockIcon = nativeImage.createFromPath(iconPath)
614
+ app.dock.setIcon(baseDockIcon)
500
615
  } catch { /* ignore */ }
501
616
  }
502
617