@tryinget/pi-activity-strip 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -21
- package/bin/pi-activity-strip.mjs +32 -4
- package/native/bin/linux-x64-gnu/artifact.json +4 -2
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/src/app.rs +1141 -84
- package/native/panel/src/app_gui_tests.rs +730 -0
- package/native/panel/src/app_hover_gui_tests.rs +917 -0
- package/native/panel/src/card_view.rs +19 -4
- package/native/panel/src/directory_view.rs +471 -0
- package/native/panel/src/hints.rs +65 -0
- package/native/panel/src/hover-lifecycle-fixtures.json +30 -0
- package/native/panel/src/hover_keyboard.rs +237 -0
- package/native/panel/src/interaction.rs +156 -0
- package/native/panel/src/keyboard-observation-fixtures.json +241 -0
- package/native/panel/src/keyboard_observation.rs +70 -0
- package/native/panel/src/main.rs +5 -0
- package/native/panel/src/protocol.rs +93 -8
- package/native/panel/src/runtime.rs +85 -12
- package/native/panel/src/style.css +85 -0
- package/package.json +6 -7
- package/src/broker/server.mjs +31 -14
- package/src/client/launcher.mjs +3 -7
- package/src/client/runtime-lock.mjs +2 -1
- package/src/common/agent-identity.mjs +6 -1
- package/src/common/contracts.d.ts +2 -0
- package/src/common/contracts.ts +39 -0
- package/src/common/keyboard-observation.mjs +35 -0
- package/src/common/overlay-readiness.mjs +11 -0
- package/src/common/presence-apps.mjs +57 -0
- package/src/common/recovery-identity.mjs +85 -0
- package/src/common/recovery-manifest.mjs +417 -0
- package/src/common/recovery-types.ts +75 -0
- package/src/common/ribbon-status.mjs +41 -0
- package/src/common/status-report.mjs +37 -2
- package/src/common/workspace-view.mjs +118 -0
- package/src/native/ak-runtime.mjs +50 -9
- package/src/native/focus-visibility.mjs +67 -0
- package/src/native/ghostty-tab-inventory.py +40 -23
- package/src/native/hover-keyboard.mjs +32 -0
- package/src/native/interaction-context.mjs +45 -0
- package/src/native/keyboard-entry.mjs +32 -0
- package/src/native/main.mjs +91 -42
- package/src/native/panel-binary.mjs +25 -8
- package/src/native/panel-neutral-source.mjs +53 -0
- package/src/native/panel-presentation.mjs +137 -0
- package/src/native/panel-projection.mjs +354 -18
- package/src/native/panel-protocol.mjs +23 -0
- package/src/native/panel-window-jump.mjs +90 -0
- package/src/native/placement.mjs +83 -20
- package/src/native/recovery-inventory.mjs +209 -0
- package/src/native/recovery-proc.mjs +178 -0
- package/src/native/recovery-sources.mjs +358 -0
- package/src/native/runtime-diagnostics.mjs +242 -0
- package/src/native/strip-hotkey-kdl.mjs +252 -0
- package/src/native/strip-hotkey.mjs +62 -0
- package/src/native/tab-inventory.mjs +62 -11
- package/src/native/window-jump.mjs +43 -0
- package/src/native/workspace-events.mjs +123 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "Pure, conservative recovery links with observation coverage, never custody transfer."
|
|
3
|
+
// read_when: ["changing recovery manifest proof semantics"]
|
|
4
|
+
// ---
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { claimantFor, parseClaimant } from "./recovery-identity.mjs";
|
|
7
|
+
|
|
8
|
+
/** @typedef {import('./recovery-types.ts').ProcessObservation} ProcessObservation */
|
|
9
|
+
/** @typedef {import('./recovery-types.ts').SessionObservation} SessionObservation */
|
|
10
|
+
/** @typedef {import('./recovery-types.ts').RecoveryInputs} RecoveryInputs */
|
|
11
|
+
|
|
12
|
+
export const MANIFEST_SCHEMA = "pi-activity-strip.recovery-manifest.v1";
|
|
13
|
+
// /proc/stat btime has one-second precision. This is a comparison allowance, not identity proof.
|
|
14
|
+
export const BIRTH_CLOCK_ALLOWANCE_MS = 2000;
|
|
15
|
+
|
|
16
|
+
/** Preserve unmatched/reused live processes independently of stale registry assertions.
|
|
17
|
+
* @param {RecoveryInputs} input @returns {SessionObservation[]} */
|
|
18
|
+
export function withProcessOnlyObservations(input) {
|
|
19
|
+
const result = [...input.sessions];
|
|
20
|
+
const linked = new Set(
|
|
21
|
+
input.sessions.map((s) => processLink(s, input).process?.pid).filter(Boolean),
|
|
22
|
+
);
|
|
23
|
+
for (const p of input.before.filter((p) => p.harness)) {
|
|
24
|
+
if (linked.has(p.pid)) continue;
|
|
25
|
+
result.push({
|
|
26
|
+
harness: p.harness,
|
|
27
|
+
sessionId: null,
|
|
28
|
+
pid: p.pid,
|
|
29
|
+
source: "process-scan",
|
|
30
|
+
sourceRef: `/proc/${p.pid}/stat`,
|
|
31
|
+
cwd: p.cwd,
|
|
32
|
+
startedAt: p.birthMs,
|
|
33
|
+
pidDomain: p.pidDomain,
|
|
34
|
+
procStart: p.startTicks,
|
|
35
|
+
bootId: p.bootId,
|
|
36
|
+
changed: false,
|
|
37
|
+
transcripts: [],
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const RECOVERY_CAPABILITIES = [
|
|
44
|
+
{
|
|
45
|
+
harness: "pi",
|
|
46
|
+
session: "broker-self-report",
|
|
47
|
+
transcript: "header-id",
|
|
48
|
+
currentDisplay: "unproved",
|
|
49
|
+
claimantPrefix: "session-",
|
|
50
|
+
limitation: "broker has no process-birth token; custom session roots need --pi-session-root",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
harness: "claude",
|
|
54
|
+
session: "registry-pid-domain-procStart",
|
|
55
|
+
transcript: "filename-candidate",
|
|
56
|
+
currentDisplay: "unproved",
|
|
57
|
+
claimantPrefix: "claude-session-",
|
|
58
|
+
limitation: "registry lacks boot id; start-time mismatch retained; transcript content not read",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
harness: "codex",
|
|
62
|
+
session: "open-rollout-header",
|
|
63
|
+
transcript: "open-descriptor",
|
|
64
|
+
currentDisplay: "unsupported",
|
|
65
|
+
claimantPrefix: "codex-session-",
|
|
66
|
+
limitation:
|
|
67
|
+
"open descriptor proves neither writer nor currently displayed thread; no cwd/recency fallback",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
harness: "nodecode",
|
|
71
|
+
session: "shell-presence-pid-startTicks",
|
|
72
|
+
transcript: "unsupported",
|
|
73
|
+
currentDisplay: "unproved",
|
|
74
|
+
claimantPrefix: "nodecode-session-",
|
|
75
|
+
limitation:
|
|
76
|
+
"presence record lacks pid domain and boot id, so links stay unconfirmed; ids unique per home",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
harness: "angelx",
|
|
80
|
+
session: "unsupported",
|
|
81
|
+
transcript: "unsupported",
|
|
82
|
+
currentDisplay: "unsupported",
|
|
83
|
+
claimantPrefix: null,
|
|
84
|
+
limitation:
|
|
85
|
+
"installed saved-session metadata has no verified live presence; filename PID may be historical",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
harness: "agnt",
|
|
89
|
+
session: "unsupported",
|
|
90
|
+
transcript: "unsupported",
|
|
91
|
+
currentDisplay: "unsupported",
|
|
92
|
+
claimantPrefix: null,
|
|
93
|
+
limitation:
|
|
94
|
+
"Electron application; frontend activeConversationId has no verified process-birth publisher",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
harness: "omp",
|
|
98
|
+
session: "unsupported",
|
|
99
|
+
transcript: "unsupported",
|
|
100
|
+
currentDisplay: "unsupported",
|
|
101
|
+
claimantPrefix: null,
|
|
102
|
+
limitation:
|
|
103
|
+
"no qualified current-session adapter; breadcrumbs and daemon client IDs are not live conversation identity",
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
harness: "unknown",
|
|
107
|
+
session: "unsupported",
|
|
108
|
+
transcript: "unsupported",
|
|
109
|
+
currentDisplay: "unsupported",
|
|
110
|
+
claimantPrefix: null,
|
|
111
|
+
limitation: "no guessed UUID, command replay, or canonical AK prefix",
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/** @param {ProcessObservation | undefined} a @param {ProcessObservation | undefined} b */
|
|
116
|
+
export function sameProcess(a, b) {
|
|
117
|
+
return Boolean(
|
|
118
|
+
a &&
|
|
119
|
+
b &&
|
|
120
|
+
a.pid === b.pid &&
|
|
121
|
+
a.startTicks === b.startTicks &&
|
|
122
|
+
a.bootId === b.bootId &&
|
|
123
|
+
a.pidDomain === b.pidDomain &&
|
|
124
|
+
a.executableIdentity === b.executableIdentity &&
|
|
125
|
+
a.ppid === b.ppid &&
|
|
126
|
+
a.harness === b.harness &&
|
|
127
|
+
a.selectorDigest === b.selectorDigest,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Namespace is checked BEFORE looking up a host PID, including coincident start ticks.
|
|
132
|
+
* @param {SessionObservation} session @param {RecoveryInputs} input */
|
|
133
|
+
export function processLink(session, input) {
|
|
134
|
+
const provenance = [session.sourceRef];
|
|
135
|
+
/** @type {string[]} */
|
|
136
|
+
const uncertainties = [];
|
|
137
|
+
const result = (
|
|
138
|
+
state = "unresolved",
|
|
139
|
+
process = /** @type {ProcessObservation | null} */ (null),
|
|
140
|
+
) => ({ state, process, provenance, uncertainties });
|
|
141
|
+
if (session.pidDomain && session.pidDomain !== input.pidDomain) {
|
|
142
|
+
uncertainties.push("foreign-pid-domain-no-mapping");
|
|
143
|
+
return result("foreign-namespace");
|
|
144
|
+
}
|
|
145
|
+
if (session.bootId && session.bootId !== input.bootId) return result("boot-mismatch");
|
|
146
|
+
if (session.changed) return result("source-changed");
|
|
147
|
+
const before = input.before.find((p) => p.pid === session.pid);
|
|
148
|
+
const after = input.after.find((p) => p.pid === session.pid);
|
|
149
|
+
if (!before) return result("not-observed");
|
|
150
|
+
provenance.push(
|
|
151
|
+
`/proc/${session.pid}/stat`,
|
|
152
|
+
`/proc/${session.pid}/exe`,
|
|
153
|
+
`/proc/${session.pid}/ns/pid`,
|
|
154
|
+
);
|
|
155
|
+
if (!sameProcess(before, after)) return result("process-changed");
|
|
156
|
+
if (before.harness !== session.harness) return result("harness-mismatch");
|
|
157
|
+
if (session.procStart && session.procStart !== before.startTicks) return result("birth-mismatch");
|
|
158
|
+
if (session.source === "process-scan") return result("process-only", before);
|
|
159
|
+
if (session.source === "codex-open-rollout") {
|
|
160
|
+
uncertainties.push("open-descriptor-is-not-writer-or-current-display");
|
|
161
|
+
return result("open-descriptor-associated", before);
|
|
162
|
+
}
|
|
163
|
+
if (session.source === "pi-broker") {
|
|
164
|
+
uncertainties.push(
|
|
165
|
+
"publisher-has-no-process-birth-token",
|
|
166
|
+
"self-report-not-current-display-proof",
|
|
167
|
+
);
|
|
168
|
+
if (
|
|
169
|
+
!session.updatedAt ||
|
|
170
|
+
session.updatedAt < input.startedAt - 12000 ||
|
|
171
|
+
session.updatedAt > input.endedAt + BIRTH_CLOCK_ALLOWANCE_MS
|
|
172
|
+
)
|
|
173
|
+
return result("stale-publisher", before);
|
|
174
|
+
if (session.startedAt < before.birthMs - BIRTH_CLOCK_ALLOWANCE_MS)
|
|
175
|
+
return result("birth-mismatch");
|
|
176
|
+
return result("reported", before);
|
|
177
|
+
}
|
|
178
|
+
if (!session.pidDomain || !session.procStart) {
|
|
179
|
+
uncertainties.push("registry-missing-pid-domain-or-birth-token");
|
|
180
|
+
return result("unconfirmed", before);
|
|
181
|
+
}
|
|
182
|
+
uncertainties.push("registry-has-no-boot-id");
|
|
183
|
+
const delta = session.startedAt - before.birthMs;
|
|
184
|
+
if (!Number.isFinite(delta) || Math.abs(delta) > BIRTH_CLOCK_ALLOWANCE_MS) {
|
|
185
|
+
uncertainties.push("registry-startedAt-differs-from-process-birth");
|
|
186
|
+
return result("uncertain-start-time", before);
|
|
187
|
+
}
|
|
188
|
+
return result("birth-correlated", before);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** @param {ProcessObservation | null} process @param {RecoveryInputs} input */
|
|
192
|
+
function windowLinks(process, input) {
|
|
193
|
+
if (!process)
|
|
194
|
+
return {
|
|
195
|
+
state: "unresolved",
|
|
196
|
+
windows: [],
|
|
197
|
+
provenance: [],
|
|
198
|
+
uncertainties: ["no-stable-process-link"],
|
|
199
|
+
};
|
|
200
|
+
/** @type {ProcessObservation[]} */
|
|
201
|
+
const chain = [];
|
|
202
|
+
const seen = new Set();
|
|
203
|
+
let current = process;
|
|
204
|
+
while (current && !seen.has(current.pid) && chain.length < 64) {
|
|
205
|
+
seen.add(current.pid);
|
|
206
|
+
if (
|
|
207
|
+
!sameProcess(
|
|
208
|
+
current,
|
|
209
|
+
input.after.find((p) => p.pid === current.pid),
|
|
210
|
+
)
|
|
211
|
+
) {
|
|
212
|
+
return { state: "changed", windows: [], provenance: [], uncertainties: ["ancestor-changed"] };
|
|
213
|
+
}
|
|
214
|
+
chain.push(current);
|
|
215
|
+
const parent = input.before.find((p) => p.pid === current.ppid);
|
|
216
|
+
if (!parent) break;
|
|
217
|
+
current = parent;
|
|
218
|
+
}
|
|
219
|
+
const candidates = input.windows.filter((w) => chain.some((p) => p.pid === w.pid));
|
|
220
|
+
const stable = candidates.filter((w) =>
|
|
221
|
+
input.windowsAfter.some(
|
|
222
|
+
(a) => a.id === w.id && a.pid === w.pid && a.workspace_id === w.workspace_id,
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
const changed =
|
|
226
|
+
stable.length !== candidates.length ||
|
|
227
|
+
input.windowsAfter.some(
|
|
228
|
+
(w) => chain.some((p) => p.pid === w.pid) && !candidates.some((b) => b.id === w.id),
|
|
229
|
+
);
|
|
230
|
+
const windows = stable.map((w) => {
|
|
231
|
+
const ws = input.workspaces.find((s) => s.id === w.workspace_id);
|
|
232
|
+
const wsStable = ws && input.workspacesAfter.some((a) => a.id === ws.id && a.idx === ws.idx);
|
|
233
|
+
return {
|
|
234
|
+
id: w.id,
|
|
235
|
+
hostPid: w.pid,
|
|
236
|
+
workspaceId: w.workspace_id,
|
|
237
|
+
workspaceIndex: wsStable ? ws.idx : null,
|
|
238
|
+
relation: "process-ancestry-containment",
|
|
239
|
+
provenance: [
|
|
240
|
+
"niri msg -j windows",
|
|
241
|
+
...chain.map((p) => `/proc/${p.pid}/stat`),
|
|
242
|
+
"niri msg -j workspaces",
|
|
243
|
+
],
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
return {
|
|
247
|
+
state: changed
|
|
248
|
+
? "changed"
|
|
249
|
+
: windows.length > 1
|
|
250
|
+
? "ambiguous"
|
|
251
|
+
: windows.length === 1
|
|
252
|
+
? "containment-only"
|
|
253
|
+
: "not-observed",
|
|
254
|
+
windows,
|
|
255
|
+
provenance: ["niri msg -j windows (before/after)"],
|
|
256
|
+
uncertainties: [
|
|
257
|
+
"containment-is-not-exact-tab-identity",
|
|
258
|
+
...(changed ? ["window-observation-changed"] : []),
|
|
259
|
+
],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** @param {RecoveryInputs} input @param {{includePaths?: boolean}} [options] */
|
|
264
|
+
|
|
265
|
+
export function buildRecoveryManifest(input, { includePaths = false } = {}) {
|
|
266
|
+
const observations = withProcessOnlyObservations(input);
|
|
267
|
+
const sessions = observations.map((s, index) => {
|
|
268
|
+
const process = processLink(s, input);
|
|
269
|
+
const claimant = s.sessionId ? claimantFor(s.harness, s.sessionId) : null;
|
|
270
|
+
return {
|
|
271
|
+
observationId: `observation-${index + 1}`,
|
|
272
|
+
harness: s.harness,
|
|
273
|
+
session: {
|
|
274
|
+
id: s.sessionId,
|
|
275
|
+
source: s.source,
|
|
276
|
+
provenance: [s.sourceRef],
|
|
277
|
+
cwd: s.cwd,
|
|
278
|
+
reportedStartedAt: s.startedAt || null,
|
|
279
|
+
},
|
|
280
|
+
process,
|
|
281
|
+
placement: windowLinks(process.process, input),
|
|
282
|
+
transcripts: s.transcripts,
|
|
283
|
+
ak: {
|
|
284
|
+
claimant,
|
|
285
|
+
authority: "AK-only; observation does not transfer custody",
|
|
286
|
+
links:
|
|
287
|
+
/** @type {Array<{taskId: number; state: string; claimant: string; leaseExpiresAt: string; provenance: string[]}>} */ ([]),
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
const claims = input.claims.map((c) => {
|
|
292
|
+
const claimant = parseClaimant(c.claimed_by);
|
|
293
|
+
const lease = Date.parse(c.lease_expires_at);
|
|
294
|
+
const matched = claimant ? sessions.filter((s) => s.ak.claimant === claimant.claimant) : [];
|
|
295
|
+
const state = !claimant
|
|
296
|
+
? "unsupported-claimant"
|
|
297
|
+
: !Number.isFinite(lease)
|
|
298
|
+
? "invalid-lease"
|
|
299
|
+
: lease <= input.endedAt
|
|
300
|
+
? "expired"
|
|
301
|
+
: c.status !== "claimed"
|
|
302
|
+
? "not-claimed"
|
|
303
|
+
: matched.length > 1
|
|
304
|
+
? "ambiguous-session"
|
|
305
|
+
: matched.length === 1
|
|
306
|
+
? "exact-claimant"
|
|
307
|
+
: "session-not-observed";
|
|
308
|
+
if (state === "exact-claimant")
|
|
309
|
+
matched[0].ak.links.push({
|
|
310
|
+
taskId: c.id,
|
|
311
|
+
state,
|
|
312
|
+
claimant: c.claimed_by,
|
|
313
|
+
leaseExpiresAt: c.lease_expires_at,
|
|
314
|
+
provenance: ["ak task list --status claimed --format json --all --verbose"],
|
|
315
|
+
});
|
|
316
|
+
return {
|
|
317
|
+
taskId: c.id,
|
|
318
|
+
claimant: c.claimed_by,
|
|
319
|
+
harness: claimant?.harness ?? null,
|
|
320
|
+
leaseExpiresAt: c.lease_expires_at,
|
|
321
|
+
state,
|
|
322
|
+
observationIds: matched.map((s) => s.observationId),
|
|
323
|
+
provenance: ["ak task list --status claimed --format json --all --verbose"],
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
const manifest = {
|
|
327
|
+
schema: MANIFEST_SCHEMA,
|
|
328
|
+
readOnly: true,
|
|
329
|
+
complete: false,
|
|
330
|
+
observation: {
|
|
331
|
+
startedAt: new Date(input.startedAt).toISOString(),
|
|
332
|
+
endedAt: new Date(input.endedAt).toISOString(),
|
|
333
|
+
bootId: input.bootId,
|
|
334
|
+
pidDomain: input.pidDomain,
|
|
335
|
+
atomic: false,
|
|
336
|
+
},
|
|
337
|
+
privacy: {
|
|
338
|
+
paths: includePaths ? "included-private-output-only" : "sha256-redacted",
|
|
339
|
+
transcriptContent: "never-emitted",
|
|
340
|
+
titles: "never-emitted",
|
|
341
|
+
commandLines: "never-emitted",
|
|
342
|
+
},
|
|
343
|
+
capabilities: RECOVERY_CAPABILITIES,
|
|
344
|
+
sources: input.sources,
|
|
345
|
+
coverage: {
|
|
346
|
+
observationRows: sessions.length,
|
|
347
|
+
identifiedSessionObservations: sessions.filter((s) => s.session.id).length,
|
|
348
|
+
distinctSessionIdentities: new Set(
|
|
349
|
+
sessions.filter((s) => s.session.id).map((s) => `${s.harness}:${s.session.id}`),
|
|
350
|
+
).size,
|
|
351
|
+
processOnlyObservations: sessions.filter((s) => !s.session.id).length,
|
|
352
|
+
processCandidates: input.before.filter((p) => p.harness).length,
|
|
353
|
+
processLinks: sessions.filter((s) => s.process.process).length,
|
|
354
|
+
processLinkStates: counts(sessions.map((s) => s.process.state)),
|
|
355
|
+
harnesses: Object.fromEntries(
|
|
356
|
+
[...new Set(sessions.map((s) => s.harness))].map((h) => [
|
|
357
|
+
h,
|
|
358
|
+
{
|
|
359
|
+
observationRows: sessions.filter((s) => s.harness === h).length,
|
|
360
|
+
distinctSessionIdentities: new Set(
|
|
361
|
+
sessions.filter((s) => s.harness === h && s.session.id).map((s) => s.session.id),
|
|
362
|
+
).size,
|
|
363
|
+
processOnlyObservations: sessions.filter((s) => s.harness === h && !s.session.id)
|
|
364
|
+
.length,
|
|
365
|
+
},
|
|
366
|
+
]),
|
|
367
|
+
),
|
|
368
|
+
placementStates: counts(sessions.map((s) => s.placement.state)),
|
|
369
|
+
transcripts: counts(sessions.flatMap((s) => s.transcripts.map((t) => t.state))),
|
|
370
|
+
claimRows: claims.length,
|
|
371
|
+
claimStates: counts(claims.map((c) => c.state)),
|
|
372
|
+
},
|
|
373
|
+
limitations: [
|
|
374
|
+
"Non-atomic bounded observation; processes can start or exit after capture.",
|
|
375
|
+
"No full historical session census or transcript/context completeness claim.",
|
|
376
|
+
"No exact terminal tab/current display proof; no reopen, focus or replay command.",
|
|
377
|
+
"AK6737 owns custody; AK6481 owns native reopen; this is observation only.",
|
|
378
|
+
],
|
|
379
|
+
sessions,
|
|
380
|
+
claims,
|
|
381
|
+
windowContainmentGroups: [
|
|
382
|
+
...new Set(sessions.flatMap((s) => s.placement.windows.map((w) => w.id))),
|
|
383
|
+
].map((windowId) => {
|
|
384
|
+
const contained = sessions.filter((s) => s.placement.windows.some((w) => w.id === windowId));
|
|
385
|
+
return {
|
|
386
|
+
windowId,
|
|
387
|
+
relation: "candidate-containment-not-session-count",
|
|
388
|
+
processObservations: new Set(contained.map((s) => s.process.process?.pid).filter(Boolean))
|
|
389
|
+
.size,
|
|
390
|
+
distinctSessionIdentities: new Set(
|
|
391
|
+
contained.filter((s) => s.session.id).map((s) => `${s.harness}:${s.session.id}`),
|
|
392
|
+
).size,
|
|
393
|
+
observationIds: contained.map((s) => s.observationId),
|
|
394
|
+
};
|
|
395
|
+
}),
|
|
396
|
+
};
|
|
397
|
+
return includePaths ? manifest : redactPaths(manifest);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** @param {string[]} values */
|
|
401
|
+
|
|
402
|
+
function counts(values) {
|
|
403
|
+
return Object.fromEntries(
|
|
404
|
+
[...new Set(values)].sort().map((key) => [key, values.filter((v) => v === key).length]),
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** @template T @param {T} value @returns {T} */
|
|
409
|
+
function redactPaths(value) {
|
|
410
|
+
return JSON.parse(
|
|
411
|
+
JSON.stringify(value, (_key, item) =>
|
|
412
|
+
typeof item === "string" && item.startsWith("/")
|
|
413
|
+
? `path-sha256:${createHash("sha256").update(item).digest("hex")}`
|
|
414
|
+
: item,
|
|
415
|
+
),
|
|
416
|
+
);
|
|
417
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// summary: Read-only recovery observation types; not AK custody or reopen authority.
|
|
2
|
+
export interface ProcessObservation {
|
|
3
|
+
pid: number;
|
|
4
|
+
ppid: number;
|
|
5
|
+
startTicks: string;
|
|
6
|
+
birthMs: number;
|
|
7
|
+
bootId: string;
|
|
8
|
+
pidDomain: string;
|
|
9
|
+
executable: string;
|
|
10
|
+
executableIdentity: string;
|
|
11
|
+
selectorDigest?: string;
|
|
12
|
+
cwd: string;
|
|
13
|
+
harness: string;
|
|
14
|
+
}
|
|
15
|
+
export interface SessionObservation {
|
|
16
|
+
harness: string;
|
|
17
|
+
sessionId: string | null;
|
|
18
|
+
pid: number;
|
|
19
|
+
source: string;
|
|
20
|
+
sourceRef: string;
|
|
21
|
+
cwd: string;
|
|
22
|
+
startedAt: number;
|
|
23
|
+
pidDomain: string | null;
|
|
24
|
+
procStart: string | null;
|
|
25
|
+
bootId?: string | null;
|
|
26
|
+
changed: boolean;
|
|
27
|
+
updatedAt?: number;
|
|
28
|
+
transcripts: TranscriptObservation[];
|
|
29
|
+
}
|
|
30
|
+
export interface TranscriptObservation {
|
|
31
|
+
path: string;
|
|
32
|
+
state: string;
|
|
33
|
+
method: string;
|
|
34
|
+
identity?: string;
|
|
35
|
+
size?: number;
|
|
36
|
+
changed?: boolean;
|
|
37
|
+
sessionId?: string | null;
|
|
38
|
+
}
|
|
39
|
+
export interface WindowObservation {
|
|
40
|
+
id: number;
|
|
41
|
+
pid: number | null;
|
|
42
|
+
workspace_id: number | null;
|
|
43
|
+
}
|
|
44
|
+
export interface WorkspaceObservation {
|
|
45
|
+
id: number;
|
|
46
|
+
idx: number;
|
|
47
|
+
}
|
|
48
|
+
export interface SourceCoverage {
|
|
49
|
+
source: string;
|
|
50
|
+
state: string;
|
|
51
|
+
observed: number;
|
|
52
|
+
rejected: number;
|
|
53
|
+
limitations: string[];
|
|
54
|
+
}
|
|
55
|
+
export interface ClaimObservation {
|
|
56
|
+
id: number;
|
|
57
|
+
claimed_by: string;
|
|
58
|
+
status: string;
|
|
59
|
+
lease_expires_at: string;
|
|
60
|
+
}
|
|
61
|
+
export interface RecoveryInputs {
|
|
62
|
+
startedAt: number;
|
|
63
|
+
endedAt: number;
|
|
64
|
+
bootId: string;
|
|
65
|
+
pidDomain: string;
|
|
66
|
+
before: ProcessObservation[];
|
|
67
|
+
after: ProcessObservation[];
|
|
68
|
+
sessions: SessionObservation[];
|
|
69
|
+
windows: WindowObservation[];
|
|
70
|
+
windowsAfter: WindowObservation[];
|
|
71
|
+
workspaces: WorkspaceObservation[];
|
|
72
|
+
workspacesAfter: WorkspaceObservation[];
|
|
73
|
+
claims: ClaimObservation[];
|
|
74
|
+
sources: SourceCoverage[];
|
|
75
|
+
}
|