@tryinget/pi-activity-strip 0.5.0 → 0.7.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 (46) hide show
  1. package/README.md +59 -7
  2. package/bin/pi-activity-strip-claude-hook.mjs +71 -0
  3. package/bin/pi-activity-strip.mjs +98 -64
  4. package/extensions/activity-strip.js +25 -21
  5. package/native/bin/linux-x64-gnu/artifact.json +2 -2
  6. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  7. package/native/panel/src/app.rs +20 -20
  8. package/native/panel/src/card_view.rs +396 -9
  9. package/native/panel/src/main.rs +64 -1
  10. package/native/panel/src/protocol.rs +142 -0
  11. package/native/panel/src/runtime.rs +50 -9
  12. package/native/panel/src/style.css +147 -60
  13. package/package.json +4 -4
  14. package/src/client/runtime-lock.mjs +140 -0
  15. package/src/common/agent-identity.mjs +163 -0
  16. package/src/common/ak-tasks.mjs +354 -0
  17. package/src/common/claude-events.mjs +190 -0
  18. package/src/common/claude-hook-config.mjs +56 -0
  19. package/src/common/claude-transcript.mjs +163 -0
  20. package/src/common/codex-transcript.mjs +153 -0
  21. package/src/common/compatibility.mjs +13 -0
  22. package/src/common/constants.mjs +2 -0
  23. package/src/common/contracts.d.ts +35 -0
  24. package/src/common/contracts.ts +67 -1
  25. package/src/common/ghostty-present.mjs +100 -0
  26. package/src/common/ghostty-theme.mjs +149 -0
  27. package/src/common/niri-focus.mjs +179 -111
  28. package/src/common/session-cards.mjs +5 -2
  29. package/src/common/status-report.mjs +51 -0
  30. package/src/common/surface-bindings.mjs +234 -0
  31. package/src/common/terminal-identity.mjs +75 -12
  32. package/src/common/window-placement.mjs +59 -0
  33. package/src/common/workspace-view.mjs +161 -0
  34. package/src/native/agent-discovery.mjs +294 -0
  35. package/src/native/ak-runtime.mjs +164 -0
  36. package/src/native/codex-discovery.mjs +128 -0
  37. package/src/native/ghostty-tab-inventory.py +99 -0
  38. package/src/native/height-repair.mjs +154 -0
  39. package/src/native/main.mjs +122 -64
  40. package/src/native/panel-binary.mjs +56 -0
  41. package/src/native/panel-projection.mjs +70 -5
  42. package/src/native/placement.mjs +225 -0
  43. package/src/native/tab-inventory.mjs +188 -0
  44. package/src/native/theme-runtime.mjs +72 -0
  45. package/src/native/theme-source.mjs +197 -0
  46. package/src/native/workspace-events.mjs +22 -1
@@ -0,0 +1,140 @@
1
+ // ---
2
+ // summary: "waits on the native runtime flock so stop returns only after the controller exits, and open can name a held lock"
3
+ // read_when:
4
+ // - "changing how stop waits for shutdown or how open reports a runtime that failed to start"
5
+ // ---
6
+
7
+ import { execFile } from "node:child_process";
8
+ import path from "node:path";
9
+ import { setTimeout as delay } from "node:timers/promises";
10
+ import { promisify } from "node:util";
11
+ import {
12
+ ACTIVITY_STRIP_SOCKET_DIR,
13
+ ACTIVITY_STRIP_START_TIMEOUT_MS,
14
+ ACTIVITY_STRIP_STOP_TIMEOUT_MS,
15
+ } from "../common/constants.mjs";
16
+ import { requestBrokerShutdown } from "./broker-client.mjs";
17
+
18
+ const execFileAsync = promisify(execFile);
19
+
20
+ /**
21
+ * The exit status `flock` uses for a held lock. It differs from every status the controller or
22
+ * `flock` itself can return, so a held lock is never confused with a failure to check it.
23
+ */
24
+ export const RUNTIME_LOCK_CONFLICT_EXIT_CODE = 75;
25
+
26
+ /** The controller holds this lock for its whole lifetime. */
27
+ export const RUNTIME_LOCK_PATH = path.join(ACTIVITY_STRIP_SOCKET_DIR, "runtime.lock");
28
+
29
+ /** @typedef {import("../common/contracts.ts").BrokerResponse} BrokerResponse */
30
+ /** @typedef {{exitCode: number | null | undefined}} ControllerHandle */
31
+
32
+ /**
33
+ * Take and release the lock once, reporting whether it was held.
34
+ * @param {string} lockPath
35
+ * @param {string[]} mode
36
+ * @returns {Promise<"free" | "held" | {error: string}>}
37
+ */
38
+ async function tryRuntimeLock(lockPath, mode) {
39
+ const conflict = String(RUNTIME_LOCK_CONFLICT_EXIT_CODE);
40
+ try {
41
+ await execFileAsync("flock", [...mode, "--conflict-exit-code", conflict, lockPath, "true"]);
42
+ return "free";
43
+ } catch (error) {
44
+ const failure = /** @type {{code?: unknown; message?: string}} */ (error ?? {});
45
+ if (failure.code === RUNTIME_LOCK_CONFLICT_EXIT_CODE) return "held";
46
+ return {
47
+ error: `Could not check the activity-strip runtime lock: ${failure.message ?? String(error)}`,
48
+ };
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Wait until the controller holding the runtime lock has exited. The controller holds the lock for
54
+ * its whole lifetime and closes its broker before it lets go, so a closed broker alone does not
55
+ * prove it is gone. `waited` says whether a runtime was still there.
56
+ * @param {string} lockPath
57
+ * @param {{timeoutMs: number}} options
58
+ * @returns {Promise<{released: boolean; waited?: boolean; error?: string}>}
59
+ */
60
+ export async function waitForRuntimeExit(lockPath, { timeoutMs }) {
61
+ const now = await tryRuntimeLock(lockPath, ["--nonblock"]);
62
+ if (now === "free") return { released: true, waited: false };
63
+ if (now !== "held") return { released: false, error: now.error };
64
+ const later = await tryRuntimeLock(lockPath, ["--wait", String(timeoutMs / 1000)]);
65
+ if (later === "free") return { released: true, waited: true };
66
+ if (later === "held") return { released: false, waited: true };
67
+ return { released: false, error: later.error };
68
+ }
69
+
70
+ /**
71
+ * Stop the runtime and return once it has exited. Every stop path, the CLI and Pi's commands, goes
72
+ * through here, so a following open can never race a runtime that is still shutting down. The lock
73
+ * is waited on even when no broker answers: a runtime whose broker has closed still holds it.
74
+ * @param {{
75
+ * requestShutdown?: () => Promise<{ok?: boolean} | null | undefined>;
76
+ * waitForExit?: typeof waitForRuntimeExit;
77
+ * lockPath?: string;
78
+ * timeoutMs?: number;
79
+ * }} [options]
80
+ * @returns {Promise<{outcome: "stopped" | "not-running" | "still-stopping"} | {outcome: "error"; error: string}>}
81
+ */
82
+ export async function stopRuntime({
83
+ requestShutdown = requestBrokerShutdown,
84
+ waitForExit = waitForRuntimeExit,
85
+ lockPath = RUNTIME_LOCK_PATH,
86
+ timeoutMs = ACTIVITY_STRIP_STOP_TIMEOUT_MS,
87
+ } = {}) {
88
+ let accepted = false;
89
+ try {
90
+ accepted = (await requestShutdown())?.ok === true;
91
+ } catch {
92
+ accepted = false;
93
+ }
94
+ const exit = await waitForExit(lockPath, { timeoutMs });
95
+ if (exit.error) return { outcome: "error", error: exit.error };
96
+ if (!exit.released) return { outcome: "still-stopping" };
97
+ return { outcome: accepted || exit.waited ? "stopped" : "not-running" };
98
+ }
99
+
100
+ /**
101
+ * Wait for the controller `open` just spawned under the runtime lock to become ready. A spawn that
102
+ * finds the lock held exits with the conflict status. The wait still continues, because the holder
103
+ * may be a controller another `open` is starting; the held lock is reported only if nothing
104
+ * becomes ready in time.
105
+ * @param {{
106
+ * controller: ControllerHandle;
107
+ * getStatus: () => Promise<BrokerResponse | null>;
108
+ * timeoutMs?: number;
109
+ * retryMs?: number;
110
+ * now?: () => number;
111
+ * sleep?: (ms: number) => Promise<unknown>;
112
+ * }} options
113
+ * @returns {Promise<{ok: true; started: boolean} | {ok: false; reason: "error" | "exited" | "lock-held" | "timeout"; status: BrokerResponse | null; exitCode?: number | null}>}
114
+ */
115
+ export async function waitForStartedRuntime({
116
+ controller,
117
+ getStatus,
118
+ timeoutMs = ACTIVITY_STRIP_START_TIMEOUT_MS,
119
+ retryMs = 125,
120
+ now = Date.now,
121
+ sleep = delay,
122
+ }) {
123
+ const deadline = now() + timeoutMs;
124
+ while (true) {
125
+ const status = await getStatus();
126
+ if (status?.ok && (!status.runtimeStatus || status.runtimeStatus.state === "ready")) {
127
+ return { ok: true, started: controller.exitCode === undefined };
128
+ }
129
+ if (status?.runtimeStatus?.state === "error") return { ok: false, reason: "error", status };
130
+ const exitCode = controller.exitCode;
131
+ if (exitCode !== undefined && exitCode !== RUNTIME_LOCK_CONFLICT_EXIT_CODE) {
132
+ return { ok: false, reason: "exited", status, exitCode };
133
+ }
134
+ if (now() >= deadline) {
135
+ const reason = exitCode === RUNTIME_LOCK_CONFLICT_EXIT_CODE ? "lock-held" : "timeout";
136
+ return { ok: false, reason, status };
137
+ }
138
+ await sleep(retryMs);
139
+ }
140
+ }
@@ -0,0 +1,163 @@
1
+ // ---
2
+ // summary: "recognizes terminal-based coding agents and shapes them into activity-strip session records"
3
+ // read_when:
4
+ // - "adding an agent CLI, changing agent card fields, or changing agent title matching"
5
+ // ---
6
+
7
+ import path from "node:path";
8
+
9
+ /**
10
+ * Terminal-based coding agents that occupy a Ghostty tab the way Pi does. Recognition is by the
11
+ * executable name of the process that owns the terminal, never by a window title, so a tab is
12
+ * admitted on process evidence alone.
13
+ * @type {ReadonlyArray<{kind: string; label: string; commands: readonly string[]}>}
14
+ */
15
+ export const AGENT_CATALOG = Object.freeze([
16
+ { kind: "claude", label: "Claude Code", commands: Object.freeze(["claude"]) },
17
+ { kind: "codex", label: "Codex", commands: Object.freeze(["codex"]) },
18
+ { kind: "gemini", label: "Gemini", commands: Object.freeze(["gemini"]) },
19
+ { kind: "amp", label: "Amp", commands: Object.freeze(["amp"]) },
20
+ { kind: "aider", label: "Aider", commands: Object.freeze(["aider"]) },
21
+ { kind: "opencode", label: "opencode", commands: Object.freeze(["opencode"]) },
22
+ { kind: "crush", label: "Crush", commands: Object.freeze(["crush"]) },
23
+ { kind: "goose", label: "Goose", commands: Object.freeze(["goose"]) },
24
+ { kind: "cursor", label: "Cursor Agent", commands: Object.freeze(["cursor-agent"]) },
25
+ { kind: "zcode", label: "zcode", commands: Object.freeze(["zcode"]) },
26
+ ]);
27
+
28
+ /** Pi publishes its own telemetry, so its processes are never discovered this way. */
29
+ const EXCLUDED_COMMANDS = new Set(["pi", "node", "bash", "sh", "zsh", "fish", "python3"]);
30
+ /**
31
+ * Executables that only ever host another program. When one of these is the process name, the
32
+ * command line names the real agent, so it is worth the extra read to look.
33
+ */
34
+ const RUNTIME_COMMANDS = [/^node/i, /^python/i, /^(bash|sh|zsh|fish|dash|env)$/i, /^(deno|bun)$/i];
35
+
36
+ /** @param {unknown} command */
37
+ export function isRuntimeCommand(command) {
38
+ const name = path.basename(String(command ?? "").trim());
39
+ return name.length > 0 && RUNTIME_COMMANDS.some((pattern) => pattern.test(name));
40
+ }
41
+ export const AGENT_ACTIVE_WINDOW_MS = 45_000;
42
+
43
+ /** @param {NodeJS.ProcessEnv} [env] */
44
+ export function agentCatalogFor(env = process.env) {
45
+ const disabled = new Set(
46
+ String(env.PI_ACTIVITY_STRIP_AGENT_KINDS_DISABLED ?? "")
47
+ .split(",")
48
+ .map((value) => value.trim().toLowerCase())
49
+ .filter(Boolean),
50
+ );
51
+ return AGENT_CATALOG.filter((agent) => !disabled.has(agent.kind));
52
+ }
53
+
54
+ /**
55
+ * Classify one process by the executable it runs. `command` is the kernel's comm value, which the
56
+ * kernel truncates to 15 characters and which names the runtime rather than the agent when the
57
+ * agent is launched through one; the command line entries name the agent in that case.
58
+ * @param {{command?: unknown; argv0?: unknown; argv1?: unknown; env?: NodeJS.ProcessEnv}} process
59
+ */
60
+ export function classifyAgentProcess({ command, argv0, argv1, env = process.env } = {}) {
61
+ const names = [command, argv0, argv1]
62
+ .map((value) => path.basename(String(value ?? "").trim()).toLowerCase())
63
+ .filter(Boolean);
64
+ if (names.length === 0 || names.every((name) => EXCLUDED_COMMANDS.has(name))) return null;
65
+ for (const agent of agentCatalogFor(env)) {
66
+ if (agent.commands.some((candidate) => names.includes(candidate))) return agent;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /** @param {unknown} cwd */
72
+ export function repoLabelFor(cwd) {
73
+ const normalized = String(cwd ?? "").trim();
74
+ if (!normalized || normalized === "/") return "";
75
+ return path.basename(normalized);
76
+ }
77
+
78
+ /**
79
+ * Shape one discovered agent tab into the record the card projection consumes. The record carries a
80
+ * terminal identity, so it groups and places exactly like a Pi terminal card, but its session id is
81
+ * namespaced by agent kind and can never be mistaken for a Pi session id.
82
+ * @param {{
83
+ * kind: string;
84
+ * label: string;
85
+ * processId: number;
86
+ * cwd: string;
87
+ * terminalKey: string;
88
+ * terminalFamily: string;
89
+ * terminalSurfaceId: string;
90
+ * sessionKey?: string;
91
+ * titleSuffix?: string;
92
+ * startedAt: number;
93
+ * lastEventAt?: number;
94
+ * now?: number;
95
+ * telemetry?: Partial<import("./claude-transcript.mjs").ClaudeTelemetry> | null;
96
+ * }} tab
97
+ */
98
+ export function agentSessionRecord({
99
+ kind,
100
+ label,
101
+ processId,
102
+ cwd,
103
+ terminalKey,
104
+ terminalFamily,
105
+ terminalSurfaceId,
106
+ sessionKey = "",
107
+ titleSuffix = "",
108
+ startedAt,
109
+ lastEventAt = 0,
110
+ now = Date.now(),
111
+ telemetry = null,
112
+ }) {
113
+ // Process start times and file modification times are fractional milliseconds, while the panel
114
+ // protocol carries whole-millisecond integers, so every timestamp is rounded at the boundary.
115
+ const startedAtMs = Math.round(startedAt) || 0;
116
+ const lastEventMs = Math.round(lastEventAt) || 0;
117
+ const nowMs = Math.round(now) || 0;
118
+ const activity = lastEventMs > 0 ? lastEventMs : startedAtMs;
119
+ // Reported activity decides liveness. A published state only counts while the transcript is
120
+ // still moving; a stale "tool" record is not evidence that a tool is running right now.
121
+ const recent = lastEventMs > 0 && nowMs - lastEventMs <= AGENT_ACTIVE_WINDOW_MS;
122
+ const reported = String(telemetry?.state ?? "");
123
+ // "waiting" and "error" describe a session that is stopped on purpose, so they survive the
124
+ // liveness window: a permission prompt can sit unanswered for a long time and is still true.
125
+ const sticky = reported === "waiting" || reported === "error";
126
+ const busy = reported === "tool" || reported === "thinking";
127
+ const active = sticky || (recent && (busy || !reported));
128
+ const state = sticky ? reported : active ? (busy ? reported : "thinking") : "idle";
129
+ const toolName = active ? String(telemetry?.toolName ?? "") : "";
130
+ const toolTarget = active ? String(telemetry?.toolTarget ?? "") : "";
131
+ const detail = [toolTarget, String(telemetry?.assistantPreview ?? ""), cwd].find(Boolean) ?? "";
132
+ return {
133
+ sessionId: `agent:${kind}:${sessionKey || processId}`,
134
+ publisherId: `agent-scan:${processId}`,
135
+ publisherSequence: 0,
136
+ processId,
137
+ agentKind: kind,
138
+ agentSessionKey: sessionKey,
139
+ terminalKind: "ghostty-surface",
140
+ terminalKey,
141
+ terminalFamily,
142
+ terminalSurfaceId,
143
+ titleSuffix,
144
+ cwd,
145
+ repoLabel: repoLabelFor(cwd),
146
+ sessionName: "",
147
+ agentLabel: label,
148
+ phase: String(telemetry?.title ?? "").trim() || label,
149
+ detail,
150
+ assistantPreview: String(telemetry?.assistantPreview ?? ""),
151
+ toolName,
152
+ toolTarget,
153
+ state,
154
+ turnIndex: Number(telemetry?.turnIndex ?? 0) || 0,
155
+ updatedAt: nowMs,
156
+ lastEventAt: activity,
157
+ startedAt: startedAtMs,
158
+ agentStartedAt: active ? activity : null,
159
+ agentActive: active,
160
+ lastPromptPreview: String(telemetry?.lastPrompt ?? ""),
161
+ errorMessage: "",
162
+ };
163
+ }
@@ -0,0 +1,354 @@
1
+ // ---
2
+ // summary: "parses read-only AK task output and joins live session-bound claims onto terminal cards"
3
+ // read_when:
4
+ // - "changing AK claim parsing, lease/vacant-custody rules, or task chip join semantics"
5
+ // ---
6
+
7
+ /**
8
+ * AK is a read-only projection for the strip: these helpers only parse CLI output and derive
9
+ * display facts. They never write, never touch the society database directly, and fail closed
10
+ * (malformed input yields no chips rather than invented state).
11
+ */
12
+
13
+ /** Chip states rendered by the native panel. */
14
+ export const AK_TASK_CHIP_ACTIVE = "active";
15
+ export const AK_TASK_CHIP_DEFERRED = "deferred";
16
+ export const AK_TASK_CHIP_ORPHANED = "orphaned";
17
+
18
+ /** AK5700 claim semantics: only `session-<uuid>` claims can belong to a live Pi terminal. */
19
+ const SESSION_CLAIM_PATTERN =
20
+ /^session-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
21
+ /** Bound the title carried into the panel protocol. */
22
+ const MAX_TITLE_LENGTH = 96;
23
+ /** A card shows at most two live-claim buttons and two state badges; the rest fold into overflow. */
24
+ const MAX_ACTIVE_CHIPS = 2;
25
+ const MAX_BADGE_CHIPS = 2;
26
+
27
+ /**
28
+ * @param {unknown} value
29
+ * @returns {value is string}
30
+ */
31
+ function isString(value) {
32
+ return typeof value === "string";
33
+ }
34
+
35
+ /**
36
+ * AK timestamps are RFC 3339 with offset. Unparseable or non-positive values are rejected so an
37
+ * unprovable lease can never be presented as live custody.
38
+ * @param {unknown} value
39
+ * @returns {number | null}
40
+ */
41
+ export function parseAkTimestampMs(value) {
42
+ if (!isString(value) || !value.trim()) return null;
43
+ const parsed = Date.parse(value.trim());
44
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
45
+ }
46
+
47
+ /**
48
+ * @param {unknown} value
49
+ * @returns {number | null} positive safe integer task id, or null
50
+ */
51
+ function parseTaskId(value) {
52
+ const id = Number(value);
53
+ return Number.isSafeInteger(id) && id > 0 ? id : null;
54
+ }
55
+
56
+ /**
57
+ * @param {unknown} value
58
+ * @returns {string} bounded title
59
+ */
60
+ function boundTitle(value) {
61
+ return isString(value) ? value.trim().slice(0, MAX_TITLE_LENGTH) : "";
62
+ }
63
+
64
+ /**
65
+ * Normalize a repo path for prefix matching. Only absolute paths can join; anything else would
66
+ * invent a relationship between a card and a task.
67
+ * @param {unknown} value
68
+ * @returns {string | null}
69
+ */
70
+ function normalizeRepoPath(value) {
71
+ if (!isString(value)) return null;
72
+ const trimmed = value.trim().replace(/\/+$/, "");
73
+ return trimmed.startsWith("/") ? trimmed : null;
74
+ }
75
+
76
+ /**
77
+ * True when a session working directory sits inside the task's registered repo.
78
+ * @param {unknown} cardCwd
79
+ * @param {string} taskRepo
80
+ */
81
+ export function cwdIsInsideRepo(cardCwd, taskRepo) {
82
+ if (!isString(cardCwd)) return false;
83
+ const cwd = cardCwd.trim().replace(/\/+$/, "");
84
+ return cwd.length > 0 && (cwd === taskRepo || cwd.startsWith(`${taskRepo}/`));
85
+ }
86
+
87
+ /**
88
+ * Parse `ak task list --format json --verbose` output into raw claim rows. Rows without a
89
+ * session-bound claim or a parseable lease are skipped; anything malformed at the envelope level
90
+ * fails the whole parse so callers can drop chips entirely.
91
+ * @param {string} raw
92
+ * @returns {{ok: true; claims: import("./contracts.ts").AkTaskClaim[]} | {ok: false; error: string}}
93
+ */
94
+ export function parseAkClaimList(raw) {
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(String(raw ?? ""));
98
+ } catch (error) {
99
+ return {
100
+ ok: false,
101
+ error: `AK task list is not JSON: ${error instanceof Error ? error.message : String(error)}`,
102
+ };
103
+ }
104
+ if (!Array.isArray(parsed)) {
105
+ return { ok: false, error: "AK task list did not return a JSON array." };
106
+ }
107
+ /** @type {import("./contracts.ts").AkTaskClaim[]} */
108
+ const claims = [];
109
+ for (const entry of parsed) {
110
+ if (!entry || typeof entry !== "object") continue;
111
+ const record = /** @type {Record<string, unknown>} */ (entry);
112
+ const id = parseTaskId(record.id);
113
+ const claimedBy = isString(record.claimed_by) ? record.claimed_by.trim() : "";
114
+ const match = SESSION_CLAIM_PATTERN.exec(claimedBy);
115
+ const leaseExpiresAt = parseAkTimestampMs(record.lease_expires_at);
116
+ const claimedAt = parseAkTimestampMs(record.claimed_at);
117
+ const repo = normalizeRepoPath(record.repo);
118
+ const title = boundTitle(record.title);
119
+ if (id === null || !match || leaseExpiresAt === null || !repo || !title) continue;
120
+ if (record.status !== "claimed") continue;
121
+ claims.push({
122
+ id,
123
+ title,
124
+ repo,
125
+ sessionId: match[1].toLowerCase(),
126
+ leaseExpiresAt,
127
+ claimedAt: claimedAt ?? 0,
128
+ });
129
+ }
130
+ return { ok: true, claims };
131
+ }
132
+
133
+ /**
134
+ * Parse `ak task deferred --format json` output. Only rows whose deferral is still active join.
135
+ * @param {string} raw
136
+ * @returns {{ok: true; deferred: import("./contracts.ts").AkTaskDeferred[]} | {ok: false; error: string}}
137
+ */
138
+ export function parseAkDeferredList(raw) {
139
+ let parsed;
140
+ try {
141
+ parsed = JSON.parse(String(raw ?? ""));
142
+ } catch (error) {
143
+ return {
144
+ ok: false,
145
+ error: `AK deferred list is not JSON: ${error instanceof Error ? error.message : String(error)}`,
146
+ };
147
+ }
148
+ if (!Array.isArray(parsed)) {
149
+ return { ok: false, error: "AK deferred list did not return a JSON array." };
150
+ }
151
+ /** @type {import("./contracts.ts").AkTaskDeferred[]} */
152
+ const deferred = [];
153
+ for (const entry of parsed) {
154
+ if (!entry || typeof entry !== "object") continue;
155
+ const record = /** @type {Record<string, unknown>} */ (entry);
156
+ const task = record.task;
157
+ const deferral = record.deferral;
158
+ if (!task || typeof task !== "object" || !deferral || typeof deferral !== "object") continue;
159
+ const taskRecord = /** @type {Record<string, unknown>} */ (task);
160
+ const deferralRecord = /** @type {Record<string, unknown>} */ (deferral);
161
+ const id = parseTaskId(taskRecord.id);
162
+ const repo = normalizeRepoPath(taskRecord.repo);
163
+ const title = boundTitle(taskRecord.title);
164
+ if (id === null || !repo || !title) continue;
165
+ if (deferralRecord.state !== "active") continue;
166
+ deferred.push({ id, title, repo });
167
+ }
168
+ return { ok: true, deferred };
169
+ }
170
+
171
+ /**
172
+ * AK5700: an expired lease is vacant custody, so the claim no longer names an active owner.
173
+ * @param {{leaseExpiresAt: number}} claim
174
+ * @param {number} nowMs
175
+ */
176
+ export function claimIsLive(claim, nowMs) {
177
+ return claim.leaseExpiresAt > nowMs;
178
+ }
179
+
180
+ /**
181
+ * Cards whose session holds a live claim get clickable task references. A claim is ambiguous when
182
+ * the claiming session id backs more than one card (one logical session resumed into two
183
+ * terminals); ambiguous claims bind nothing. A task that also carries an active deferral never
184
+ * becomes a button: the deferred badge state wins.
185
+ * @param {Array<Record<string, unknown>>} cards
186
+ * @param {import("./contracts.ts").AkTaskClaim[]} claims
187
+ * @param {Set<number>} deferredIds
188
+ * @param {number} nowMs
189
+ * @returns {Map<string, import("./contracts.ts").AkTaskClaim[]>} active claims per cardId
190
+ */
191
+ function activeClaimsByCardId(cards, claims, deferredIds, nowMs) {
192
+ /** @type {Map<string, Set<string>>} */
193
+ const sessionIdToCardIds = new Map();
194
+ for (const card of cards) {
195
+ const cardId = String(card.cardId ?? "");
196
+ for (const sessionId of cardSessionIds(card)) {
197
+ const cardIds = sessionIdToCardIds.get(sessionId) ?? new Set();
198
+ cardIds.add(cardId);
199
+ sessionIdToCardIds.set(sessionId, cardIds);
200
+ }
201
+ }
202
+ /** @type {Map<string, import("./contracts.ts").AkTaskClaim[]>} */
203
+ const byCardId = new Map();
204
+ for (const card of cards) {
205
+ const cardId = String(card.cardId ?? "");
206
+ byCardId.set(
207
+ cardId,
208
+ claims
209
+ .filter((claim) => claimIsLive(claim, nowMs))
210
+ .filter((claim) => !deferredIds.has(claim.id))
211
+ .filter((claim) => {
212
+ const cardIds = sessionIdToCardIds.get(claim.sessionId);
213
+ return cardIds?.size === 1 && cardIds.has(cardId);
214
+ })
215
+ .sort(compareClaimsNewestFirst),
216
+ );
217
+ }
218
+ return byCardId;
219
+ }
220
+
221
+ /**
222
+ * @param {Record<string, unknown>} card
223
+ * @returns {string[]}
224
+ */
225
+ function cardSessionIds(card) {
226
+ const ids = Array.isArray(card.publisherSessionIds) ? card.publisherSessionIds : [];
227
+ return [...new Set(ids.map((value) => String(value ?? "").trim()).filter(Boolean))];
228
+ }
229
+
230
+ /** @param {import("./contracts.ts").AkTaskClaim} left @param {import("./contracts.ts").AkTaskClaim} right */
231
+ function compareClaimsNewestFirst(left, right) {
232
+ if (left.claimedAt !== right.claimedAt) return right.claimedAt - left.claimedAt;
233
+ if (left.leaseExpiresAt !== right.leaseExpiresAt)
234
+ return right.leaseExpiresAt - left.leaseExpiresAt;
235
+ return left.id - right.id;
236
+ }
237
+
238
+ /** @param {import("./contracts.ts").AkTaskClaim} left @param {import("./contracts.ts").AkTaskClaim} right */
239
+ function compareOrphansSoonestVacantFirst(left, right) {
240
+ if (left.leaseExpiresAt !== right.leaseExpiresAt)
241
+ return left.leaseExpiresAt - right.leaseExpiresAt;
242
+ return left.id - right.id;
243
+ }
244
+
245
+ /**
246
+ * Join AK task references onto projected cards.
247
+ *
248
+ * - `active` chips: the card's own session holds a live claim (exact session-id join, unique).
249
+ * - `orphaned` badges: a live-lease claim by a session with no live card, joined by repo. Custody
250
+ * has not lapsed yet, but the owner is gone, so the fact is a badge rather than a button.
251
+ * - `deferred` badges: tasks with an active deferral, joined by repo.
252
+ *
253
+ * @param {{
254
+ * cards: Array<Record<string, unknown>>;
255
+ * claims?: import("./contracts.ts").AkTaskClaim[];
256
+ * deferred?: import("./contracts.ts").AkTaskDeferred[];
257
+ * liveSessionIds?: Set<string> | string[];
258
+ * nowMs: number;
259
+ * }} options
260
+ * @returns {{cards: Array<Record<string, unknown>>; orphanedClaimCount: number; renderedCounts: {active: number; orphaned: number; deferred: number}}}
261
+ */
262
+ export function joinAkTaskChips({
263
+ cards,
264
+ claims = [],
265
+ deferred = [],
266
+ liveSessionIds = new Set(),
267
+ nowMs,
268
+ }) {
269
+ const liveIds =
270
+ liveSessionIds instanceof Set ? liveSessionIds : new Set([...liveSessionIds].map(String));
271
+ const deferredIds = new Set(deferred.map((task) => task.id));
272
+ const byCardId = activeClaimsByCardId(cards, claims, deferredIds, nowMs);
273
+
274
+ const liveClaims = claims.filter((claim) => claimIsLive(claim, nowMs));
275
+ const orphanedClaims = liveClaims.filter((claim) => !liveIds.has(claim.sessionId));
276
+ const orphanedIds = new Set(orphanedClaims.map((claim) => claim.id));
277
+
278
+ /** @type {number} */
279
+ let renderedActive = 0;
280
+ /** @type {number} */
281
+ let renderedOrphaned = 0;
282
+ /** @type {number} */
283
+ let renderedDeferred = 0;
284
+
285
+ const joined = cards.map((card) => {
286
+ const cardActive = byCardId.get(String(card.cardId ?? "")) ?? [];
287
+ const active = cardActive.slice(0, MAX_ACTIVE_CHIPS);
288
+ const activeOverflow = Math.max(0, cardActive.length - active.length);
289
+ const activeIds = new Set(cardActive.map((claim) => claim.id));
290
+ const cwd = String(card.cwd ?? "");
291
+
292
+ const orphaned = orphanedClaims
293
+ .filter((claim) => cwdIsInsideRepo(cwd, claim.repo))
294
+ .sort(compareOrphansSoonestVacantFirst);
295
+ const deferredForCard = deferred
296
+ .filter((task) => cwdIsInsideRepo(cwd, task.repo) && !orphanedIds.has(task.id))
297
+ .filter((task) => !activeIds.has(task.id))
298
+ .sort((left, right) => left.id - right.id);
299
+ const badges = [...orphaned, ...deferredForCard].slice(0, MAX_BADGE_CHIPS);
300
+ const badgeOverflow = Math.max(0, orphaned.length + deferredForCard.length - badges.length);
301
+
302
+ const chips = [
303
+ ...active.map((claim) => ({ id: claim.id, title: claim.title, state: AK_TASK_CHIP_ACTIVE })),
304
+ ...badges.map((task) => ({
305
+ id: task.id,
306
+ title: task.title,
307
+ state: orphanedIds.has(task.id) ? AK_TASK_CHIP_ORPHANED : AK_TASK_CHIP_DEFERRED,
308
+ })),
309
+ ];
310
+ renderedActive += active.length;
311
+ renderedOrphaned += badges.filter((task) => orphanedIds.has(task.id)).length;
312
+ renderedDeferred += badges.filter((task) => !orphanedIds.has(task.id)).length;
313
+
314
+ const next = { ...card };
315
+ if (chips.length > 0) next.akTasks = chips;
316
+ if (activeOverflow + badgeOverflow > 0) next.akTaskOverflow = activeOverflow + badgeOverflow;
317
+ return next;
318
+ });
319
+
320
+ return {
321
+ cards: joined,
322
+ orphanedClaimCount: orphanedClaims.length,
323
+ renderedCounts: {
324
+ active: renderedActive,
325
+ orphaned: renderedOrphaned,
326
+ deferred: renderedDeferred,
327
+ },
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Convenience summary for the status surface.
333
+ * @param {{
334
+ * claims?: import("./contracts.ts").AkTaskClaim[];
335
+ * deferred?: import("./contracts.ts").AkTaskDeferred[];
336
+ * liveSessionIds?: Set<string> | string[];
337
+ * nowMs: number;
338
+ * }} options
339
+ */
340
+ export function summarizeAkTasks({
341
+ claims = [],
342
+ deferred = [],
343
+ liveSessionIds = new Set(),
344
+ nowMs,
345
+ }) {
346
+ const liveIds =
347
+ liveSessionIds instanceof Set ? liveSessionIds : new Set([...liveSessionIds].map(String));
348
+ const liveClaims = claims.filter((claim) => claimIsLive(claim, nowMs));
349
+ return {
350
+ liveClaimCount: liveClaims.length,
351
+ orphanedClaimCount: liveClaims.filter((claim) => !liveIds.has(claim.sessionId)).length,
352
+ deferredCount: deferred.length,
353
+ };
354
+ }