@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.
- package/README.md +59 -7
- package/bin/pi-activity-strip-claude-hook.mjs +71 -0
- package/bin/pi-activity-strip.mjs +98 -64
- package/extensions/activity-strip.js +25 -21
- package/native/bin/linux-x64-gnu/artifact.json +2 -2
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/src/app.rs +20 -20
- package/native/panel/src/card_view.rs +396 -9
- package/native/panel/src/main.rs +64 -1
- package/native/panel/src/protocol.rs +142 -0
- package/native/panel/src/runtime.rs +50 -9
- package/native/panel/src/style.css +147 -60
- package/package.json +4 -4
- package/src/client/runtime-lock.mjs +140 -0
- package/src/common/agent-identity.mjs +163 -0
- package/src/common/ak-tasks.mjs +354 -0
- package/src/common/claude-events.mjs +190 -0
- package/src/common/claude-hook-config.mjs +56 -0
- package/src/common/claude-transcript.mjs +163 -0
- package/src/common/codex-transcript.mjs +153 -0
- package/src/common/compatibility.mjs +13 -0
- package/src/common/constants.mjs +2 -0
- package/src/common/contracts.d.ts +35 -0
- package/src/common/contracts.ts +67 -1
- package/src/common/ghostty-present.mjs +100 -0
- package/src/common/ghostty-theme.mjs +149 -0
- package/src/common/niri-focus.mjs +179 -111
- package/src/common/session-cards.mjs +5 -2
- package/src/common/status-report.mjs +51 -0
- package/src/common/surface-bindings.mjs +234 -0
- package/src/common/terminal-identity.mjs +75 -12
- package/src/common/window-placement.mjs +59 -0
- package/src/common/workspace-view.mjs +161 -0
- package/src/native/agent-discovery.mjs +294 -0
- package/src/native/ak-runtime.mjs +164 -0
- package/src/native/codex-discovery.mjs +128 -0
- package/src/native/ghostty-tab-inventory.py +99 -0
- package/src/native/height-repair.mjs +154 -0
- package/src/native/main.mjs +122 -64
- package/src/native/panel-binary.mjs +56 -0
- package/src/native/panel-projection.mjs +70 -5
- package/src/native/placement.mjs +225 -0
- package/src/native/tab-inventory.mjs +188 -0
- package/src/native/theme-runtime.mjs +72 -0
- package/src/native/theme-source.mjs +197 -0
- 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
|
+
}
|