/** * The coordination helpers. * * Both surfaces (bash hooks + this TS module) write into the same * `.harnery/active/.json` heartbeat files and `.harnery/pid-map/` * ppid map, so a single coord state can be observed and mutated from * either side without divergence. */ import { spawnSync } from "node:child_process"; import { existsSync, readdirSync, readFileSync } from "node:fs"; import { dirname, join, resolve, sep } from "node:path"; import { listHookProducerStateRecordsV3, readHookProducerStateV3, } from "../events/v3/producers/recorder.ts"; // NOTE: kept dependency-free (node builtins only); this file is vendored verbatim into // a downstream consumer, so it cannot import the coordEnv helper. const MAX_INSTANCE_ID_LENGTH = 128; /** * Instance IDs become coordination filenames, so only one portable basename * alphabet is accepted at every filesystem boundary. UUIDs, test IDs, and * legacy hex IDs all fit this contract; separators and dot segments do not. */ export function isSafeInstanceId(value: unknown): value is string { if (typeof value !== "string" || value.length === 0 || value.length > MAX_INSTANCE_ID_LENGTH) { return false; } for (let index = 0; index < value.length; index++) { const code = value.charCodeAt(index); const alpha = (code >= 65 && code <= 90) || (code >= 97 && code <= 122); const digit = code >= 48 && code <= 57; if (!alpha && !digit && code !== 45 && code !== 95) return false; } return true; } export function assertSafeInstanceId(value: unknown): asserts value is string { if (!isSafeInstanceId(value)) { throw new Error("instance_id must be 1-128 ASCII letters, digits, hyphens, or underscores"); } } /** * Resolve one direct-child filename beneath a trusted coordination directory. * * This is a second boundary behind the instance-ID allowlist. Normalizing the * candidate and proving its directory prefix prevents traversal even if a new * caller constructs a filename from a different untrusted source. */ export function resolveContainedFile(directory: string, fileName: string): string { const root = resolve(directory); const candidate = resolve(root, fileName); if (!candidate.startsWith(`${root}${sep}`) || dirname(candidate) !== root) { throw new Error("coordination filename must resolve directly beneath its root"); } return candidate; } /** * Resolve the monorepo root for coord-state purposes. * * Thin alias for `resolveCoordRoot()`; kept because it is the name the CLI * command modules and the vendored downstream consumer already import. */ export function monorepoRoot(): string | null { return resolveCoordRoot(); } /** * THE coordination-root resolution. Every surface — the hooks, the CLI's reads, * and the CLI's canonical emits — resolves through this one function, because a * root the two layers disagree about is a root that silently breaks the * end-of-turn rules: the hook evaluates `coord.status_observed` from the stream * it reads, so an emit into a different V3 ledger root is invisible * and rule 1/3 blocks a turn that did run `agents status`, with no sequence of * CLI commands able to satisfy it. * * Precedence: * 1. `HARNERY_COORD_ROOT_OVERRIDE` — explicit pin (tests, git hooks, and the * root every coord-helper spawn pins for its child). * 2. `CLAUDE_PROJECT_DIR` — the adapter stating which project it opened. Hook * processes inherit the session's *shell* cwd, which follows `cd` into a * subdirectory or submodule that may carry a `.harnery/` of its own (or * none at all), so the adapter's own statement outranks the cwd walk. * 3. The candidate root that already holds THIS session's heartbeat. * 4. The nearest enclosing `.harnery/`, then a git-derived root. * * Step 3 is what makes CLI/hook disagreement structurally impossible rather * than a coin flip. Choosing either root unconditionally is wrong in one * direction each: preferring the git superproject strands a session whose * adapter opened the submodule itself (its heartbeat lives in the submodule, * so status/set-task wrote events the hook never read), while walking up from * cwd alone strands the opposite case, a session opened on the superproject * whose shell has cd'd into a submodule that carries its own `.harnery/` * (regression-tested in tests/unit/coord-helper-root-pin.test.ts). The session's * own heartbeat settles it: whichever root the hook registered this session in * is the root the CLI must use, and the adapter-exported session id needed to * recognize it is available to a plain tool-call subprocess even though * `CLAUDE_PROJECT_DIR` is not. */ export function resolveCoordRoot(start: string = process.cwd()): string | null { const rootOverride = process.env.HARNERY_COORD_ROOT_OVERRIDE; if (rootOverride) return rootOverride; const projectDir = process.env.CLAUDE_PROJECT_DIR; if (projectDir) { const fromProject = nearestCoordRoot(projectDir); if (fromProject) return fromProject; } // Nearest-first, so the fallback at the end keeps the historical cwd-walk // behavior for a shell that has no registered session at all. const ancestors = ancestorCoordRoots(start); for (const candidate of ancestors) { if (rootKnowsSession(candidate)) return candidate; } // Only reached when no enclosing root knows this session: a submodule // worktree (`harn worktree add-submodule`) has no ancestor relationship with // the superproject that registered the session, so ask git for it. const gitRoots = gitCoordRoots(start).filter((r) => !ancestors.includes(r)); for (const candidate of gitRoots) { if (rootKnowsSession(candidate)) return candidate; } if (ancestors.length > 0) return ancestors[0] as string; if (gitRoots.length > 0) return gitRoots[0] as string; // Nothing carries `.harnery/` yet. Hand back the enclosing checkout so a // first run (`harn init`, agent-hook's session.started) has somewhere to // create it. return gitToplevel(start); } /** Nearest enclosing directory carrying `.harnery/`, or null. */ export function nearestCoordRoot(start: string): string | null { return ancestorCoordRoots(start)[0] ?? null; } /** Every enclosing directory carrying `.harnery/`, nearest first. */ function ancestorCoordRoots(start: string): string[] { const found: string[] = []; let dir = resolve(start); while (true) { if (existsSync(join(dir, ".harnery"))) found.push(dir); const parent = dirname(dir); if (parent === dir) break; dir = parent; } return found; } /** * Does this root's `.harnery/` already know the process asking? * * Two discriminators, both genuinely about *this* session: the adapter-exported * session id matching live V3 producer state, and a pid-map row on our own ppid * chain. Deliberately NOT the single-live-agent fallback that owner resolution * ends with — a lone stranger in the wrong root is exactly how `whoami` came to * report another agent's name and task as its own. */ export function rootKnowsSession(root: string): boolean { if (resolveOwnerBySessionEnv(root)) return true; return resolveOwnerByPidmap(root).owner !== null; } /** * Git-derived candidate roots for `start`, in preference order, filtered to * those that actually carry `.harnery/`. * * Spawns run with `cwd: start` rather than the process cwd so an injected * start dir resolves its own repository — without that, a call about some * unrelated directory inherits this process's repo and can resolve a root that * has nothing to do with the question asked. */ function gitCoordRoots(start: string): string[] { const roots: string[] = []; const push = (value: string | null) => { if (value && existsSync(join(value, ".harnery")) && !roots.includes(value)) roots.push(value); }; // Superproject working tree (running from inside a submodule). push(gitRevParse(start, "--show-superproject-working-tree")); // `--git-common-dir` fallback for submodule worktrees: `git worktree add` // inside a submodule produces a worktree whose superproject working tree is // empty (the worktree has no submodule relationship of its own), but the // common dir points at `/.git/modules//`, so the // superproject is recoverable by stripping that suffix. const common = gitRevParse(start, "--git-common-dir"); if (common) { const idx = common.indexOf("/.git/modules/"); if (idx !== -1) push(common.substring(0, idx)); } // Top-level (regular checkout). push(gitRevParse(start, "--show-toplevel")); return roots; } function gitToplevel(start: string): string | null { return gitRevParse(start, "--show-toplevel"); } function gitRevParse(cwd: string, flag: string): string | null { const key = `${cwd}\0${flag}`; const cached = gitRevParseCache.get(key); if (cached !== undefined) return cached; let value: string | null = null; try { // `cwd` must exist or the spawn itself fails; callers pass paths that may // not (a project dir from a stale env var), so treat any failure as "no // answer" rather than letting it throw. const r = spawnSync("git", ["rev-parse", flag], { encoding: "utf8", cwd }); if (r.status === 0) value = r.stdout.trim() || null; } catch { value = null; } gitRevParseCache.set(key, value); return value; } /** * Memoized because git spawns are the expensive part of resolution and every * CLI command resolves the root many times per invocation. Keyed by (cwd, flag); * a repository's identity does not change under a running process. */ const gitRevParseCache = new Map(); /** Parse owner from a pid-map row (`owner` or `owner\tplatform`). */ export function parsePidmapRowOwner(row: string): string { const trimmed = row.trim(); const tab = trimmed.indexOf("\t"); return tab >= 0 ? trimmed.slice(0, tab) : trimmed; } /** Parse platform from a pid-map row; legacy rows default to `claude-code`. */ export function parsePidmapRowPlatform(row: string): string { const platform = row.trim().split("\t")[1]?.trim(); return platform || "claude-code"; } /** Parse the start token from a pid-map row; rows written before it carry none. */ export function parsePidmapRowStartToken(row: string): string | undefined { return row.trim().split("\t")[2]?.trim() || undefined; } /** * Is the process now holding `pid` the one this row was written for? * * A pid is a number the OS re-issues, and quickly: a `pid_max` of 99999 against * ~100 new processes a second recycles the whole space about every quarter * hour. Believing a row past that point resolves this session to whichever * agent last held the number — which is what made `whoami` report a stranger's * name and files. The start token settles it, since two processes may share a * pid but never a pid and a start instant. * * Deliberately inlined rather than imported from `state/proc-start.ts`: this * file is vendored verbatim into a downstream consumer and stays on node * builtins only. The token is a wire format shared with that module and with * the host's commit guard, so the copies must agree byte for byte; exported so * a test can hold this one against `processStartToken` and fail on drift. */ export function pidStartToken(pid: number): string | null { const forced = process.env.HARNERY_PID_PROBE; const useProcfs = forced === "procfs" || (forced !== "ps" && existsSync("/proc/self/stat")); // One machine, one probe. Falling back to the other on a read failure would // answer in the wrong dialect and read as a recycled pid. if (useProcfs) { try { const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); const afterComm = stat.slice(stat.lastIndexOf(") ") + 2); // Fields after comm, 0-based: 0 is state (field 3), starttime (22) is 19. const ticks = afterComm.split(" ")[19]; if (!ticks || !/^\d+$/.test(ticks)) return null; // Ticks count from boot, so they repeat across reboots; the boot id scopes // them. Rows written before it carry ticks alone and still compare. let boot = ""; try { const raw = readFileSync("/proc/sys/kernel/random/boot_id", "utf8") .trim() .replace(/-/g, ""); if (/^[0-9a-f]{8,}$/.test(raw)) boot = `${raw.slice(0, 8)}.`; } catch { /* unnamed boot: fall back to the tick-only shape */ } return `l${boot}${ticks}`; } catch { return null; } } try { // TZ and locale are pinned because `ps` renders the date through them, and // two callers with different environments must not disagree about one // process. const out = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 2000, env: { ...process.env, TZ: "UTC", LC_ALL: "C" }, }); if (out.status !== 0) return null; const lstart = (out.stdout ?? "").split("\n")[0]?.trim().replace(/\s+/g, " "); return lstart ? `p${lstart}` : null; } catch { return null; } } function pidWasRecycled(pid: number, row: string): boolean { const recorded = parsePidmapRowStartToken(row); if (!recorded) return false; // pre-token row: unverifiable, behave as before const current = pidStartToken(pid); if (!current) return false; if (current === recorded) return false; // A row predating the boot segment recorded ticks alone; compare it on what // it recorded rather than pruning every live row on the first upgraded run. if ( recorded[0] === "l" && current[0] === "l" && recorded.includes(".") !== current.includes(".") ) { const ticks = (t: string) => (t.includes(".") ? t.slice(t.indexOf(".") + 1) : t.slice(1)); return ticks(recorded) !== ticks(current); } return true; } function readPidmapRow(pidmapDir: string, pid: number): string | null { const candidate = resolve(pidmapDir, String(pid)); if (!existsSync(candidate)) return null; try { const row = readFileSync(candidate, "utf8").trim(); return row || null; } catch { return null; } } /** * Walk up the ppid chain looking for a pid-map entry. Returns * the resolved instance_id or null. * * Pid-map files are `instance_id` or `instance_id\tplatform` (Cursor Phase 1). * Prefer a row whose platform matches `HARNERY_AGENT_COORD_PLATFORM` (default * `claude-code`); otherwise return the first owner seen on the walk. * * Subagents intentionally do not write pid-map entries; a bare Bash-tool * ppid-walk from inside an unbridged child therefore resolves to the parent's * pid-map entry. Native hook bridges supersede this fallback through the * child session environment. */ export function resolveOwner(): string | null { return resolveOwnerWithSource().owner; } /** * Like `resolveOwner` but also reports which resolution path matched. * Used by `harn agents whoami` to surface the path (`env` / `pidmap`) in * the diagnostic output. Operators trying to debug "why doesn't my * Codex session see itself?" need to know whether `HARNERY_AGENT_COORD_OWNER` * is propagating or the ppid-walk is the load-bearing path. */ export function resolveOwnerWithSource(): { owner: string | null; source: "env" | "pidmap" | "pidmap_fallback" | "session_env" | "active_singleton" | "none"; } { const bridge = process.env.HARNERY_AGENT_COORD_BRIDGE?.trim(); const envOwner = process.env.HARNERY_AGENT_COORD_OWNER?.trim(); // A bridge-marked child must prove identity through a live V3 producer. An // inherited owner override is only a string, so trusting it here would let a // stale or foreign environment bypass the bridge's fail-closed contract. if (envOwner && !bridge) { return { owner: envOwner, source: "env" }; } const root = monorepoRoot(); if (!root) return { owner: null, source: "none" }; // Every supported adapter exports its native session id into subprocesses. // Match it against the live V3 producer state, never a disposable cache. if (shouldPreferSessionEnv()) { const bySession = resolveOwnerBySessionEnvDetailed(root); if (bySession) { // Env vars cross process boundaries that session identity does not: a // nested different-adapter child inherits its spawner's exported id (a // Codex session's CODEX_THREAD_ID survives into a `claude -p` child's // shells untouched), and that stale statement joins the spawner's live // producer. The nearest token-verified pid-map anchor names the session // whose tool environment this process actually runs in, so on a // CROSS-adapter conflict the anchor wins — the anchoring adapter never // stamps another adapter's variable, so a foreign-adapter id here is // inherited context, while the anchor row is re-healed on every tool // call. A SAME-adapter conflict keeps the env: the adapter stamps its // own variable fresh per tool call, and the row may lag one heal (e.g. // a session id change inside one process). Only per-session-process // adapters may anchor (claude-code, codex) — Cursor sessions share one // terminal process, so a verified Cursor row can belong to a sibling // session. Unverified rows keep losing to the env too: without a start // token the row is the guess the env ordering was introduced to beat. // Never for bridge-marked children: a connector crosses a process-tree // boundary, so an anchor in THIS tree is someone else's by definition. const anchor = bridge ? null : nearestVerifiedSessionAnchor(root); if ( anchor && anchor.owner !== bySession.owner && !bySession.adapters.includes(anchor.platform) && (anchor.platform === "claude-code" || anchor.platform === "codex") ) { return { owner: anchor.owner, source: "pidmap" }; } return { owner: bySession.owner, source: "session_env" }; } } // Connector children cross process-tree boundaries where pid ancestry is // not logical session identity. Once marked, a missing V3 generation is // terminal: never guess through pid-map or singleton fallback. if (bridge) return { owner: null, source: "none" }; if (!existsSync(resolve(root, ".harnery", "pid-map"))) return { owner: null, source: "none" }; const byPidmap = resolveOwnerByPidmap(root); if (byPidmap.owner) return byPidmap; // Last resort: if exactly one V3 generation is live in this coord root, it's // unambiguously us — resolve to it. This is what lets the bare `agents // status` / `set-task` the stop hook recommends work without a `--session-id` // flag in the common single-agent case. With 0 or 2+ live agents it would be // a guess, so we stay null and require the explicit flag. const singleton = resolveSingleActiveOwner(root); if (singleton) { return { owner: singleton, source: "active_singleton" }; } return { owner: null, source: "none" }; } /** * The nearest pid-map anchor on our own ppid chain whose start token PROVES the * row still describes the process holding that pid, or null when the nearest * row is unverified, recycled, or absent. * * Nearest matters: in a nested-agent tree (session A spawns a headless session * B whose shell runs this code), both A's and B's adapter processes are * ancestors, and only the innermost one owns this process's tool environment. * Exported for unit testing with an injectable root. */ export function nearestVerifiedSessionAnchor( root: string, ): { owner: string; platform: string } | null { const pidmapDir = resolve(root, ".harnery", "pid-map"); if (!existsSync(pidmapDir)) return null; let pid: number | null = process.pid; for (let hop = 0; hop < 20; hop++) { if (pid === null) break; const row = readPidmapRow(pidmapDir, pid); if (row) { const owner = parsePidmapRowOwner(row); if (owner && !pidWasRecycled(pid, row)) { // A recycled row is about a dead process's tenure of this pid — walk // past it like the owner walk does. A live row without a provable // token match is the nearest anchor but not a VERIFIED one: stop and // report none, so the session env keeps outranking guesses. const token = parsePidmapRowStartToken(row); if (!token || !pidStartToken(pid)) return null; return { owner, platform: parsePidmapRowPlatform(row) }; } } pid = readPpid(pid); } return null; } /** * Walk our own ppid chain for a pid-map row in ONE given root. * * Root-parameterized (rather than resolving the root itself) so root resolution * can use it as a discriminator without recursing back into itself. */ export function resolveOwnerByPidmap(root: string): { owner: string | null; source: "pidmap" | "pidmap_fallback" | "none"; } { const pidmapDir = resolve(root, ".harnery", "pid-map"); if (!existsSync(pidmapDir)) return { owner: null, source: "none" }; const prefer = process.env.HARNERY_AGENT_COORD_PLATFORM?.trim() || "claude-code"; let fallbackOwner: string | null = null; let pid: number | null = process.pid; for (let hop = 0; hop < 20; hop++) { if (pid === null) break; const row = readPidmapRow(pidmapDir, pid); if (row && !pidWasRecycled(pid, row)) { const rowOwner = parsePidmapRowOwner(row); const rowPlat = parsePidmapRowPlatform(row); if (rowPlat === prefer) { return { owner: rowOwner || null, source: "pidmap" }; } if (!fallbackOwner && rowOwner) fallbackOwner = rowOwner; } pid = readPpid(pid); } if (fallbackOwner) { return { owner: fallbackOwner, source: "pidmap_fallback" }; } return { owner: null, source: "none" }; } /** * Adapter-exported session-id environment variables, in precedence order. Each * supported adapter propagates its session id into the env of the subprocess it * spawns for a tool call (Claude Code's Bash tool, Cursor's terminal, Codex's * shell). A coord CLI invoked as such a tool can therefore recover its own * identity from the env even when the ppid walk misses. * * Kept inline (no shared-helper import) so this file stays node-builtins-only * for the vendored downstream consumer. */ const SESSION_ID_ENV_VARS = [ "HARNERY_AGENT_COORD_SESSION_ID", // explicit override, wins if set "CLAUDE_CODE_SESSION_ID", "CURSOR_SESSION_ID", "CURSOR_CONVERSATION_ID", "CODEX_SESSION_ID", "CODEX_THREAD_ID", ] as const; /** Read normalized candidates from the first non-empty adapter session-id env var. */ /** * First adapter/bridge-stamped session id from the environment, WITHOUT the * live-heartbeat validation resolveOwnerBySessionEnv applies. A fresh session * has no heartbeat until its first set-task, so heartbeat-validated resolution * returns null there by design; commands that REGISTER a session (set-task) * may use this id directly — it carries the same trust as an explicit * `--session-id` argument, because the adapter or connector stamped it. */ export function sessionIdentityFromEnv(): string | null { return sessionIdsFromEnv()[0] ?? null; } function sessionIdsFromEnv(): string[] { for (const key of SESSION_ID_ENV_VARS) { const v = process.env[key]?.trim(); if (!v) continue; if (key === "CURSOR_CONVERSATION_ID" && v.startsWith("bc-") && v.length > 3) { return [v.slice(3), v]; } return [v]; } return []; } /** Read the first non-empty adapter session-id env var, or null. */ function sessionIdFromEnv(): string | null { return sessionIdsFromEnv()[0] ?? null; } /** * Should the adapter-exported session id be consulted before the ppid walk? * * Yes, whenever one is exported. The env var is the adapter stating its own * identity; the walk is a guess over a namespace the OS recycles. On a box with * `pid_max` of 99999 and ~100 pids allocated per second the whole pid space * turns over about every quarter hour, so a row written before that can name a * pid some unrelated process now holds. Pruning cannot save the walk here: * it removes rows whose pid is dead, and a recycled pid is alive. Letting a * guess outrank a statement of fact is what made `agents whoami` report another * agent's name and file list. * * This only reorders the two. Session-env resolution still requires a live * V3 producer carrying that session id, so when it does not match, the walk runs * exactly as before. */ function shouldPreferSessionEnv(): boolean { return sessionIdFromEnv() !== null; } /** * Resolve the owner by joining adapter session environment to the live V3 * hook-producer state. A missing, terminal, or ambiguous generation fails * closed. The disposable cache is consulted only to recover the native * instance label bound to an already-authoritative canonical instance. * * Exported for unit testing with an injectable root. */ export function resolveOwnerBySessionEnv(root: string): string | null { return resolveOwnerBySessionEnvDetailed(root)?.owner ?? null; } /** * `resolveOwnerBySessionEnv` plus WHICH adapter's producer state the session id * joined. The caller uses the adapter to tell a same-adapter conflict (the env * var is stamped fresh per tool call and outranks a possibly-lagging pid-map * row) from a cross-adapter one (the var is inherited context from an outer * session and the verified row wins). */ export function resolveOwnerBySessionEnvDetailed( root: string, ): { owner: string; adapters: string[] } | null { const sessionIds = sessionIdsFromEnv(); if (sessionIds.length === 0) return null; const adapters = adapterCandidatesFromEnv(); const matches = new Map>(); for (const sessionId of sessionIds) { for (const adapter of adapters) { const state = readHookProducerStateV3(root, adapter, sessionId); if (state && !state.terminal) { const owner = nativeOwnerForV3Instance(root, state.instance_id); const set = matches.get(owner) ?? new Set(); set.add(adapter); matches.set(owner, set); } } } if (matches.size !== 1) return null; const [owner, adapterSet] = [...matches.entries()][0]!; return { owner, adapters: [...adapterSet] }; } /** * Return the native instance label of the sole live V3 generation in this * coord root, or null if there are zero or more than one. * * Exported for unit testing with an injectable root (the caller in * `resolveOwnerWithSource` passes `monorepoRoot()`). */ export function resolveSingleActiveOwner(root: string): string | null { const live = new Set( listHookProducerStateRecordsV3(root, { includeTerminal: false }).map(({ state }) => nativeOwnerForV3Instance(root, state.instance_id), ), ); return live.size === 1 ? [...live][0]! : null; } function adapterCandidatesFromEnv(): Array<"claude-code" | "codex" | "cursor"> { const value = process.env.HARNERY_AGENT_COORD_PLATFORM?.trim(); if (value === "claude-code" || value === "codex" || value === "cursor") return [value]; return ["claude-code", "codex", "cursor"]; } function nativeOwnerForV3Instance(root: string, instanceId: string): string { const activeDir = resolve(root, ".harnery", "active"); try { for (const file of readdirSync(activeDir)) { if (!file.endsWith(".json")) continue; const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8")) as Record< string, unknown >; if ( parsed.schema_version === 2 && parsed.v3_instance_id === instanceId && typeof parsed.instance_id === "string" ) { return parsed.instance_id; } } } catch { // The producer state remains authoritative; fall through to the canonical label. } return instanceId.startsWith("inst_") ? instanceId.slice(5) : instanceId; } function readPpid(pid: number): number | null { // Linux/WSL fast path: /proc//status carries `PPid:`. try { const status = readFileSync(`/proc/${pid}/status`, "utf8"); const m = status.match(/^PPid:\s+(\d+)/m); if (m) { const parsed = Number.parseInt(m[1]!, 10); if (Number.isFinite(parsed) && parsed > 0) return parsed; } } catch { // no /proc (macOS/BSD) — fall through to ps } // Portable fallback: `ps -o ppid= -p ` works on macOS/BSD/Linux. try { const out = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" }); if (out.status === 0) { const parsed = Number.parseInt(out.stdout.trim(), 10); if (Number.isFinite(parsed) && parsed > 0) return parsed; } } catch { // ps unavailable — give up } return null; }