@tryinget/pi-activity-strip 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 (58) hide show
  1. package/README.md +59 -21
  2. package/bin/pi-activity-strip.mjs +32 -4
  3. package/native/bin/linux-x64-gnu/artifact.json +4 -2
  4. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  5. package/native/panel/src/app.rs +1141 -84
  6. package/native/panel/src/app_gui_tests.rs +730 -0
  7. package/native/panel/src/app_hover_gui_tests.rs +917 -0
  8. package/native/panel/src/card_view.rs +19 -4
  9. package/native/panel/src/directory_view.rs +471 -0
  10. package/native/panel/src/hints.rs +65 -0
  11. package/native/panel/src/hover-lifecycle-fixtures.json +30 -0
  12. package/native/panel/src/hover_keyboard.rs +237 -0
  13. package/native/panel/src/interaction.rs +156 -0
  14. package/native/panel/src/keyboard-observation-fixtures.json +241 -0
  15. package/native/panel/src/keyboard_observation.rs +70 -0
  16. package/native/panel/src/main.rs +5 -0
  17. package/native/panel/src/protocol.rs +93 -8
  18. package/native/panel/src/runtime.rs +85 -12
  19. package/native/panel/src/style.css +85 -0
  20. package/package.json +6 -7
  21. package/src/broker/server.mjs +31 -14
  22. package/src/client/launcher.mjs +3 -7
  23. package/src/client/runtime-lock.mjs +2 -1
  24. package/src/common/agent-identity.mjs +6 -1
  25. package/src/common/contracts.d.ts +2 -0
  26. package/src/common/contracts.ts +39 -0
  27. package/src/common/keyboard-observation.mjs +35 -0
  28. package/src/common/overlay-readiness.mjs +11 -0
  29. package/src/common/presence-apps.mjs +57 -0
  30. package/src/common/recovery-identity.mjs +85 -0
  31. package/src/common/recovery-manifest.mjs +417 -0
  32. package/src/common/recovery-types.ts +75 -0
  33. package/src/common/ribbon-status.mjs +41 -0
  34. package/src/common/status-report.mjs +37 -2
  35. package/src/common/workspace-view.mjs +118 -0
  36. package/src/native/ak-runtime.mjs +50 -9
  37. package/src/native/focus-visibility.mjs +67 -0
  38. package/src/native/ghostty-tab-inventory.py +40 -23
  39. package/src/native/hover-keyboard.mjs +32 -0
  40. package/src/native/interaction-context.mjs +45 -0
  41. package/src/native/keyboard-entry.mjs +32 -0
  42. package/src/native/main.mjs +91 -42
  43. package/src/native/panel-binary.mjs +25 -8
  44. package/src/native/panel-neutral-source.mjs +53 -0
  45. package/src/native/panel-presentation.mjs +137 -0
  46. package/src/native/panel-projection.mjs +354 -18
  47. package/src/native/panel-protocol.mjs +23 -0
  48. package/src/native/panel-window-jump.mjs +90 -0
  49. package/src/native/placement.mjs +83 -20
  50. package/src/native/recovery-inventory.mjs +209 -0
  51. package/src/native/recovery-proc.mjs +178 -0
  52. package/src/native/recovery-sources.mjs +358 -0
  53. package/src/native/runtime-diagnostics.mjs +242 -0
  54. package/src/native/strip-hotkey-kdl.mjs +252 -0
  55. package/src/native/strip-hotkey.mjs +62 -0
  56. package/src/native/tab-inventory.mjs +62 -11
  57. package/src/native/window-jump.mjs +43 -0
  58. package/src/native/workspace-events.mjs +123 -2
@@ -1,5 +1,10 @@
1
1
  mod app;
2
2
  mod card_view;
3
+ mod directory_view;
4
+ mod hints;
5
+ mod hover_keyboard;
6
+ mod interaction;
7
+ mod keyboard_observation;
3
8
  mod protocol;
4
9
  mod runtime;
5
10
 
@@ -134,20 +134,57 @@ pub struct ViewMessage {
134
134
  #[serde(rename = "type")]
135
135
  pub message_type: String,
136
136
  pub revision: u64,
137
+ /// Changes only when exact controller admission/visibility context changes, not on telemetry.
138
+ #[serde(default)]
139
+ pub interaction_epoch: u64,
137
140
  #[serde(default)]
138
141
  pub visible: bool,
139
142
  #[serde(default)]
140
143
  pub focused_card_id: Option<String>,
141
144
  #[serde(default)]
142
145
  pub sessions: Vec<Card>,
146
+ /// Every agent window on every workspace, for the brand block's window list.
147
+ #[serde(default)]
148
+ pub windows: Vec<WindowEntry>,
149
+ /// The compositor shortcut that enters keyboard mode, as the operator's config binds it.
150
+ #[serde(default)]
151
+ pub hotkey: Option<String>,
152
+ }
153
+
154
+ /// One entry of the window list: the card's first line and the Niri window number to jump to.
155
+ #[derive(Clone, Debug, Deserialize, PartialEq)]
156
+ #[serde(rename_all = "camelCase")]
157
+ pub struct WindowEntry {
158
+ #[serde(default)]
159
+ pub card_id: String,
160
+ #[serde(default)]
161
+ pub label: String,
162
+ pub window_id: i64,
163
+ /// Niri's workspace id: the grouping key, since workspace numbers repeat across monitors.
164
+ #[serde(default, deserialize_with = "lenient_integer")]
165
+ pub workspace_id: Option<i64>,
166
+ #[serde(default, deserialize_with = "lenient_integer")]
167
+ pub workspace_idx: Option<i64>,
168
+ #[serde(default)]
169
+ pub workspace_name: Option<String>,
170
+ #[serde(default)]
171
+ pub hidden_tab: bool,
172
+ #[serde(default)]
173
+ pub current: bool,
143
174
  }
144
175
 
145
176
  fn protocol_version() -> u8 {
146
- 1
177
+ 2
147
178
  }
148
179
 
149
180
  static OUTPUT: OnceLock<Mutex<io::Stdout>> = OnceLock::new();
150
181
  static READY: AtomicBool = AtomicBool::new(false);
182
+ /// Do not rely on closing a never-mapped GTK window to end its application hold.
183
+ /// A fatal private-protocol mismatch drops the Wayland connection (and any grab) immediately.
184
+ pub fn reject_protocol(message: impl Into<String>) -> ! {
185
+ emit_error(message);
186
+ std::process::exit(1);
187
+ }
151
188
 
152
189
  pub fn emit(event: Value) {
153
190
  let stdout = OUTPUT.get_or_init(|| Mutex::new(io::stdout()));
@@ -167,7 +204,7 @@ pub fn was_ready() -> bool {
167
204
  pub fn emit_ready() {
168
205
  READY.store(true, Ordering::SeqCst);
169
206
  emit(json!({
170
- "protocol": 1,
207
+ "protocol": 2,
171
208
  "type": "ready",
172
209
  "pid": std::process::id(),
173
210
  "namespace": "pi-activity-strip"
@@ -175,7 +212,7 @@ pub fn emit_ready() {
175
212
  }
176
213
 
177
214
  pub fn emit_error(message: impl Into<String>) {
178
- emit(json!({ "protocol": 1, "type": "error", "message": message.into() }));
215
+ emit(json!({ "protocol": 2, "type": "error", "message": message.into() }));
179
216
  }
180
217
 
181
218
  pub fn demo_view() -> ViewMessage {
@@ -184,11 +221,13 @@ pub fn demo_view() -> ViewMessage {
184
221
  .unwrap_or_default()
185
222
  .as_millis() as i64;
186
223
  ViewMessage {
187
- protocol: 1,
224
+ protocol: 2,
188
225
  message_type: "view".into(),
189
226
  revision: 1,
227
+ interaction_epoch: 1,
190
228
  visible: true,
191
229
  focused_card_id: Some("demo-tool".into()),
230
+ hotkey: Some("Super+Shift+A".into()),
192
231
  sessions: vec![
193
232
  Card {
194
233
  card_id: "demo-monitor".into(),
@@ -252,6 +291,33 @@ pub fn demo_view() -> ViewMessage {
252
291
  ..empty_card()
253
292
  },
254
293
  ],
294
+ windows: vec![
295
+ demo_window("demo-monitor", "activity-strip", 36, 2, false, false),
296
+ demo_window("demo-tool", "native-dogfood", 43, 2, false, true),
297
+ demo_window("demo-hidden", "pi-extensions", 43, 2, true, false),
298
+ demo_window("demo-other", "dep-surgeon", 344, 3, false, false),
299
+ demo_window("demo-far", "niri-desktop-continuity", 1048, 5, false, false),
300
+ ],
301
+ }
302
+ }
303
+
304
+ fn demo_window(
305
+ card_id: &str,
306
+ label: &str,
307
+ window_id: i64,
308
+ workspace_idx: i64,
309
+ hidden_tab: bool,
310
+ current: bool,
311
+ ) -> WindowEntry {
312
+ WindowEntry {
313
+ card_id: card_id.into(),
314
+ label: label.into(),
315
+ window_id,
316
+ workspace_id: Some(workspace_idx + 100),
317
+ workspace_idx: Some(workspace_idx),
318
+ workspace_name: None,
319
+ hidden_tab,
320
+ current,
255
321
  }
256
322
  }
257
323
 
@@ -290,7 +356,7 @@ mod tests {
290
356
  #[test]
291
357
  fn view_messages_accept_current_node_projection_shape() {
292
358
  let view: ViewMessage = serde_json::from_str(
293
- r#"{"protocol":1,"type":"view","revision":7,"visible":true,"focusedCardId":"card-a","sessions":[{"cardId":"card-a","sessionId":"session-a","state":"tool","agentActive":true,"agentStartedAt":null,"processId":4242}]}"#,
359
+ r#"{"protocol":2,"type":"view","revision":7,"visible":true,"focusedCardId":"card-a","sessions":[{"cardId":"card-a","sessionId":"session-a","state":"tool","agentActive":true,"agentStartedAt":null,"processId":4242}]}"#,
294
360
  )
295
361
  .expect("view should parse");
296
362
  assert_eq!(view.revision, 7);
@@ -300,12 +366,31 @@ mod tests {
300
366
  assert_eq!(view.sessions[0].pid, 4242);
301
367
  assert!(view.sessions[0].ak_tasks.is_empty());
302
368
  assert_eq!(view.sessions[0].ak_task_overflow, 0);
369
+ assert!(
370
+ view.windows.is_empty(),
371
+ "controllers without a window list still parse"
372
+ );
373
+ }
374
+
375
+ #[test]
376
+ fn view_messages_carry_the_window_list() {
377
+ let view: ViewMessage = serde_json::from_str(
378
+ r#"{"type":"view","revision":1,"windows":[{"cardId":"terminal:a","label":"pi-extensions","windowId":432,"workspaceIdx":"x","workspaceName":null,"hiddenTab":true,"current":false}]}"#,
379
+ )
380
+ .expect("view should parse");
381
+ assert_eq!(view.windows.len(), 1);
382
+ assert_eq!(view.windows[0].window_id, 432);
383
+ assert_eq!(
384
+ view.windows[0].workspace_idx, None,
385
+ "a malformed workspace number drops only that field"
386
+ );
387
+ assert!(view.windows[0].hidden_tab);
303
388
  }
304
389
 
305
390
  #[test]
306
391
  fn window_and_workspace_numbers_are_optional_card_fields() {
307
392
  let view: ViewMessage = serde_json::from_str(
308
- r#"{"protocol":1,"type":"view","revision":9,"sessions":[{"cardId":"card-a","windowId":43,"workspaceIdx":2},{"cardId":"card-b","windowId":null,"workspaceIdx":null},{"cardId":"card-c"}]}"#,
393
+ r#"{"protocol":2,"type":"view","revision":9,"sessions":[{"cardId":"card-a","windowId":43,"workspaceIdx":2},{"cardId":"card-b","windowId":null,"workspaceIdx":null},{"cardId":"card-c"}]}"#,
309
394
  )
310
395
  .expect("view should parse");
311
396
  assert_eq!(view.sessions[0].window_id, Some(43));
@@ -322,7 +407,7 @@ mod tests {
322
407
  #[test]
323
408
  fn a_malformed_window_or_workspace_number_drops_only_that_field() {
324
409
  let view: ViewMessage = serde_json::from_str(
325
- r#"{"protocol":1,"type":"view","revision":10,"sessions":[{"cardId":"card-a","windowId":"43","workspaceIdx":2.5},{"cardId":"card-b","windowId":44,"workspaceIdx":3}]}"#,
410
+ r#"{"protocol":2,"type":"view","revision":10,"sessions":[{"cardId":"card-a","windowId":"43","workspaceIdx":2.5},{"cardId":"card-b","windowId":44,"workspaceIdx":3}]}"#,
326
411
  )
327
412
  .expect("a bad optional field must not reject the whole view");
328
413
  assert_eq!(view.sessions.len(), 2);
@@ -335,7 +420,7 @@ mod tests {
335
420
  #[test]
336
421
  fn ak_task_chips_parse_with_states() {
337
422
  let view: ViewMessage = serde_json::from_str(
338
- r#"{"protocol":1,"type":"view","revision":8,"sessions":[{"cardId":"card-a","akTasks":[{"id":5701,"title":"Show clickable AK-task references","state":"active"},{"id":5432,"title":"Preserve safe NEXUS recovery reasons","state":"orphaned"},{"id":4220,"title":"Control Decision 77 epoch admissions","state":"deferred"}],"akTaskOverflow":3}]}"#,
423
+ r#"{"protocol":2,"type":"view","revision":8,"sessions":[{"cardId":"card-a","akTasks":[{"id":5701,"title":"Show clickable AK-task references","state":"active"},{"id":5432,"title":"Preserve safe NEXUS recovery reasons","state":"orphaned"},{"id":4220,"title":"Control Decision 77 epoch admissions","state":"deferred"}],"akTaskOverflow":3}]}"#,
339
424
  )
340
425
  .expect("view should parse");
341
426
  let card = &view.sessions[0];
@@ -45,20 +45,38 @@ pub fn apply_theme(definitions: &str) {
45
45
  THEME_PROVIDER.with(|provider| provider.load_from_data(&css));
46
46
  }
47
47
 
48
+ /// Read controller messages from stdin on their own thread. The thread is detached on purpose: it
49
+ /// lives exactly as long as stdin, and on EOF it reports the parent gone, which closes the panel.
48
50
  pub fn start_input_reader(sender: relm4::Sender<AppMsg>) {
49
- std::thread::spawn(move || {
50
- for line in BufReader::new(std::io::stdin()).lines() {
51
- match line {
52
- Ok(line) if !line.trim().is_empty() => consume_line(&sender, &line),
53
- Ok(_) => {}
54
- Err(error) => {
55
- let _ = sender.send(AppMsg::InputError(format!("panel input failed: {error}")));
56
- break;
57
- }
51
+ let reader_sender = sender.clone();
52
+ let spawned = std::thread::Builder::new()
53
+ .name("panel-input".into())
54
+ .spawn(move || read_input(reader_sender));
55
+ match spawned {
56
+ Ok(handle) => drop(handle),
57
+ Err(error) => {
58
+ let _ = sender.send(AppMsg::InputError(format!(
59
+ "could not start the panel input reader: {error}"
60
+ )));
61
+ let _ = sender.send(AppMsg::ParentGone);
62
+ }
63
+ }
64
+ }
65
+
66
+ fn read_input(sender: relm4::Sender<AppMsg>) {
67
+ for line in BufReader::new(std::io::stdin()).lines() {
68
+ match line {
69
+ Ok(line) if !line.trim().is_empty() => {
70
+ consume_line(&sender, &line);
71
+ }
72
+ Ok(_) => {}
73
+ Err(error) => {
74
+ let _ = sender.send(AppMsg::InputError(format!("panel input failed: {error}")));
75
+ break;
58
76
  }
59
77
  }
60
- let _ = sender.send(AppMsg::ParentGone);
61
- });
78
+ }
79
+ let _ = sender.send(AppMsg::ParentGone);
62
80
  }
63
81
 
64
82
  fn consume_line(sender: &relm4::Sender<AppMsg>, line: &str) {
@@ -71,6 +89,12 @@ fn consume_line(sender: &relm4::Sender<AppMsg>, line: &str) {
71
89
  return;
72
90
  }
73
91
  };
92
+ if value.get("protocol").and_then(|p| p.as_u64()) != Some(2) {
93
+ crate::protocol::reject_protocol(format!(
94
+ "Native panel private protocol mismatch: expected 2, received {}",
95
+ value.get("protocol").unwrap_or(&serde_json::Value::Null)
96
+ ));
97
+ }
74
98
  match value.get("type").and_then(|kind| kind.as_str()) {
75
99
  Some("view") => match serde_json::from_value::<ViewMessage>(value) {
76
100
  Ok(view) => {
@@ -95,7 +119,40 @@ fn consume_line(sender: &relm4::Sender<AppMsg>, line: &str) {
95
119
  }
96
120
  }
97
121
  Some("focus-strip") => {
98
- let _ = sender.send(AppMsg::FocusStrip);
122
+ let _ = sender.send(AppMsg::FocusStrip(
123
+ value
124
+ .get("revision")
125
+ .and_then(|item| item.as_u64())
126
+ .unwrap_or(0),
127
+ value
128
+ .get("epoch")
129
+ .and_then(|item| item.as_u64())
130
+ .unwrap_or(0),
131
+ ));
132
+ }
133
+ Some("hover-grant") => {
134
+ let _ = sender.send(AppMsg::HoverGrant(
135
+ value
136
+ .get("token")
137
+ .and_then(|item| item.as_u64())
138
+ .unwrap_or(0),
139
+ value
140
+ .get("epoch")
141
+ .and_then(|item| item.as_u64())
142
+ .unwrap_or(0),
143
+ value
144
+ .get("request")
145
+ .and_then(|item| item.as_u64())
146
+ .unwrap_or(0),
147
+ ));
148
+ }
149
+ Some("hover-denied") => {
150
+ let _ = sender.send(AppMsg::HoverDenied(
151
+ value
152
+ .get("token")
153
+ .and_then(|item| item.as_u64())
154
+ .unwrap_or(0),
155
+ ));
99
156
  }
100
157
  Some("collapse") => {
101
158
  let _ = sender.send(AppMsg::Collapse);
@@ -121,6 +178,22 @@ fn consume_line(sender: &relm4::Sender<AppMsg>, line: &str) {
121
178
  .to_owned();
122
179
  let _ = sender.send(AppMsg::ActivationResult(card_id, ok, message));
123
180
  }
181
+ Some("jump-result") => {
182
+ let ok = value
183
+ .get("ok")
184
+ .and_then(|item| item.as_bool())
185
+ .unwrap_or(false);
186
+ let message = value
187
+ .get("message")
188
+ .and_then(|item| item.as_str())
189
+ .unwrap_or(if ok { "Jumped." } else { "Nothing moved." })
190
+ .to_owned();
191
+ let window_id = value
192
+ .get("windowId")
193
+ .and_then(|item| item.as_i64())
194
+ .unwrap_or(-1);
195
+ let _ = sender.send(AppMsg::JumpResult(window_id, ok, message));
196
+ }
124
197
  Some(kind) => {
125
198
  let _ = sender.send(AppMsg::InputError(format!(
126
199
  "unsupported panel message: {kind}"
@@ -265,3 +265,88 @@ window {
265
265
  font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
266
266
  font-size: 8px;
267
267
  }
268
+
269
+ /*
270
+ * The brand block's window list: every agent window on every workspace, as its card's first line
271
+ * and its Niri window number. The number is the thing you type, so it carries the accent.
272
+ */
273
+ .directory {
274
+ border-top: 1px solid alpha(@pi_foreground, 0.12);
275
+ padding-top: 7px;
276
+ }
277
+
278
+ .jump-line {
279
+ color: @pi_accent;
280
+ font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
281
+ font-size: 10px;
282
+ font-weight: 600;
283
+ }
284
+
285
+ /* biome-ignore lint/correctness/noUnknownTypeSelector: GTK CSS uses widget node names. */
286
+ .directory-scroller,
287
+ .directory-scroller > viewport {
288
+ background: transparent;
289
+ }
290
+
291
+ /* Same as the card row: scrolls by wheel or touchpad without a visible bar. */
292
+ /* biome-ignore lint/correctness/noUnknownTypeSelector: GTK CSS uses widget node names. */
293
+ .directory-scroller scrollbar {
294
+ opacity: 0;
295
+ min-width: 0;
296
+ min-height: 0;
297
+ }
298
+
299
+ .directory-header {
300
+ color: alpha(@pi_foreground, 0.5);
301
+ font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
302
+ font-size: 8px;
303
+ letter-spacing: 1px;
304
+ margin-top: 5px;
305
+ }
306
+
307
+ .directory-row {
308
+ background: transparent;
309
+ border: 1px solid transparent;
310
+ border-radius: 6px;
311
+ color: @pi_foreground;
312
+ min-height: 0;
313
+ padding: 2px 6px;
314
+ }
315
+
316
+ .directory-row:hover,
317
+ .directory-row:focus-visible {
318
+ background: mix(@pi_background, @pi_foreground, 0.1);
319
+ }
320
+
321
+ /* The window you are in: the same accent ring as its card. */
322
+ .directory-row.current {
323
+ border-color: @pi_accent;
324
+ }
325
+
326
+ .directory-label {
327
+ font-size: 11px;
328
+ }
329
+
330
+ .directory-id {
331
+ color: @pi_accent;
332
+ font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
333
+ font-size: 10px;
334
+ font-weight: 600;
335
+ }
336
+
337
+ .directory-empty {
338
+ color: alpha(@pi_foreground, 0.5);
339
+ font-size: 10px;
340
+ }
341
+
342
+ /* Which workspace a row's window is on; quieter than the number you type. */
343
+ .directory-workspace {
344
+ color: alpha(@pi_foreground, 0.5);
345
+ font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
346
+ font-size: 9px;
347
+ }
348
+
349
+ /* The row Enter would open: a typed name always shows where it leads. */
350
+ .directory-row.chosen {
351
+ background: alpha(@pi_accent, 0.16);
352
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tryinget/pi-activity-strip",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "pi extension package for activity-strip workflows in monorepo runtime",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -46,7 +46,7 @@
46
46
  "docs:list:json": "node ~/ai-society/core/agent-scripts/scripts/docs-list.mjs --json",
47
47
  "release:check": "bash ./scripts/release-check.sh",
48
48
  "release:check:quick": "SKIP_PI_SMOKE=1 bash ./scripts/release-check.sh",
49
- "native:check": "cd native/panel && RUSTUP_TOOLCHAIN=stable cargo fmt --check && RUSTUP_TOOLCHAIN=stable cargo test",
49
+ "native:check": "cd native/panel && RUSTUP_TOOLCHAIN=1.98.0 cargo fmt --check && RUSTUP_TOOLCHAIN=1.98.0 cargo test --locked",
50
50
  "native:build": "bash ./scripts/build-native-panel.sh",
51
51
  "strip:open": "node ./bin/pi-activity-strip.mjs open",
52
52
  "strip:status": "node ./bin/pi-activity-strip.mjs status",
@@ -88,13 +88,12 @@
88
88
  },
89
89
  "devDependencies": {
90
90
  "@biomejs/biome": "2.4.4",
91
- "@earendil-works/pi-agent-core": "0.84.4",
92
- "@earendil-works/pi-ai": "0.84.4",
93
- "@earendil-works/pi-coding-agent": "0.84.4",
91
+ "@earendil-works/pi-agent-core": "1.4.0",
92
+ "@earendil-works/pi-ai": "1.4.0",
93
+ "@earendil-works/pi-coding-agent": "1.4.0",
94
94
  "@types/node": "22.14.0",
95
- "@typescript/native-preview": "7.0.0-dev.20260417.1",
96
95
  "typebox": "1.3.7",
97
- "typescript": "5.9.2"
96
+ "typescript": "7.0.2"
98
97
  },
99
98
  "overrides": {
100
99
  "fast-xml-parser": "5.7.0"
@@ -58,28 +58,45 @@ export class ActivityStripBroker extends EventEmitter {
58
58
  }
59
59
 
60
60
  async start() {
61
+ // Linux sun_path has 108 bytes including the trailing NUL. Node can silently
62
+ // bind a truncated path, so reject before chmod/unlink can change the filesystem.
63
+ if (Buffer.byteLength(this.socketPath, "utf8") > 107) {
64
+ throw new RangeError("Activity strip socket path must not exceed 107 UTF-8 bytes.");
65
+ }
66
+
61
67
  fs.mkdirSync(this.socketDir, { recursive: true, mode: 0o700 });
62
68
  fs.chmodSync(this.socketDir, 0o700);
63
69
  safeUnlink(this.socketPath);
64
70
 
65
- await new Promise((resolve, reject) => {
66
- this.server.once("error", reject);
67
- this.server.listen(this.socketPath, () => {
68
- this.server.off("error", reject);
69
- try {
70
- fs.chmodSync(this.socketPath, 0o600);
71
+ let bound = false;
72
+ try {
73
+ await new Promise((resolve, reject) => {
74
+ this.server.once("error", reject);
75
+ this.server.listen(this.socketPath, () => {
76
+ this.server.off("error", reject);
77
+ bound = true;
71
78
  resolve(undefined);
72
- } catch (error) {
73
- reject(error);
74
- }
79
+ });
75
80
  });
76
- });
81
+ fs.chmodSync(this.socketPath, 0o600);
77
82
 
78
- this.tick = setInterval(() => {
83
+ this.tick = setInterval(() => {
84
+ this.emitSnapshot();
85
+ }, ACTIVITY_STRIP_BROADCAST_TICK_MS);
86
+ this.tick.unref?.();
79
87
  this.emitSnapshot();
80
- }, ACTIVITY_STRIP_BROADCAST_TICK_MS);
81
- this.tick.unref?.();
82
- this.emitSnapshot();
88
+ } catch (error) {
89
+ // Only a successful bind gives us a socket to clean up. On a failed bind,
90
+ // stop() could unlink the address of the listener that beat us to it.
91
+ if (bound) {
92
+ try {
93
+ await this.stop();
94
+ } catch {
95
+ // Preserve the startup failure even if cleanup also fails.
96
+ }
97
+ }
98
+ throw error;
99
+ }
83
100
  }
84
101
 
85
102
  emitSnapshot() {
@@ -8,13 +8,9 @@ import { spawn } from "node:child_process";
8
8
  import { setTimeout as delay } from "node:timers/promises";
9
9
  import { assessActivityStripCompatibility } from "../common/compatibility.mjs";
10
10
  import { ACTIVITY_STRIP_START_TIMEOUT_MS } from "../common/constants.mjs";
11
+ import { isOverlayReady } from "../common/overlay-readiness.mjs";
11
12
  import { getBrokerStatus } from "./broker-client.mjs";
12
13
 
13
- /** @param {import("../common/contracts.ts").BrokerResponse | null | undefined} result */
14
- function isBrokerReady(result) {
15
- return Boolean(result?.ok && (!result.runtimeStatus || result.runtimeStatus.state === "ready"));
16
- }
17
-
18
14
  /** @param {import("../common/contracts.ts").BrokerResponse | null | undefined} result */
19
15
  function describeBrokerWait(result) {
20
16
  if (!result?.ok) return "Activity strip broker did not answer.";
@@ -64,7 +60,7 @@ export async function ensureActivityStripRunning(binPath, options = {}) {
64
60
  latestStatus = null;
65
61
  }
66
62
 
67
- if (isBrokerReady(latestStatus)) {
63
+ if (isOverlayReady(latestStatus)) {
68
64
  return { ok: true, started: false };
69
65
  }
70
66
 
@@ -101,7 +97,7 @@ export async function ensureActivityStripRunning(binPath, options = {}) {
101
97
  } catch {
102
98
  latestStatus = null;
103
99
  }
104
- if (isBrokerReady(latestStatus)) {
100
+ if (isOverlayReady(latestStatus)) {
105
101
  return { ok: true, started };
106
102
  }
107
103
  if (latestStatus?.runtimeStatus?.state === "error") {
@@ -13,6 +13,7 @@ import {
13
13
  ACTIVITY_STRIP_START_TIMEOUT_MS,
14
14
  ACTIVITY_STRIP_STOP_TIMEOUT_MS,
15
15
  } from "../common/constants.mjs";
16
+ import { isOverlayReady } from "../common/overlay-readiness.mjs";
16
17
  import { requestBrokerShutdown } from "./broker-client.mjs";
17
18
 
18
19
  const execFileAsync = promisify(execFile);
@@ -123,7 +124,7 @@ export async function waitForStartedRuntime({
123
124
  const deadline = now() + timeoutMs;
124
125
  while (true) {
125
126
  const status = await getStatus();
126
- if (status?.ok && (!status.runtimeStatus || status.runtimeStatus.state === "ready")) {
127
+ if (isOverlayReady(status)) {
127
128
  return { ok: true, started: controller.exitCode === undefined };
128
129
  }
129
130
  if (status?.runtimeStatus?.state === "error") return { ok: false, reason: "error", status };
@@ -21,8 +21,13 @@ export const AGENT_CATALOG = Object.freeze([
21
21
  { kind: "opencode", label: "opencode", commands: Object.freeze(["opencode"]) },
22
22
  { kind: "crush", label: "Crush", commands: Object.freeze(["crush"]) },
23
23
  { kind: "goose", label: "Goose", commands: Object.freeze(["goose"]) },
24
- { kind: "cursor", label: "Cursor Agent", commands: Object.freeze(["cursor-agent"]) },
24
+ // Cursor's CLI installs both names; `agent` is the one its docs now lead with.
25
+ { kind: "cursor", label: "Cursor Agent", commands: Object.freeze(["cursor-agent", "agent"]) },
25
26
  { kind: "zcode", label: "zcode", commands: Object.freeze(["zcode"]) },
27
+ // The nodecode TUI; its gateway runs outside any terminal, so tab admission never sees it.
28
+ { kind: "nodecode", label: "Nodecode", commands: Object.freeze(["nodecode"]) },
29
+ // angelx is a Python launcher; the cockpit binary it starts, `angel`, is what holds the tab.
30
+ { kind: "angel", label: "angelx", commands: Object.freeze(["angel"]) },
26
31
  ]);
27
32
 
28
33
  /** Pi publishes its own telemetry, so its processes are never discovered this way. */
@@ -99,6 +99,7 @@ export interface ActivityStripRuntimeStatus {
99
99
  clickThrough?: boolean;
100
100
  panelMoveCount?: number;
101
101
  panelActivationCount?: number;
102
+ panelJumpCount?: number;
102
103
  panelRestartCount?: number;
103
104
  lastMovedCardId?: string | null;
104
105
  warnings?: string[];
@@ -168,6 +169,7 @@ export interface SessionStartContextLike {
168
169
  }
169
170
 
170
171
  export interface BeforeAgentStartEventLike {
172
+ messageType?: "user" | "custom";
171
173
  prompt?: string;
172
174
  }
173
175
 
@@ -80,6 +80,17 @@ export interface BrokerSnapshot {
80
80
 
81
81
  export type ActivityStripRuntimeState = "starting" | "ready" | "error";
82
82
 
83
+ /** A last native observation, not current capture/authorization or a cross-clock timer. */
84
+ export interface NativeKeyboardObservation {
85
+ panelPid: number;
86
+ request: number;
87
+ origin: "hover" | "explicit";
88
+ reason: "acquired" | "activity" | "idle-expired" | "source-revoked";
89
+ nativeMs: number;
90
+ deadlineMs: number | null;
91
+ captured: boolean;
92
+ }
93
+
83
94
  export interface ActivityStripRuntimeStatus {
84
95
  state: ActivityStripRuntimeState;
85
96
  backend?: "native-layer-shell";
@@ -114,15 +125,42 @@ export interface ActivityStripRuntimeStatus {
114
125
  akTaskError?: string | null;
115
126
  akTaskClaimCount?: number;
116
127
  akTaskDeferredCount?: number;
128
+ /** When the deferral list was last read; it is reused for up to AK_DEFERRED_REFRESH_MS (AK7943). */
129
+ akTaskDeferredReadAt?: number | null;
130
+ /** How long that list may be reused (AK_DEFERRED_REFRESH_MS). */
131
+ akTaskDeferredMaxAgeMs?: number;
117
132
  akTaskOrphanCount?: number;
118
133
  akTaskRenderedCount?: number;
119
134
  rendererVisibilityTransitionCount?: number;
120
135
  controllerPid?: number;
121
136
  panelPid?: number | null;
122
137
  panelExpanded?: boolean;
138
+ nativeKeyboardObservation?: NativeKeyboardObservation | null;
139
+ /** Optional observational extension; absent on older controllers, never input/AK authority. */
140
+ ribbonDiagnostics?: {
141
+ controllerStartupSourceSha256: string | null;
142
+ expectedPrivateProtocol: number;
143
+ panelBuild: {
144
+ state: string;
145
+ binarySha256?: string;
146
+ processStartTicks?: string;
147
+ privateProtocol?: number | null;
148
+ receiptedSourceSha256?: string | null;
149
+ };
150
+ visibilityReason: string;
151
+ panelBuildObservedAt?: number | null;
152
+ focus: string;
153
+ contextValid: boolean;
154
+ eligible: boolean;
155
+ interactionEpoch: number;
156
+ keyboardAuthorization: "none" | "hover" | "explicit";
157
+ captureExpiry: string;
158
+ nativeKeyboardObservation?: NativeKeyboardObservation | null;
159
+ };
123
160
  clickThrough?: boolean;
124
161
  panelMoveCount?: number;
125
162
  panelActivationCount?: number;
163
+ panelJumpCount?: number;
126
164
  panelRestartCount?: number;
127
165
  lastMovedCardId?: string | null;
128
166
  warnings?: string[];
@@ -198,6 +236,7 @@ export interface SessionStartContextLike {
198
236
  }
199
237
 
200
238
  export interface BeforeAgentStartEventLike {
239
+ messageType?: "user" | "custom";
201
240
  prompt?: string;
202
241
  }
203
242