@tryinget/pi-activity-strip 0.5.0 → 0.6.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 (41) hide show
  1. package/README.md +41 -6
  2. package/bin/pi-activity-strip-claude-hook.mjs +71 -0
  3. package/bin/pi-activity-strip.mjs +22 -1
  4. package/native/bin/linux-x64-gnu/artifact.json +2 -2
  5. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  6. package/native/panel/src/app.rs +20 -20
  7. package/native/panel/src/card_view.rs +308 -9
  8. package/native/panel/src/protocol.rs +63 -0
  9. package/native/panel/src/runtime.rs +50 -9
  10. package/native/panel/src/style.css +134 -60
  11. package/package.json +4 -4
  12. package/src/common/agent-identity.mjs +163 -0
  13. package/src/common/ak-tasks.mjs +354 -0
  14. package/src/common/claude-events.mjs +190 -0
  15. package/src/common/claude-hook-config.mjs +56 -0
  16. package/src/common/claude-transcript.mjs +163 -0
  17. package/src/common/codex-transcript.mjs +153 -0
  18. package/src/common/compatibility.mjs +13 -0
  19. package/src/common/contracts.d.ts +26 -0
  20. package/src/common/contracts.ts +64 -1
  21. package/src/common/ghostty-present.mjs +100 -0
  22. package/src/common/ghostty-theme.mjs +149 -0
  23. package/src/common/niri-focus.mjs +179 -110
  24. package/src/common/session-cards.mjs +5 -2
  25. package/src/common/status-report.mjs +51 -0
  26. package/src/common/surface-bindings.mjs +234 -0
  27. package/src/common/terminal-identity.mjs +75 -12
  28. package/src/common/window-placement.mjs +59 -0
  29. package/src/common/workspace-view.mjs +161 -0
  30. package/src/native/agent-discovery.mjs +294 -0
  31. package/src/native/ak-runtime.mjs +164 -0
  32. package/src/native/codex-discovery.mjs +128 -0
  33. package/src/native/ghostty-tab-inventory.py +99 -0
  34. package/src/native/height-repair.mjs +154 -0
  35. package/src/native/main.mjs +120 -14
  36. package/src/native/panel-projection.mjs +67 -5
  37. package/src/native/placement.mjs +225 -0
  38. package/src/native/tab-inventory.mjs +188 -0
  39. package/src/native/theme-runtime.mjs +72 -0
  40. package/src/native/theme-source.mjs +197 -0
  41. package/src/native/workspace-events.mjs +16 -1
@@ -0,0 +1,154 @@
1
+ // ---
2
+ // summary: "restores tiled windows stranded at the wrong height when the ribbon's exclusive zone appears or disappears"
3
+ // read_when:
4
+ // - "changing height repair, its evidence rules, or its safety bounds"
5
+ // ---
6
+
7
+ export const HEIGHT_REPAIR_SETTLE_MS = 300;
8
+ export const HEIGHT_REPAIR_MAX_PER_TRANSITION = 48;
9
+
10
+ /** @typedef {{id?: unknown; is_floating?: unknown; layout?: {window_size?: number[]}}} NiriWindow */
11
+ /** @typedef {{id: number; height: number}} StaleWindow */
12
+
13
+ /** @param {NiriWindow} window */
14
+ function tiledHeight(window) {
15
+ if (window?.is_floating === true) return 0;
16
+ const height = Number(window?.layout?.window_size?.[1] ?? 0);
17
+ return Number.isFinite(height) && height > 0 ? height : 0;
18
+ }
19
+
20
+ /** @param {NiriWindow[]} windows */
21
+ function heightsById(windows) {
22
+ /** @type {Map<number, number>} */
23
+ const heights = new Map();
24
+ for (const window of windows ?? []) {
25
+ if (!Number.isInteger(window?.id)) continue;
26
+ const height = tiledHeight(window);
27
+ if (height > 0) heights.set(Number(window.id), height);
28
+ }
29
+ return heights;
30
+ }
31
+
32
+ /**
33
+ * Decide which windows the compositor left behind. Changing the exclusive zone changes the output
34
+ * working area, so every window whose height is automatic moves. A window that did not move while
35
+ * others did is only treated as stale when it still sits at a height those movers just vacated:
36
+ * that is positive evidence of the old working area, so a deliberately chosen height of any other
37
+ * value is never touched.
38
+ * @param {NiriWindow[]} before
39
+ * @param {NiriWindow[]} after
40
+ * @returns {StaleWindow[]}
41
+ */
42
+ export function findStaleWindows(before, after) {
43
+ const previous = heightsById(before);
44
+ const current = heightsById(after);
45
+ /** @type {Set<number>} */
46
+ const vacated = new Set();
47
+ for (const [id, height] of current) {
48
+ const wasHeight = previous.get(id);
49
+ if (wasHeight !== undefined && wasHeight !== height) vacated.add(wasHeight);
50
+ }
51
+ if (vacated.size === 0) return [];
52
+
53
+ /** @type {StaleWindow[]} */
54
+ const stale = [];
55
+ for (const [id, height] of current) {
56
+ const wasHeight = previous.get(id);
57
+ if (wasHeight === undefined || wasHeight !== height) continue;
58
+ if (vacated.has(height)) stale.push({ id, height });
59
+ }
60
+ return stale.sort((left, right) => left.id - right.id);
61
+ }
62
+
63
+ /**
64
+ * @param {{
65
+ * execFileAsync: (file: string, args: string[], options: object) => Promise<{stdout?: string}>;
66
+ * env?: NodeJS.ProcessEnv;
67
+ * runtimeStatus: import("../common/contracts.ts").ActivityStripRuntimeStatus;
68
+ * readWindows: () => Promise<NiriWindow[]>;
69
+ * sleep?: (milliseconds: number) => Promise<void>;
70
+ * settleMs?: number;
71
+ * }} options
72
+ */
73
+ export function createHeightRepair({
74
+ execFileAsync,
75
+ env = process.env,
76
+ runtimeStatus,
77
+ readWindows,
78
+ sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
79
+ settleMs = HEIGHT_REPAIR_SETTLE_MS,
80
+ }) {
81
+ const enabled = env.PI_ACTIVITY_STRIP_HEIGHT_REPAIR !== "0";
82
+ /** @type {NiriWindow[]} */
83
+ let lastWindows = [];
84
+ /** Windows already reset at a given height, so one pass can never loop on one window. */
85
+ /** @type {Set<string>} */
86
+ const repaired = new Set();
87
+ let inFlight = false;
88
+ runtimeStatus.heightRepairState = enabled ? "enabled" : "disabled";
89
+ runtimeStatus.heightRepairCount = 0;
90
+ runtimeStatus.lastHeightRepairAt = 0;
91
+
92
+ /** @param {NiriWindow[]} windows */
93
+ function observe(windows) {
94
+ if (!Array.isArray(windows) || windows.length === 0) return;
95
+ // A window seen at a height other than the one it was reset at has moved since, so a future
96
+ // stranding there is a new event and must not be suppressed by the earlier repair.
97
+ for (const window of windows) {
98
+ if (!Number.isInteger(window?.id)) continue;
99
+ const height = tiledHeight(window);
100
+ if (height <= 0) continue;
101
+ for (const key of repaired) {
102
+ const [id, at] = key.split(":");
103
+ if (Number(id) === Number(window.id) && Number(at) !== height) repaired.delete(key);
104
+ }
105
+ }
106
+ lastWindows = windows;
107
+ }
108
+
109
+ /**
110
+ * Run after the panel reports that it applied a visibility change, which is the only moment the
111
+ * exclusive zone changes. One pass at a time; a pass never retries a window it already reset.
112
+ */
113
+ async function repairAfterZoneChange() {
114
+ if (!enabled || inFlight) return [];
115
+ const before = lastWindows;
116
+ if (before.length === 0) return [];
117
+ inFlight = true;
118
+ try {
119
+ await sleep(settleMs);
120
+ const after = await readWindows();
121
+ if (after.length === 0) return [];
122
+ lastWindows = after;
123
+ const stale = findStaleWindows(before, after).filter(
124
+ (window) => !repaired.has(`${window.id}:${window.height}`),
125
+ );
126
+ const bounded = stale.slice(0, HEIGHT_REPAIR_MAX_PER_TRANSITION);
127
+ /** @type {number[]} */
128
+ const applied = [];
129
+ for (const window of bounded) {
130
+ try {
131
+ await execFileAsync(
132
+ "niri",
133
+ ["msg", "action", "reset-window-height", "--id", String(window.id)],
134
+ { env },
135
+ );
136
+ repaired.add(`${window.id}:${window.height}`);
137
+ applied.push(window.id);
138
+ } catch {
139
+ // A window that closed mid-pass, or a rejected action, is left to the next transition.
140
+ }
141
+ }
142
+ if (applied.length > 0) {
143
+ runtimeStatus.heightRepairCount =
144
+ Number(runtimeStatus.heightRepairCount ?? 0) + applied.length;
145
+ runtimeStatus.lastHeightRepairAt = Date.now();
146
+ }
147
+ return applied;
148
+ } finally {
149
+ inFlight = false;
150
+ }
151
+ }
152
+
153
+ return { observe, repairAfterZoneChange, enabled };
154
+ }
@@ -13,15 +13,17 @@ import { fileURLToPath } from "node:url";
13
13
  import { promisify } from "node:util";
14
14
  import { createActivityStripBroker } from "../broker/server.mjs";
15
15
  import { createLatestOnlyRunner } from "../common/alignment-controller.mjs";
16
+ import { pruneClaudeEventRecords } from "../common/claude-events.mjs";
16
17
  import { ACTIVITY_STRIP_WORKSPACE_SYNC_MS } from "../common/constants.mjs";
17
- import {
18
- focusNiriSession,
19
- readNiriWindows,
20
- readNiriWorkspaces,
21
- resolveFocusedWorkspaceView,
22
- } from "../common/niri-focus.mjs";
18
+ import { focusNiriSession, readNiriWindows, readNiriWorkspaces } from "../common/niri-focus.mjs";
23
19
  import { haveSameRecordMembership } from "../common/session-cards.mjs";
20
+ import { resolveFocusedWorkspaceView } from "../common/workspace-view.mjs";
21
+ import { discoverAgentTabs } from "./agent-discovery.mjs";
22
+ import { startAkTaskProjection } from "./ak-runtime.mjs";
23
+ import { createHeightRepair } from "./height-repair.mjs";
24
24
  import { createNativePanelProjection } from "./panel-projection.mjs";
25
+ import { createPlacementRuntime } from "./placement.mjs";
26
+ import { createThemeRuntime, THEME_POLL_INTERVAL_MS } from "./theme-runtime.mjs";
25
27
  import { createNiriWorkspaceEventWatcher } from "./workspace-events.mjs";
26
28
 
27
29
  /** @typedef {import("../common/contracts.ts").ActivityStripRuntimeStatus} ActivityStripRuntimeStatus */
@@ -66,6 +68,15 @@ const runtimeStatus = {
66
68
  lastMovedCardId: null,
67
69
  rendererCardCount: 0,
68
70
  rendererCardIds: [],
71
+ rendererHiddenTabCardCount: 0,
72
+ agentTabCount: 0,
73
+ surfaceBindingCount: 0,
74
+ unplacedSurfaceCount: 0,
75
+ tabInventoryState: process.env.PI_ACTIVITY_STRIP_TAB_INVENTORY === "0" ? "disabled" : "pending",
76
+ tabInventoryDetail: null,
77
+ tabInventoryFrameCount: 0,
78
+ tabInventoryTabCount: 0,
79
+ tabInventoryProbedAt: null,
69
80
  warnings: [],
70
81
  error: null,
71
82
  };
@@ -74,6 +85,15 @@ function isNiriSession() {
74
85
  return Boolean(process.env.NIRI_SOCKET);
75
86
  }
76
87
 
88
+ // Hidden Ghostty tabs are placed by their own runtime: host-process containment plus the window
89
+ // memory learned from visible titles and the bounded AT-SPI tab inventory.
90
+ const placement = createPlacementRuntime({
91
+ env: process.env,
92
+ runtimeStatus,
93
+ execFileAsync,
94
+ onBindingsLearned: () => reconcileRunner.request(),
95
+ });
96
+
77
97
  function panelBinaryPath() {
78
98
  const override = process.env.PI_ACTIVITY_STRIP_NATIVE_PANEL_BIN?.trim();
79
99
  const allowUnverified = process.env.PI_ACTIVITY_STRIP_ALLOW_UNVERIFIED_PANEL === "1";
@@ -150,19 +170,32 @@ const projection = createNativePanelProjection({
150
170
  publish(view) {
151
171
  runtimeStatus.rendererCardCount = view.sessions.length;
152
172
  runtimeStatus.rendererCardIds = view.sessions.map((session) => String(session.cardId ?? ""));
173
+ runtimeStatus.rendererHiddenTabCardCount = view.sessions.filter(
174
+ (session) => session.surfaceVisible === false,
175
+ ).length;
176
+ runtimeStatus.akTaskRenderedCount = view.sessions.reduce(
177
+ (total, session) => total + (Array.isArray(session.akTasks) ? session.akTasks.length : 0),
178
+ 0,
179
+ );
153
180
  writePanel(view);
154
181
  },
155
182
  });
156
183
 
157
- /** @param {string} targetId @returns {Promise<{ok: boolean; error?: string; windowId?: number}>} */
184
+ // AK task references are a read-only projection of claims held by live sessions; the strip
185
+ // never writes AK state and renders nothing when the read fails.
186
+ startAkTaskProjection({ execFileAsync, env: process.env, runtimeStatus, projection });
187
+
188
+ /** @param {string} targetId @returns {Promise<{ok: boolean; error?: string; windowId?: number; presented?: boolean; verified?: boolean}>} */
158
189
  async function focusSession(targetId) {
159
190
  const session = projection.resolveTarget(targetId);
160
191
  if (!session) {
161
192
  return { ok: false, error: "Session is no longer present or is ambiguous; focus did nothing." };
162
193
  }
163
- const rawResult = await focusNiriSession(session, execFileAsync, process.env);
194
+ const rawResult = await focusNiriSession(session, execFileAsync, process.env, placement.options);
164
195
  const result = {
165
196
  ok: rawResult.ok === true,
197
+ presented: rawResult.presented === true,
198
+ verified: rawResult.verified === true,
166
199
  ...(typeof rawResult.error === "string" ? { error: rawResult.error } : {}),
167
200
  ...(Number.isInteger(rawResult.windowId) ? { windowId: Number(rawResult.windowId) } : {}),
168
201
  };
@@ -212,6 +245,7 @@ function consumePanelEvents(child, chunk) {
212
245
  }
213
246
  }, 30_000);
214
247
  child._stableTimer.unref?.();
248
+ theme.republish();
215
249
  if (pendingView) {
216
250
  const view = pendingView;
217
251
  pendingView = null;
@@ -220,7 +254,12 @@ function consumePanelEvents(child, chunk) {
220
254
  projection.send();
221
255
  }
222
256
  } else if (event.type === "visibility-applied") {
223
- runtimeStatus.windowVisible = event.visible === true;
257
+ const visible = event.visible === true;
258
+ const changed = runtimeStatus.windowVisible !== visible;
259
+ runtimeStatus.windowVisible = visible;
260
+ // Showing or hiding the surface is the only moment the exclusive zone changes, so it is
261
+ // the only moment a tiled window can be left at the previous working area's height.
262
+ if (changed) void heightRepair.repairAfterZoneChange();
224
263
  } else if (event.type === "expanded") {
225
264
  runtimeStatus.panelExpanded = event.expanded === true;
226
265
  } else if (event.type === "activate") {
@@ -232,7 +271,11 @@ function consumePanelEvents(child, chunk) {
232
271
  cardId,
233
272
  ok: result.ok === true,
234
273
  message: result.ok
235
- ? "Focused Ghostty window."
274
+ ? result.presented
275
+ ? result.verified
276
+ ? "Presented the hidden tab and focused its Ghostty window."
277
+ : "Focused its Ghostty window and asked Ghostty to show the tab."
278
+ : "Focused Ghostty window."
236
279
  : result.error || "Focus failed; nothing moved.",
237
280
  }),
238
281
  );
@@ -310,6 +353,49 @@ const getNiriWindows = () =>
310
353
  const getNiriWorkspaces = () =>
311
354
  readNiriWorkspaces(execFileAsync, process.env, ACTIVITY_STRIP_WORKSPACE_SYNC_MS);
312
355
 
356
+ // Agent tabs are read from the process table, not the broker, so they are refreshed on a calm
357
+ // clock of their own rather than on every workspace event.
358
+ const AGENT_SCAN_INTERVAL_MS = 3000;
359
+ let agentScanAt = 0;
360
+
361
+ function refreshAgentTabs() {
362
+ if (process.env.PI_ACTIVITY_STRIP_AGENT_TABS === "0") return;
363
+ if (Date.now() - agentScanAt < AGENT_SCAN_INTERVAL_MS) return;
364
+ agentScanAt = Date.now();
365
+ try {
366
+ const records = discoverAgentTabs({ env: process.env });
367
+ runtimeStatus.agentTabCount = records.length;
368
+ runtimeStatus.agentScanError = null;
369
+ projection.setAgentSessions(records);
370
+ // A session that dies without firing its end hook leaves a record behind; retire the ones no
371
+ // live tab claims so they cannot accumulate.
372
+ pruneClaudeEventRecords(
373
+ records
374
+ .filter((record) => record.agentKind === "claude" && record.agentSessionKey)
375
+ .map((record) => String(record.agentSessionKey)),
376
+ );
377
+ } catch (error) {
378
+ // Recorded on its own field: replacing the shared warning list would erase a panel error, and
379
+ // a transient scan failure must not leave a permanent warning behind.
380
+ runtimeStatus.agentScanError = error instanceof Error ? error.message : String(error);
381
+ }
382
+ }
383
+
384
+ // The ribbon reads Ghostty's own theme, so it follows the terminal into light and dark.
385
+ const theme = createThemeRuntime({
386
+ execFileAsync,
387
+ env: process.env,
388
+ runtimeStatus,
389
+ publish: (definitions) => writePanel({ protocol: 1, type: "theme", definitions }),
390
+ });
391
+
392
+ const heightRepair = createHeightRepair({
393
+ execFileAsync,
394
+ env: process.env,
395
+ runtimeStatus,
396
+ readWindows: getNiriWindows,
397
+ });
398
+
313
399
  const reconcileRunner = createLatestOnlyRunner(async ({ isCurrent }) => {
314
400
  if (!isNiriSession()) {
315
401
  projection.send();
@@ -317,18 +403,25 @@ const reconcileRunner = createLatestOnlyRunner(async ({ isCurrent }) => {
317
403
  }
318
404
  const [windows, workspaces] = await Promise.all([getNiriWindows(), getNiriWorkspaces()]);
319
405
  if (!isCurrent()) return;
320
- const view = resolveFocusedWorkspaceView(windows, workspaces, projection.getRawSessions(), {
321
- env: process.env,
322
- });
406
+ placement.observeWindowList(windows);
407
+ heightRepair.observe(windows);
408
+ refreshAgentTabs();
409
+ runtimeStatus.surfaceBindingCount = placement.bindingCount;
410
+ const sessions = projection.getRawSessions();
411
+ runtimeStatus.unplacedSurfaceCount = placement.countUnplaced(windows, sessions);
412
+ const view = resolveFocusedWorkspaceView(windows, workspaces, sessions, placement.options);
413
+ if (view) placement.learnFromPlacements(view.sessions, windows);
323
414
  projection.publishWorkspaceView(
324
415
  view ?? { workspace: null, sessions: [], focusedSessionId: null, focusedCardId: null },
325
416
  );
417
+ void placement.scheduleInventory(windows);
326
418
  });
327
419
 
328
420
  async function shutdown(exitCode = 0) {
329
421
  if (shuttingDown) return;
330
422
  shuttingDown = true;
331
423
  watcher?.stop();
424
+ placement.persist();
332
425
  writePanel({
333
426
  protocol: 1,
334
427
  type: "view",
@@ -356,8 +449,10 @@ async function main() {
356
449
  }),
357
450
  });
358
451
  broker.on("snapshot", (snapshot) => {
452
+ // Compare like with like: the raw set also holds discovered agent tabs, which the broker
453
+ // never publishes, so comparing it to a broker snapshot would always differ and reconcile.
359
454
  const membershipChanged = !haveSameRecordMembership(
360
- projection.getRawSessions(),
455
+ projection.getBrokerSessions(),
361
456
  snapshot.sessions,
362
457
  );
363
458
  projection.updateSnapshot(snapshot);
@@ -373,10 +468,21 @@ async function main() {
373
468
  env: process.env,
374
469
  onFocusedWorkspace: () => reconcileRunner.request(),
375
470
  onFallback: () => reconcileRunner.request(),
471
+ onWindowChanged: (window) => {
472
+ if (placement.observeWindowEvent(window)) reconcileRunner.request();
473
+ },
474
+ onWindowClosed: (windowId) => {
475
+ if (placement.forgetWindow(windowId)) reconcileRunner.request();
476
+ },
477
+ onWindowFocusChanged: () => reconcileRunner.request(),
376
478
  fallbackMs: ACTIVITY_STRIP_WORKSPACE_SYNC_MS,
377
479
  });
378
480
  }
379
481
 
482
+ void theme.refresh({ force: true });
483
+ const themeTimer = setInterval(() => void theme.refresh(), THEME_POLL_INTERVAL_MS);
484
+ themeTimer.unref?.();
485
+
380
486
  process.once("SIGINT", () => void shutdown(0));
381
487
  process.once("SIGTERM", () => void shutdown(0));
382
488
  }
@@ -4,18 +4,23 @@
4
4
  // - "changing native panel card membership, focus, or transport revisions"
5
5
  // ---
6
6
 
7
+ import { joinAkTaskChips } from "../common/ak-tasks.mjs";
7
8
  import { resolveSnapshotSession } from "../common/niri-focus.mjs";
8
9
  import { projectSessionCards, sessionRecordKey } from "../common/session-cards.mjs";
9
10
 
10
11
  /** @typedef {Record<string, unknown>} SessionRecord */
11
12
  /** @typedef {{generatedAt: number; sessions: SessionRecord[]}} Snapshot */
12
13
  /** @typedef {{workspace: Record<string, unknown> | null; sessions: SessionRecord[]; focusedSessionId: string | null; focusedCardId?: string | null}} WorkspaceView */
14
+ /** @typedef {{placement: string; surfaceVisible: boolean; windowId: number | null}} CardPlacement */
13
15
  /** @typedef {{isNiriSession: () => boolean; publish: (view: Record<string, unknown> & {type: string; sessions: SessionRecord[]}) => void}} NativePanelProjectionOptions */
14
16
 
15
17
  /** @param {NativePanelProjectionOptions} options */
16
18
  export function createNativePanelProjection({ isNiriSession, publish }) {
17
19
  /** @type {Snapshot} */
18
20
  let snapshot = { generatedAt: Date.now(), sessions: [] };
21
+ /** Agent tabs discovered from the process table; they publish no telemetry of their own. */
22
+ /** @type {SessionRecord[]} */
23
+ let agentSessions = [];
19
24
  /** @type {string | null} */
20
25
  let focusedSessionId = null;
21
26
  /** @type {string | null} */
@@ -26,13 +31,44 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
26
31
  let workspaceCardIds = new Set();
27
32
  /** @type {Set<string>} */
28
33
  let workspaceRecordKeys = new Set();
34
+ /** @type {Map<string, CardPlacement>} */
35
+ let workspacePlacements = new Map();
36
+ /** Read-only AK task claims joined onto cards; absent until the first successful AK read. */
37
+ /** @type {{claims: import("../common/contracts.ts").AkTaskClaim[]; deferred: import("../common/contracts.ts").AkTaskDeferred[]} | null} */
38
+ let akTasks = null;
29
39
  let revision = 0;
30
40
 
41
+ function allSessions() {
42
+ return agentSessions.length > 0 ? [...snapshot.sessions, ...agentSessions] : snapshot.sessions;
43
+ }
44
+
31
45
  function getDisplaySessions() {
32
- const sessions = isNiriSession()
33
- ? snapshot.sessions.filter((session) => workspaceRecordKeys.has(sessionRecordKey(session)))
34
- : snapshot.sessions;
35
- return projectSessionCards(sessions, isNiriSession() ? workspaceCardIds : null);
46
+ const rawSessions = allSessions();
47
+ let cards;
48
+ if (!isNiriSession()) {
49
+ cards = projectSessionCards(rawSessions, null);
50
+ } else {
51
+ const sessions = rawSessions.filter((session) =>
52
+ workspaceRecordKeys.has(sessionRecordKey(session)),
53
+ );
54
+ cards = projectSessionCards(sessions, workspaceCardIds).map((card) => {
55
+ const placement = workspacePlacements.get(card.cardId);
56
+ return placement ? { ...card, ...placement } : card;
57
+ });
58
+ }
59
+ if (!akTasks) return cards;
60
+ // Dead-session detection must consider every live session, including cards projected onto
61
+ // other workspaces, so a claim is only orphaned when no live session anywhere holds it.
62
+ const liveSessionIds = new Set(
63
+ rawSessions.map((session) => String(session.sessionId ?? "")).filter(Boolean),
64
+ );
65
+ return joinAkTaskChips({
66
+ cards,
67
+ claims: akTasks.claims,
68
+ deferred: akTasks.deferred,
69
+ liveSessionIds,
70
+ nowMs: Date.now(),
71
+ }).cards;
36
72
  }
37
73
 
38
74
  function currentView() {
@@ -64,7 +100,23 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
64
100
 
65
101
  return {
66
102
  getDisplaySessions,
67
- getRawSessions: () => snapshot.sessions,
103
+ getRawSessions: allSessions,
104
+ /** Broker-published records only, for comparisons against a broker snapshot. */
105
+ getBrokerSessions: () => snapshot.sessions,
106
+ /** @param {SessionRecord[]} records */
107
+ setAgentSessions(records) {
108
+ agentSessions = Array.isArray(records) ? records : [];
109
+ },
110
+ /**
111
+ * Replace the read-only AK task data joined onto cards. An empty refresh clears every chip;
112
+ * `null` data (AK never read) leaves cards untouched, which is how the strip stays unchanged
113
+ * when AK is absent.
114
+ * @param {{claims: import("../common/contracts.ts").AkTaskClaim[]; deferred: import("../common/contracts.ts").AkTaskDeferred[]} | null} state
115
+ */
116
+ setAkTasks(state) {
117
+ akTasks = state;
118
+ send();
119
+ },
68
120
  /** @param {string} targetId */
69
121
  resolveTarget: (targetId) => resolveSnapshotSession(getDisplaySessions(), targetId),
70
122
  /** @param {WorkspaceView} view */
@@ -78,6 +130,16 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
78
130
  : [sessionRecordKey(session)],
79
131
  ),
80
132
  );
133
+ workspacePlacements = new Map(
134
+ view.sessions.map((session) => [
135
+ String(session.cardId ?? ""),
136
+ {
137
+ placement: String(session.placement ?? "title"),
138
+ surfaceVisible: session.surfaceVisible !== false,
139
+ windowId: Number.isInteger(session.windowId) ? Number(session.windowId) : null,
140
+ },
141
+ ]),
142
+ );
81
143
  focusedSessionId = view.focusedSessionId;
82
144
  focusedCardId = view.focusedCardId ?? null;
83
145
  send();