@tryinget/pi-activity-strip 0.9.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 (57) hide show
  1. package/README.md +59 -22
  2. package/bin/pi-activity-strip.mjs +32 -4
  3. package/native/bin/linux-x64-gnu/artifact.json +3 -2
  4. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  5. package/native/panel/src/app.rs +572 -266
  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 +79 -17
  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 +4 -0
  17. package/native/panel/src/protocol.rs +22 -8
  18. package/native/panel/src/runtime.rs +43 -2
  19. package/package.json +5 -5
  20. package/src/broker/server.mjs +31 -14
  21. package/src/client/launcher.mjs +3 -7
  22. package/src/client/runtime-lock.mjs +2 -1
  23. package/src/common/agent-identity.mjs +6 -1
  24. package/src/common/contracts.d.ts +1 -0
  25. package/src/common/contracts.ts +38 -0
  26. package/src/common/keyboard-observation.mjs +35 -0
  27. package/src/common/overlay-readiness.mjs +11 -0
  28. package/src/common/presence-apps.mjs +57 -0
  29. package/src/common/recovery-identity.mjs +85 -0
  30. package/src/common/recovery-manifest.mjs +417 -0
  31. package/src/common/recovery-types.ts +75 -0
  32. package/src/common/ribbon-status.mjs +41 -0
  33. package/src/common/status-report.mjs +37 -2
  34. package/src/common/workspace-view.mjs +54 -0
  35. package/src/native/ak-runtime.mjs +50 -9
  36. package/src/native/focus-visibility.mjs +67 -0
  37. package/src/native/ghostty-tab-inventory.py +40 -23
  38. package/src/native/hover-keyboard.mjs +32 -0
  39. package/src/native/interaction-context.mjs +45 -0
  40. package/src/native/keyboard-entry.mjs +32 -0
  41. package/src/native/main.mjs +77 -71
  42. package/src/native/panel-binary.mjs +25 -8
  43. package/src/native/panel-neutral-source.mjs +53 -0
  44. package/src/native/panel-presentation.mjs +137 -0
  45. package/src/native/panel-projection.mjs +338 -24
  46. package/src/native/panel-protocol.mjs +23 -0
  47. package/src/native/panel-window-jump.mjs +90 -0
  48. package/src/native/placement.mjs +83 -20
  49. package/src/native/recovery-inventory.mjs +209 -0
  50. package/src/native/recovery-proc.mjs +178 -0
  51. package/src/native/recovery-sources.mjs +358 -0
  52. package/src/native/runtime-diagnostics.mjs +242 -0
  53. package/src/native/strip-hotkey-kdl.mjs +252 -0
  54. package/src/native/strip-hotkey.mjs +62 -0
  55. package/src/native/tab-inventory.mjs +62 -11
  56. package/src/native/window-jump.mjs +43 -0
  57. package/src/native/workspace-events.mjs +123 -2
@@ -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. */
@@ -169,6 +169,7 @@ export interface SessionStartContextLike {
169
169
  }
170
170
 
171
171
  export interface BeforeAgentStartEventLike {
172
+ messageType?: "user" | "custom";
172
173
  prompt?: string;
173
174
  }
174
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,12 +125,38 @@ 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;
@@ -199,6 +236,7 @@ export interface SessionStartContextLike {
199
236
  }
200
237
 
201
238
  export interface BeforeAgentStartEventLike {
239
+ messageType?: "user" | "custom";
202
240
  prompt?: string;
203
241
  }
204
242
 
@@ -0,0 +1,35 @@
1
+ /** Optional private-v2 observation. Never authorization, query text or a wall-clock countdown.
2
+ * @param {unknown} input */
3
+ export function normalizeKeyboardObservation(input) {
4
+ if (!input || typeof input !== "object") return null;
5
+ const event = /** @type {Record<string, unknown>} */ (input);
6
+ const { protocol, type, request, origin, reason, nativeMs, deadlineMs, captured } = event;
7
+ const safe = (/** @type {unknown} */ value) => Number.isSafeInteger(value) && Number(value) >= 0;
8
+ if (
9
+ protocol !== 2 ||
10
+ type !== "keyboard-observation" ||
11
+ !safe(request) ||
12
+ Number(request) === 0 ||
13
+ !safe(nativeMs) ||
14
+ captured !== true ||
15
+ typeof reason !== "string" ||
16
+ !["acquired", "activity", "idle-expired", "source-revoked"].includes(reason)
17
+ )
18
+ return null;
19
+ if (origin === "hover") {
20
+ if (
21
+ !safe(deadlineMs) ||
22
+ (reason === "idle-expired" && Number(nativeMs) < Number(deadlineMs)) ||
23
+ (["acquired", "activity"].includes(reason) && Number(nativeMs) >= Number(deadlineMs))
24
+ )
25
+ return null;
26
+ } else if (origin !== "explicit" || deadlineMs !== null || reason === "idle-expired") return null;
27
+ return {
28
+ request: Number(request),
29
+ origin: /** @type {"hover" | "explicit"} */ (origin),
30
+ reason: /** @type {"acquired" | "activity" | "idle-expired" | "source-revoked"} */ (reason),
31
+ nativeMs: Number(nativeMs),
32
+ deadlineMs: deadlineMs === null ? null : Number(deadlineMs),
33
+ captured: true,
34
+ };
35
+ }
@@ -0,0 +1,11 @@
1
+ // ---
2
+ // summary: "distinguishes broker liveness from an explicitly ready native overlay"
3
+ // read_when:
4
+ // - "changing startup reuse, waiter success, or CLI status exit semantics"
5
+ // ---
6
+
7
+ /** A pong alone is not overlay readiness or artifact approval.
8
+ * @param {import("./contracts.ts").BrokerResponse | null | undefined} result */
9
+ export function isOverlayReady(result) {
10
+ return result?.ok === true && result.runtimeStatus?.state === "ready";
11
+ }
@@ -0,0 +1,57 @@
1
+ // ---
2
+ // summary: "decides which focused non-agent applications keep the activity strip present"
3
+ // read_when:
4
+ // - "changing which applications keep the strip visible, or the presence env override"
5
+ // ---
6
+
7
+ /**
8
+ * Applications the operator works in beside the agents: the terminal family that hosts agent tabs
9
+ * (and plain shells, nodecode, angelx, Cursor's `agent`), and the password manager reached for in
10
+ * the middle of agent work. Focusing one of them keeps the strip on screen. Presence is a
11
+ * visibility policy only; it never claims that the window owns a card.
12
+ */
13
+ export const DEFAULT_PRESENCE_APP_IDS = Object.freeze([
14
+ "com.mitchellh.ghostty",
15
+ "com.tryinget.ghosttysidequest",
16
+ "com.onepassword.OnePassword",
17
+ "1Password",
18
+ ]);
19
+
20
+ /** @type {Map<string, {all: boolean; ids: Set<string>}>} */
21
+ const parsed = new Map();
22
+
23
+ /**
24
+ * `PI_ACTIVITY_STRIP_PRESENCE_APP_IDS` is a comma list applied to the defaults: `id` adds an app,
25
+ * `-id` removes one, `none` drops every default, `*` keeps the strip present over every app.
26
+ * App ids compare case-insensitively.
27
+ * @param {NodeJS.ProcessEnv} [env]
28
+ */
29
+ export function presenceAppsFor(env = process.env) {
30
+ const raw = String(env?.PI_ACTIVITY_STRIP_PRESENCE_APP_IDS ?? "");
31
+ const cached = parsed.get(raw);
32
+ if (cached) return cached;
33
+ const ids = new Set(DEFAULT_PRESENCE_APP_IDS.map((id) => id.toLowerCase()));
34
+ let all = false;
35
+ for (const entry of raw.split(",").map((value) => value.trim().toLowerCase())) {
36
+ if (!entry) continue;
37
+ if (entry === "*") all = true;
38
+ else if (entry === "none") {
39
+ ids.clear();
40
+ all = false;
41
+ } else if (entry.startsWith("-")) ids.delete(entry.slice(1).trim());
42
+ else ids.add(entry);
43
+ }
44
+ const result = { all, ids };
45
+ parsed.set(raw, result);
46
+ return result;
47
+ }
48
+
49
+ /** @param {unknown} appId @param {NodeJS.ProcessEnv} [env] */
50
+ export function isPresenceApp(appId, env = process.env) {
51
+ const id = String(appId ?? "")
52
+ .trim()
53
+ .toLowerCase();
54
+ if (!id) return false;
55
+ const apps = presenceAppsFor(env);
56
+ return apps.all || apps.ids.has(id);
57
+ }
@@ -0,0 +1,85 @@
1
+ // ---
2
+ // summary: "Exact harness-qualified claimant identities for read-only recovery projections."
3
+ // read_when: ["changing recovery claimant joins"]
4
+ // ---
5
+
6
+ export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
+
8
+ /** One base-36 field of a Nodecode id: uppercase with no leading zero, so one spelling each.
9
+ * @param {string} field @param {number} least @param {number} most */
10
+ function base36Field(field, least, most) {
11
+ return field.length >= least && field.length <= most && /^(?:0|[1-9A-Z][0-9A-Z]*)$/.test(field);
12
+ }
13
+
14
+ /** A decimal field of a Nodecode id, of at least `least`, in at most six digits.
15
+ * @param {string} field @param {number} least */
16
+ function decimalField(field, least) {
17
+ return /^[1-9][0-9]{0,5}$/.test(field) && Number(field) >= least;
18
+ }
19
+
20
+ /** @param {string} time @param {string} random */
21
+ function stamp(time, random) {
22
+ return base36Field(time, 7, 8) && base36Field(random, 1, 10);
23
+ }
24
+
25
+ /** AK owns this grammar: valid_nodecode_session_id in agent-kernel crates/ak-core/src/tasks.rs.
26
+ * A shell is `s-<random>`, a durable session `session-<time>-<random>`, a fork
27
+ * `fork-turn-<time>-<random>-<n>` and one more number when that name is taken. `s-GATEWAY` is the
28
+ * gateway's shared pseudo-session: it names no one session. Every other Nodecode id stays refused.
29
+ * @param {unknown} id */
30
+ function validNodecodeId(id) {
31
+ if (typeof id !== "string" || id === "s-GATEWAY") return false;
32
+ const parts = id.split("-");
33
+ if (parts[0] === "s" && parts.length === 2) return base36Field(parts[1], 1, 8);
34
+ if (parts[0] === "session" && parts.length === 3) return stamp(parts[1], parts[2]);
35
+ if (parts[0] !== "fork" || parts[1] !== "turn") return false;
36
+ if (parts.length < 5 || parts.length > 6) return false;
37
+ const [, , time, random, n, taken] = parts;
38
+ return (
39
+ stamp(time, random) && decimalField(n, 1) && (taken === undefined || decimalField(taken, 2))
40
+ );
41
+ }
42
+
43
+ /** @param {string} id */
44
+ const lowercaseUuid = (id) => (UUID.test(id) ? id.toLowerCase() : null);
45
+ /** @param {string} id */
46
+ const exactNodecodeId = (id) => (validNodecodeId(id) ? id : null);
47
+
48
+ /** Each harness's AK prefix, and how its ids are spelled in a claimant (null: no such claimant).
49
+ * A UUID folds to lowercase; a Nodecode id is case-exact, so it is kept or refused, never folded. */
50
+ const HARNESSES =
51
+ /** @type {Record<string, {prefix: string; spell: (id: string) => string | null}>} */ ({
52
+ pi: { prefix: "session-", spell: lowercaseUuid },
53
+ claude: { prefix: "claude-session-", spell: lowercaseUuid },
54
+ codex: { prefix: "codex-session-", spell: lowercaseUuid },
55
+ nodecode: { prefix: "nodecode-session-", spell: exactNodecodeId },
56
+ });
57
+
58
+ /** Unknown harnesses and ids outside a harness's form have no published AK claimant mapping.
59
+ * @param {string} harness @param {string} id */
60
+ export function claimantFor(harness, id) {
61
+ if (!Object.hasOwn(HARNESSES, harness)) return null;
62
+ const { prefix, spell } = HARNESSES[harness];
63
+ const spelled = spell(id);
64
+ return spelled === null ? null : `${prefix}${spelled}`;
65
+ }
66
+
67
+ /** @param {unknown} value */
68
+ export function parseClaimant(value) {
69
+ if (typeof value !== "string") return null;
70
+ for (const [harness, { prefix, spell }] of Object.entries(HARNESSES)) {
71
+ if (!value.startsWith(prefix)) continue;
72
+ const id = spell(value.slice(prefix.length));
73
+ if (id !== null) return { harness, id, claimant: `${prefix}${id}` };
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /** Strip publisher ids are explicitly namespaced; a bare UUID is Pi only.
79
+ * @param {unknown} value */
80
+ export function publisherClaimant(value) {
81
+ if (typeof value !== "string") return null;
82
+ if (UUID.test(value)) return claimantFor("pi", value);
83
+ const match = /^agent:([^:]+):(.+)$/.exec(value);
84
+ return match ? claimantFor(match[1], match[2]) : null;
85
+ }