@delorenj/pjangler 1.5.38 → 1.5.40

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/dist/crank/tui.js CHANGED
@@ -213,6 +213,59 @@ var PJANGLER_VERSION = (() => {
213
213
  return "0.0.0";
214
214
  })();
215
215
 
216
+ // src/crank/engine.ts
217
+ var WINDOW_MS = 24 * 60 * 60 * 1e3;
218
+ var TICK_MS = 5 * 60 * 1e3;
219
+ var STALL_MS = 3 * TICK_MS;
220
+ var ENGINE_AUDIT_TAIL_BYTES = 1024 * 1024;
221
+ var FUTURE_SKEW_MS = 5 * 6e4;
222
+ function clockTime(ms) {
223
+ const d = new Date(ms);
224
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
225
+ }
226
+ function ago(thenMs, nowMs) {
227
+ const s = Math.max(0, Math.round((nowMs - thenMs) / 1e3));
228
+ if (s < 60) return `${s}s ago`;
229
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
230
+ if (s < 86400) return `${Math.floor(s / 3600)}h ${String(Math.floor(s % 3600 / 60)).padStart(2, "0")}m ago`;
231
+ return `${Math.floor(s / 86400)}d ${Math.floor(s % 86400 / 3600)}h ago`;
232
+ }
233
+ function compactTokens(n) {
234
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
235
+ if (n >= 1e3) return `${Math.round(n / 1e3)}k`;
236
+ return String(n);
237
+ }
238
+ function nextTickAtOrAfter(ms) {
239
+ return Math.ceil(ms / TICK_MS) * TICK_MS;
240
+ }
241
+ function engineStalled(status) {
242
+ return status.known && status.lastTickMs !== void 0 && status.readAtMs - status.lastTickMs > STALL_MS;
243
+ }
244
+ function engineBlockedLabel(status) {
245
+ if (!status.known) return void 0;
246
+ if (engineStalled(status) && status.lastTickMs !== void 0) return `blocked: engine not ticking (last tick ${ago(status.lastTickMs, status.readAtMs)})`;
247
+ if (status.outcome !== "refused") return void 0;
248
+ if (status.tokenBlocked) {
249
+ const resume = status.resumeMs !== void 0 ? ` \u2014 resumes \u2248 ${clockTime(nextTickAtOrAfter(status.resumeMs))}` : "";
250
+ return `blocked: daily token cap${resume}`;
251
+ }
252
+ return `blocked: ${status.reason ?? "engine refused the last tick"}`;
253
+ }
254
+ function engineSummary(status) {
255
+ if (!status.known || status.lastTickMs === void 0) return { text: "engine status unknown", tone: "unknown" };
256
+ const now = status.readAtMs;
257
+ const usage = `${status.beats24h} beat${status.beats24h === 1 ? "" : "s"} / ${compactTokens(status.tokens24h)} tokens in 24h${status.cap ? ` of ${compactTokens(status.cap)}` : ""}`;
258
+ const tick = `last tick ${ago(status.lastTickMs, now)}`;
259
+ const stalled = engineStalled(status) ? " - not ticking" : "";
260
+ if (status.outcome === "cranked") return { text: `${tick} (cranked)${stalled} \xB7 ${usage}`, tone: stalled ? "warn" : "ok" };
261
+ if (status.outcome === "refused") {
262
+ const since = status.refusedSinceMs !== void 0 && status.refusedSinceMs < status.lastTickMs ? ` since ${ago(status.refusedSinceMs, now)}` : "";
263
+ const resume = status.tokenBlocked && status.resumeMs !== void 0 ? ` \xB7 resumes \u2248 ${clockTime(nextTickAtOrAfter(status.resumeMs))}` : "";
264
+ return { text: `REFUSING${since}: ${status.reason ?? "refused"} \xB7 ${tick}${stalled} \xB7 ${usage}${resume}`, tone: "warn" };
265
+ }
266
+ return { text: `${tick} (${status.lastStatus ?? "other"}${status.reason ? `: ${status.reason}` : ""})${stalled} \xB7 ${usage}`, tone: "warn" };
267
+ }
268
+
216
269
  // src/crank/index.ts
217
270
  var ZCRANK_INTERVALS = ["5m", "1h", "6h", "1d"];
218
271
  var INTERVAL_SECONDS = { "5m": 300, "1h": 3600, "6h": 21600, "1d": 86400 };
@@ -239,6 +292,7 @@ function formatAge(thenMs, nowMs) {
239
292
  if (s < 60) return `${s}s ago`;
240
293
  return `${formatDuration(s)} ago`;
241
294
  }
295
+ var PANE_CWD_TTL_SECONDS = 6 * 3600;
242
296
  function renderLogRow(raw) {
243
297
  try {
244
298
  const parsed = JSON.parse(raw);
@@ -294,10 +348,6 @@ async function pickInterval(props, run = {}) {
294
348
  const choice = await runInk((finish) => /* @__PURE__ */ jsx3(IntervalPicker, { ...props, onDone: finish }), run);
295
349
  return choice ?? null;
296
350
  }
297
- function clockTime(ms) {
298
- const d = new Date(ms);
299
- return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
300
- }
301
351
  function describeMatch(view) {
302
352
  const { resolution } = view;
303
353
  if (resolution.storedAsIs) return `no live tab or key matches "${view.name}" - stored under the project name`;
@@ -346,6 +396,9 @@ function CrankDashboard({ controller, refreshMs = 5e3, clock = Date.now, onQuit
346
396
  });
347
397
  const onInterval = (interval) => act(`interval ${interval}`, () => controller.setInterval(interval));
348
398
  const next = nextRunMs({ enabled: isOn, interval: snap.interval, lastRun: snap.lastRunMs, nowMs: now });
399
+ const engineLine = engineSummary(view.engine);
400
+ const blocked = isOn ? engineBlockedLabel(view.engine) : void 0;
401
+ const engineColor = engineLine.tone === "ok" ? themeColor("ok") : engineLine.tone === "warn" ? themeColor("warn") : themeColor("muted");
349
402
  const logRows = Math.max(3, (rows || 24) - 19);
350
403
  const tail = snap.log.slice(-logRows);
351
404
  return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", paddingX: 1, children: [
@@ -390,7 +443,7 @@ function CrankDashboard({ controller, refreshMs = 5e3, clock = Date.now, onQuit
390
443
  ] }),
391
444
  /* @__PURE__ */ jsxs3(Text3, { children: [
392
445
  /* @__PURE__ */ jsx3(Text3, { color: themeColor("muted"), children: "Next run " }),
393
- next === void 0 ? /* @__PURE__ */ jsx3(Text3, { color: themeColor("muted"), children: "off" }) : /* @__PURE__ */ jsxs3(Text3, { children: [
446
+ next === void 0 ? /* @__PURE__ */ jsx3(Text3, { color: themeColor("muted"), children: "off" }) : blocked ? /* @__PURE__ */ jsx3(Text3, { color: themeColor("warn"), children: blocked }) : /* @__PURE__ */ jsxs3(Text3, { children: [
394
447
  next <= now ? "now" : `in ${formatDuration((next - now) / 1e3)}`,
395
448
  " ",
396
449
  /* @__PURE__ */ jsxs3(Text3, { color: themeColor("muted"), children: [
@@ -400,8 +453,12 @@ function CrankDashboard({ controller, refreshMs = 5e3, clock = Date.now, onQuit
400
453
  ] })
401
454
  ] })
402
455
  ] }),
456
+ /* @__PURE__ */ jsxs3(Text3, { wrap: "truncate-end", children: [
457
+ /* @__PURE__ */ jsx3(Text3, { color: themeColor("muted"), children: "Engine " }),
458
+ /* @__PURE__ */ jsx3(Text3, { color: engineColor, children: engineLine.text })
459
+ ] }),
403
460
  /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginTop: 1, children: [
404
- /* @__PURE__ */ jsx3(Text3, { color: themeColor("muted"), children: "Last runs" }),
461
+ /* @__PURE__ */ jsx3(Text3, { color: themeColor("muted"), children: "Last runs of this tab (beats that touched it, not engine ticks)" }),
405
462
  snap.beats.length === 0 ? /* @__PURE__ */ jsx3(Text3, { color: themeColor("muted"), children: " none yet" }) : snap.beats.map((beat, i) => /* @__PURE__ */ jsxs3(Text3, { wrap: "truncate-end", children: [
406
463
  " ",
407
464
  formatAge(beat.tsMs, now).padEnd(10),
package/dist/index.js CHANGED
@@ -6064,8 +6064,8 @@ var init_capture = __esm({
6064
6064
 
6065
6065
  // src/index.ts
6066
6066
  import { spawnSync as spawnSync18 } from "node:child_process";
6067
- import { existsSync as existsSync27, readdirSync as readdirSync13, readFileSync as readFileSync30, statSync as statSync9 } from "node:fs";
6068
- import { basename as basename11, join as join35, resolve as resolve25 } from "node:path";
6067
+ import { existsSync as existsSync26, readdirSync as readdirSync13, readFileSync as readFileSync31, statSync as statSync9 } from "node:fs";
6068
+ import { basename as basename11, join as join36, resolve as resolve25 } from "node:path";
6069
6069
  import { Command as Command3, CommanderError, Option } from "commander";
6070
6070
 
6071
6071
  // src/recipes/Recipe.ts
@@ -20943,8 +20943,204 @@ async function loadScreen(relativeToDist, base) {
20943
20943
  init_boardQuery();
20944
20944
  init_style();
20945
20945
  import { spawnSync as spawnSync17 } from "node:child_process";
20946
- import { readFileSync as readFileSync29 } from "node:fs";
20946
+ import { mkdirSync as mkdirSync12, readFileSync as readFileSync30, realpathSync as realpathSync12, renameSync as renameSync10, writeFileSync as writeFileSync15 } from "node:fs";
20947
+ import { homedir as homedir15 } from "node:os";
20948
+ import { dirname as dirname22, join as join35 } from "node:path";
20949
+
20950
+ // src/crank/engine.ts
20951
+ import { closeSync as closeSync9, constants as constants7, fstatSync as fstatSync5, openSync as openSync9, readFileSync as readFileSync29, readSync as readSync5 } from "node:fs";
20952
+ import { homedir as homedir14 } from "node:os";
20947
20953
  import { join as join34 } from "node:path";
20954
+ var ENGINE_DEFAULT_JOB_ID = "777c511a009c";
20955
+ var WINDOW_MS = 24 * 60 * 60 * 1e3;
20956
+ var TICK_MS = 5 * 60 * 1e3;
20957
+ var STALL_MS = 3 * TICK_MS;
20958
+ var ENGINE_AUDIT_TAIL_BYTES = 1024 * 1024;
20959
+ function unknownEngineStatus(nowMs, jobId = ENGINE_DEFAULT_JOB_ID) {
20960
+ return { known: false, jobId, readAtMs: nowMs, outcome: "unknown", beats24h: 0, tokens24h: 0, tokenBlocked: false };
20961
+ }
20962
+ var FUTURE_SKEW_MS = 5 * 6e4;
20963
+ function parseAuditRows(text2, jobId) {
20964
+ const rows = [];
20965
+ for (const line of text2.split("\n")) {
20966
+ const trimmed2 = line.trim();
20967
+ if (!trimmed2) continue;
20968
+ let parsed;
20969
+ try {
20970
+ parsed = JSON.parse(trimmed2);
20971
+ } catch {
20972
+ continue;
20973
+ }
20974
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue;
20975
+ const record3 = parsed;
20976
+ if (String(record3.job_id ?? "") !== jobId) continue;
20977
+ const ts = typeof record3.ts === "string" ? Date.parse(record3.ts) : Number.NaN;
20978
+ if (!Number.isFinite(ts)) continue;
20979
+ const tokens = Number(record3.total_tokens ?? 0);
20980
+ const reasonRaw = record3.economics_reason ?? record3.error;
20981
+ rows.push({
20982
+ tsMs: ts,
20983
+ status: typeof record3.status === "string" ? record3.status : "",
20984
+ tokens: Number.isFinite(tokens) && tokens > 0 ? tokens : 0,
20985
+ reason: typeof reasonRaw === "string" && reasonRaw.trim() ? reasonRaw.trim() : void 0
20986
+ });
20987
+ }
20988
+ return rows.sort((a, b) => a.tsMs - b.tsMs);
20989
+ }
20990
+ function capFromReason(reason) {
20991
+ const match = /daily token limit reached \(([\d,]+)\)/i.exec(reason ?? "");
20992
+ if (!match) return void 0;
20993
+ const n = Number.parseInt((match[1] ?? "").replace(/,/g, ""), 10);
20994
+ return Number.isFinite(n) && n > 0 ? n : void 0;
20995
+ }
20996
+ function parseEngineStatus(auditText, input) {
20997
+ const jobId = input.jobId ?? ENGINE_DEFAULT_JOB_ID;
20998
+ try {
20999
+ const rows = parseAuditRows(auditText, jobId).filter((r) => r.tsMs <= input.nowMs + FUTURE_SKEW_MS);
21000
+ const last = rows.at(-1);
21001
+ if (!last) return unknownEngineStatus(input.nowMs, jobId);
21002
+ const windowStart = input.nowMs - WINDOW_MS;
21003
+ const counted = rows.filter((r) => (r.status === "completed" || r.status === "started") && r.tsMs >= windowStart);
21004
+ const tokens24h = counted.reduce((sum, r) => sum + r.tokens, 0);
21005
+ const ran = last.status === "completed" || last.status === "started";
21006
+ const refused = !ran && last.status !== "failed" && (last.status === "skipped" || last.status === "blocked" || !!last.reason);
21007
+ const outcome = ran ? "cranked" : refused ? "refused" : "other";
21008
+ const tokenBlocked = outcome === "refused" && /daily token limit/i.test(last.reason ?? "");
21009
+ let refusedSinceMs;
21010
+ if (outcome === "refused") {
21011
+ refusedSinceMs = last.tsMs;
21012
+ for (let i = rows.length - 2; i >= 0; i--) {
21013
+ const r = rows[i];
21014
+ if (!r) break;
21015
+ if (r.status === "completed" || r.status === "started" || r.status === "failed") break;
21016
+ refusedSinceMs = r.tsMs;
21017
+ }
21018
+ }
21019
+ const cap = input.cap && input.cap > 0 ? input.cap : capFromReason(last.reason);
21020
+ let resumeMs;
21021
+ if (tokenBlocked && cap) {
21022
+ let remaining = tokens24h;
21023
+ resumeMs = remaining < cap ? input.nowMs : void 0;
21024
+ for (const row of counted) {
21025
+ if (remaining < cap) break;
21026
+ remaining -= row.tokens;
21027
+ if (remaining < cap) {
21028
+ resumeMs = row.tsMs + WINDOW_MS;
21029
+ break;
21030
+ }
21031
+ }
21032
+ }
21033
+ return {
21034
+ known: true,
21035
+ jobId,
21036
+ readAtMs: input.nowMs,
21037
+ outcome,
21038
+ lastStatus: last.status || void 0,
21039
+ lastTickMs: last.tsMs,
21040
+ reason: outcome === "cranked" ? void 0 : last.reason,
21041
+ refusedSinceMs,
21042
+ beats24h: counted.length,
21043
+ tokens24h,
21044
+ cap,
21045
+ tokenBlocked,
21046
+ resumeMs
21047
+ };
21048
+ } catch {
21049
+ return unknownEngineStatus(input.nowMs, jobId);
21050
+ }
21051
+ }
21052
+ function readTail(path, maxBytes) {
21053
+ const fd = openSync9(path, constants7.O_RDONLY | constants7.O_NONBLOCK);
21054
+ try {
21055
+ const stat = fstatSync5(fd);
21056
+ if (!stat.isFile()) throw new Error(`${path} is not a regular file`);
21057
+ const size = stat.size;
21058
+ const length = Math.min(size, maxBytes);
21059
+ const buffer = Buffer.alloc(length);
21060
+ let read = 0;
21061
+ while (read < length) {
21062
+ const n = readSync5(fd, buffer, read, length - read, size - length + read);
21063
+ if (n <= 0) break;
21064
+ read += n;
21065
+ }
21066
+ const text2 = buffer.subarray(0, read).toString("utf8");
21067
+ if (size <= maxBytes) return text2;
21068
+ const newline = text2.indexOf("\n");
21069
+ return newline === -1 ? "" : text2.slice(newline + 1);
21070
+ } finally {
21071
+ closeSync9(fd);
21072
+ }
21073
+ }
21074
+ function engineSources(env2 = process.env) {
21075
+ const cronDir = join34(homedir14(), ".hermes", "profiles", "33god-pm", "cron");
21076
+ return {
21077
+ auditPath: env2.PJ_CRANK_USAGE_AUDIT?.trim() || join34(cronDir, "usage_audit.jsonl"),
21078
+ jobsPath: join34(cronDir, "jobs.json"),
21079
+ jobId: env2.PJ_CRANK_JOB_ID?.trim() || ENGINE_DEFAULT_JOB_ID
21080
+ };
21081
+ }
21082
+ function readCap(jobsPath, jobId) {
21083
+ try {
21084
+ const parsed = JSON.parse(readFileSync29(jobsPath, "utf8"));
21085
+ const jobs = Array.isArray(parsed.jobs) ? parsed.jobs : [];
21086
+ const job = jobs.find((j) => typeof j === "object" && j !== null && String(j.id ?? "") === jobId);
21087
+ const economics = job?.economics;
21088
+ if (typeof economics !== "object" || economics === null) return void 0;
21089
+ const cap = Number(economics.max_daily_tokens);
21090
+ return Number.isFinite(cap) && cap > 0 ? cap : void 0;
21091
+ } catch {
21092
+ return void 0;
21093
+ }
21094
+ }
21095
+ function readEngineStatus(options = {}) {
21096
+ const nowMs = options.nowMs ?? Date.now();
21097
+ const base = engineSources(options.env);
21098
+ const sources = { ...base, ...options.sources };
21099
+ try {
21100
+ const text2 = readTail(sources.auditPath, options.tailBytes ?? ENGINE_AUDIT_TAIL_BYTES);
21101
+ return parseEngineStatus(text2, { jobId: sources.jobId, cap: readCap(sources.jobsPath, sources.jobId), nowMs });
21102
+ } catch {
21103
+ return unknownEngineStatus(nowMs, sources.jobId);
21104
+ }
21105
+ }
21106
+ function clockTime(ms) {
21107
+ const d = new Date(ms);
21108
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
21109
+ }
21110
+ function ago(thenMs, nowMs) {
21111
+ const s = Math.max(0, Math.round((nowMs - thenMs) / 1e3));
21112
+ if (s < 60) return `${s}s ago`;
21113
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
21114
+ if (s < 86400) return `${Math.floor(s / 3600)}h ${String(Math.floor(s % 3600 / 60)).padStart(2, "0")}m ago`;
21115
+ return `${Math.floor(s / 86400)}d ${Math.floor(s % 86400 / 3600)}h ago`;
21116
+ }
21117
+ function compactTokens(n) {
21118
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
21119
+ if (n >= 1e3) return `${Math.round(n / 1e3)}k`;
21120
+ return String(n);
21121
+ }
21122
+ function nextTickAtOrAfter(ms) {
21123
+ return Math.ceil(ms / TICK_MS) * TICK_MS;
21124
+ }
21125
+ function engineStalled(status) {
21126
+ return status.known && status.lastTickMs !== void 0 && status.readAtMs - status.lastTickMs > STALL_MS;
21127
+ }
21128
+ function engineSummary(status) {
21129
+ if (!status.known || status.lastTickMs === void 0) return { text: "engine status unknown", tone: "unknown" };
21130
+ const now = status.readAtMs;
21131
+ const usage = `${status.beats24h} beat${status.beats24h === 1 ? "" : "s"} / ${compactTokens(status.tokens24h)} tokens in 24h${status.cap ? ` of ${compactTokens(status.cap)}` : ""}`;
21132
+ const tick = `last tick ${ago(status.lastTickMs, now)}`;
21133
+ const stalled = engineStalled(status) ? " - not ticking" : "";
21134
+ if (status.outcome === "cranked") return { text: `${tick} (cranked)${stalled} \xB7 ${usage}`, tone: stalled ? "warn" : "ok" };
21135
+ if (status.outcome === "refused") {
21136
+ const since = status.refusedSinceMs !== void 0 && status.refusedSinceMs < status.lastTickMs ? ` since ${ago(status.refusedSinceMs, now)}` : "";
21137
+ const resume = status.tokenBlocked && status.resumeMs !== void 0 ? ` \xB7 resumes \u2248 ${clockTime(nextTickAtOrAfter(status.resumeMs))}` : "";
21138
+ return { text: `REFUSING${since}: ${status.reason ?? "refused"} \xB7 ${tick}${stalled} \xB7 ${usage}${resume}`, tone: "warn" };
21139
+ }
21140
+ return { text: `${tick} (${status.lastStatus ?? "other"}${status.reason ? `: ${status.reason}` : ""})${stalled} \xB7 ${usage}`, tone: "warn" };
21141
+ }
21142
+
21143
+ // src/crank/index.ts
20948
21144
  var ZCRANK_REDIS_HOST = "127.0.0.1";
20949
21145
  var ZCRANK_REDIS_PORT = 6379;
20950
21146
  function redisEndpoint(env2 = process.env) {
@@ -21095,6 +21291,15 @@ function redis(deps = {}) {
21095
21291
  setTabEnabled: (tab, enabled2) => {
21096
21292
  run("HSET", `zcrank:tab:${tab}`, "enabled", enabled2 ? "1" : "0");
21097
21293
  },
21294
+ /** HGET zcrank:tab:<tab> root -> the owning project identity, or undefined (legacy key / absent). */
21295
+ tabRoot: (tab) => {
21296
+ const v = run("HGET", `zcrank:tab:${tab}`, "root");
21297
+ return v === "" || v == null ? void 0 : v;
21298
+ },
21299
+ /** HSET zcrank:tab:<tab> root <project identity> (PJAN-201). */
21300
+ setTabRoot: (tab, root) => {
21301
+ run("HSET", `zcrank:tab:${tab}`, "root", root);
21302
+ },
21098
21303
  /** HGET zcrank:tab:<tab> interval -> a valid interval, else undefined (absent or garbage). */
21099
21304
  tabInterval: (tab) => parseInterval(run("HGET", `zcrank:tab:${tab}`, "interval")),
21100
21305
  /** HSET zcrank:tab:<tab> interval <5m|1h|6h|1d>. */
@@ -21165,11 +21370,193 @@ function liveTabs(deps = {}) {
21165
21370
  try {
21166
21371
  const parsed = JSON.parse(result2.stdout);
21167
21372
  if (!Array.isArray(parsed)) return [];
21168
- return parsed.filter((entry) => typeof entry === "object" && entry !== null).map((entry) => ({ name: String(entry.name ?? ""), position: Number(entry.position ?? 0) }));
21373
+ return parsed.filter((entry) => typeof entry === "object" && entry !== null).map((entry) => {
21374
+ const id = Number(entry.tab_id);
21375
+ return {
21376
+ name: String(entry.name ?? ""),
21377
+ position: Number(entry.position ?? 0),
21378
+ ...entry.tab_id !== void 0 && entry.tab_id !== null && Number.isInteger(id) ? { id } : {}
21379
+ };
21380
+ });
21381
+ } catch {
21382
+ return [];
21383
+ }
21384
+ }
21385
+ function livePanes(deps = {}) {
21386
+ const shell = deps.shell ?? defaultShell;
21387
+ const result2 = shell("env", [
21388
+ "-u",
21389
+ "ZELLIJ",
21390
+ "-u",
21391
+ "ZELLIJ_SESSION_NAME",
21392
+ "timeout",
21393
+ "8s",
21394
+ "zellij",
21395
+ "-s",
21396
+ ZCRANK_SESSION,
21397
+ "action",
21398
+ "list-panes",
21399
+ "--json",
21400
+ "--all"
21401
+ ]);
21402
+ if (result2.status !== 0 || !result2.stdout.trim()) return [];
21403
+ try {
21404
+ const parsed = JSON.parse(result2.stdout);
21405
+ if (!Array.isArray(parsed)) return [];
21406
+ const panes = [];
21407
+ for (const entry of parsed) {
21408
+ if (typeof entry !== "object" || entry === null) continue;
21409
+ const e = entry;
21410
+ const id = Number(e.id);
21411
+ if (!Number.isFinite(id)) continue;
21412
+ const tabId = Number(e.tab_id);
21413
+ const tabPosition = Number(e.tab_position);
21414
+ panes.push({
21415
+ id,
21416
+ tabId: e.tab_id !== void 0 && e.tab_id !== null && Number.isInteger(tabId) ? tabId : void 0,
21417
+ tabPosition: e.tab_position !== void 0 && e.tab_position !== null && Number.isInteger(tabPosition) ? tabPosition : void 0,
21418
+ tabName: String(e.tab_name ?? ""),
21419
+ isPlugin: e.is_plugin === true,
21420
+ cwd: typeof e.pane_cwd === "string" && e.pane_cwd.trim() ? e.pane_cwd : void 0
21421
+ });
21422
+ }
21423
+ return panes;
21169
21424
  } catch {
21170
21425
  return [];
21171
21426
  }
21172
21427
  }
21428
+ function projectIdentity(dir, deps = {}) {
21429
+ const target = (dir ?? "").trim();
21430
+ if (!target) return void 0;
21431
+ const cached = deps.cache?.get(target);
21432
+ if (cached !== void 0) return cached ?? void 0;
21433
+ if (deps.budget) {
21434
+ deps.budget.untilMs ??= Date.now() + deps.budget.totalMs;
21435
+ if (Date.now() > deps.budget.untilMs) return void 0;
21436
+ }
21437
+ const shell = deps.shell ?? defaultShell;
21438
+ let identity2;
21439
+ try {
21440
+ const result2 = shell("timeout", ["2s", "git", "-C", target, "worktree", "list", "--porcelain"]);
21441
+ if (result2.status === 0) {
21442
+ const first = result2.stdout.split("\n").find((line) => line.startsWith("worktree "));
21443
+ const path = first?.slice("worktree ".length).replace(/\r$/, "").trim();
21444
+ if (path) identity2 = realpathSync12(path);
21445
+ }
21446
+ } catch {
21447
+ identity2 = void 0;
21448
+ }
21449
+ deps.cache?.set(target, identity2 ?? null);
21450
+ return identity2;
21451
+ }
21452
+ function withTabOwners(tabs, panes, identify, claimed = []) {
21453
+ const claimedSet = new Set(claimed);
21454
+ return tabs.map((tab) => {
21455
+ const counts = /* @__PURE__ */ new Map();
21456
+ for (const pane of panes) {
21457
+ if (pane.isPlugin || !pane.cwd) continue;
21458
+ const inTab = tab.id !== void 0 && pane.tabId !== void 0 ? pane.tabId === tab.id : pane.tabPosition === tab.position;
21459
+ if (!inTab) continue;
21460
+ const identity2 = identify(pane.cwd);
21461
+ if (identity2) counts.set(identity2, (counts.get(identity2) ?? 0) + 1);
21462
+ }
21463
+ const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
21464
+ const tied = ranked.filter(([, n]) => n === ranked[0]?.[1]).map(([identity2]) => identity2);
21465
+ const claimedTied = tied.filter((identity2) => claimedSet.has(identity2));
21466
+ const owner = tied.length === 1 ? tied[0] : claimedTied.length === 1 ? claimedTied[0] : void 0;
21467
+ const enriched = { ...tab };
21468
+ if (owner) enriched.owner = owner;
21469
+ if (ranked.length) enriched.ownedPanes = Object.fromEntries(ranked);
21470
+ return enriched;
21471
+ });
21472
+ }
21473
+ var PANE_CWD_TTL_SECONDS = 6 * 3600;
21474
+ function paneCwdCachePath(env2 = process.env) {
21475
+ return env2.ZCRANK_CWD_CACHE?.trim() || join35(homedir15(), ".local/state/zcrank/pane-cwds.json");
21476
+ }
21477
+ function loadCwdCache(path) {
21478
+ try {
21479
+ const parsed = JSON.parse(readFileSync30(path, "utf8"));
21480
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
21481
+ } catch {
21482
+ return {};
21483
+ }
21484
+ }
21485
+ function saveCwdCache(path, cache) {
21486
+ try {
21487
+ mkdirSync12(dirname22(path), { recursive: true });
21488
+ const tmp = `${path}.${process.pid}.tmp`;
21489
+ writeFileSync15(tmp, JSON.stringify(cache));
21490
+ renameSync10(tmp, path);
21491
+ } catch {
21492
+ }
21493
+ }
21494
+ function cwdEntryFresh(entry, nowSeconds) {
21495
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return false;
21496
+ const ts = Number(entry.ts ?? 0);
21497
+ return Number.isFinite(ts) && nowSeconds - ts < PANE_CWD_TTL_SECONDS;
21498
+ }
21499
+ function resolvePaneCwds(fetch2, opts = {}) {
21500
+ const now = (opts.nowMs ?? Date.now()) / 1e3;
21501
+ const path = opts.cachePath ?? paneCwdCachePath();
21502
+ const get = () => {
21503
+ try {
21504
+ const rows = fetch2();
21505
+ return Array.isArray(rows) ? rows : [];
21506
+ } catch {
21507
+ return [];
21508
+ }
21509
+ };
21510
+ const panes = get();
21511
+ if (!panes.length) return panes;
21512
+ const cache = loadCwdCache(path);
21513
+ const sameTab = (entry, pane) => (entry.tab_id ?? null) === (pane.tabId ?? null);
21514
+ const fill = () => {
21515
+ let unknown2 = 0;
21516
+ for (const pane of panes) {
21517
+ if (pane.isPlugin) continue;
21518
+ const key = String(pane.id);
21519
+ if (pane.cwd) {
21520
+ cache[key] = { cwd: pane.cwd, tab_id: pane.tabId ?? null, ts: now };
21521
+ continue;
21522
+ }
21523
+ const entry = cache[key];
21524
+ if (cwdEntryFresh(entry, now) && sameTab(entry, pane) && typeof entry.cwd === "string" && entry.cwd) {
21525
+ pane.cwd = entry.cwd;
21526
+ } else {
21527
+ unknown2++;
21528
+ }
21529
+ }
21530
+ return unknown2;
21531
+ };
21532
+ let unknown = fill();
21533
+ for (let attempt = 0; attempt < (opts.retries ?? 0) && unknown; attempt++) {
21534
+ const again = new Map(get().filter((pane) => pane.cwd).map((pane) => [pane.id, pane.cwd]));
21535
+ for (const pane of panes) {
21536
+ const cwd = again.get(pane.id);
21537
+ if (!pane.isPlugin && !pane.cwd && cwd) pane.cwd = cwd;
21538
+ }
21539
+ unknown = fill();
21540
+ }
21541
+ const live = new Set(panes.filter((pane) => !pane.isPlugin).map((pane) => String(pane.id)));
21542
+ for (const key of Object.keys(cache)) {
21543
+ if (!live.has(key) || !cwdEntryFresh(cache[key], now)) delete cache[key];
21544
+ }
21545
+ saveCwdCache(path, cache);
21546
+ return panes;
21547
+ }
21548
+ function liveTabsWithOwners(deps = {}) {
21549
+ const tabs = liveTabs(deps);
21550
+ if (!tabs.length) return tabs;
21551
+ const cache = /* @__PURE__ */ new Map();
21552
+ const budget = { totalMs: IDENTITY_BUDGET_MS };
21553
+ const panes = resolvePaneCwds(() => livePanes(deps), { retries: deps.retries ?? 1, nowMs: deps.nowMs, cachePath: deps.cachePath });
21554
+ return withTabOwners(tabs, panes, (cwd) => projectIdentity(cwd, { shell: deps.shell, cache, budget }), deps.claimed ?? []);
21555
+ }
21556
+ function claimedRoots(store, keys = storedTabKeys(store)) {
21557
+ return [...new Set(Object.values(storedTabRoots(store, keys)))].sort();
21558
+ }
21559
+ var IDENTITY_BUDGET_MS = 2e4;
21173
21560
  function candidateNames(project) {
21174
21561
  const out = [project.name.trim()].filter(Boolean);
21175
21562
  for (const agent of project.agents) {
@@ -21182,13 +21569,41 @@ function candidateNames(project) {
21182
21569
  }
21183
21570
  return out;
21184
21571
  }
21185
- function resolveTabForProject(project, live, storedTabs) {
21572
+ function keyForLiveTab(tab, storedTabs) {
21573
+ const display = tabDisplay(tab.name);
21574
+ if (storedTabs.includes(display)) return display;
21575
+ return storedTabs.find((s) => canon(s) && canon(s) === canon(display)) ?? display;
21576
+ }
21577
+ function ownerOfLiveTab(live, liveTab) {
21578
+ if (liveTab === void 0) return void 0;
21579
+ return live.find((tab) => tabDisplay(tab.name) === liveTab)?.owner;
21580
+ }
21581
+ function resolveTabForProject(project, live, storedTabs, scope = {}) {
21582
+ const byCwd = resolveByCwd(project, live, storedTabs, scope);
21583
+ if (byCwd) return byCwd;
21584
+ const resolution = resolveByName(project, live, storedTabs);
21585
+ const tabOwner = ownerOfLiveTab(live, resolution.liveTab);
21586
+ return tabOwner ? { ...resolution, tabOwner } : resolution;
21587
+ }
21588
+ function resolveByCwd(project, live, storedTabs, scope) {
21589
+ const identity2 = scope.identity;
21590
+ if (!identity2) return void 0;
21591
+ const owned = live.filter((tab2) => tab2.owner === identity2);
21592
+ if (!owned.length) return void 0;
21593
+ const candidateCanons = new Set(candidateNames(project).map((name) => canon(name)).filter(Boolean));
21594
+ const ranked = [...owned].sort((a, b) => Number(candidateCanons.has(canon(b.name))) - Number(candidateCanons.has(canon(a.name))) || (b.ownedPanes?.[identity2] ?? 0) - (a.ownedPanes?.[identity2] ?? 0) || a.position - b.position);
21595
+ const tab = ranked[0];
21596
+ if (!tab) return void 0;
21597
+ let key = keyForLiveTab(tab, storedTabs);
21598
+ if (!storedTabs.includes(key)) {
21599
+ const rooted = storedTabs.find((stored) => scope.roots?.[stored] === identity2);
21600
+ if (rooted !== void 0) key = rooted;
21601
+ }
21602
+ return { tab: key, source: "live-cwd", matched: identity2, storedAsIs: false, liveTab: tabDisplay(tab.name), tabOwner: identity2 };
21603
+ }
21604
+ function resolveByName(project, live, storedTabs) {
21186
21605
  const candidates = candidateNames(project);
21187
- const keyFor = (tab) => {
21188
- const display = tabDisplay(tab.name);
21189
- if (storedTabs.includes(display)) return display;
21190
- return storedTabs.find((s) => canon(s) && canon(s) === canon(display)) ?? display;
21191
- };
21606
+ const keyFor = (tab) => keyForLiveTab(tab, storedTabs);
21192
21607
  const sideSets = [
21193
21608
  (tab) => [tab.name],
21194
21609
  (tab) => tabDisplay(tab.name).split(":")
@@ -21224,7 +21639,7 @@ function resolveTabForProject(project, live, storedTabs) {
21224
21639
  function projectNameFor(handle, registryName) {
21225
21640
  if (registryName && registryName.trim()) return registryName.trim();
21226
21641
  try {
21227
- const manifest = JSON.parse(readFileSync29(join34(handle.root, ".project.json"), "utf8"));
21642
+ const manifest = JSON.parse(readFileSync30(join35(handle.root, ".project.json"), "utf8"));
21228
21643
  const name = manifest.project_name;
21229
21644
  if (typeof name === "string" && name.trim()) return name.trim();
21230
21645
  } catch {
@@ -21235,7 +21650,7 @@ function projectAgentsFor(handle, registryAgents) {
21235
21650
  const fromRecord = Object.entries(registryAgents ?? {}).map(([agentHandle, value]) => ({ handle: agentHandle, role: String(value?.role ?? "") }));
21236
21651
  if (fromRecord.length) return fromRecord;
21237
21652
  try {
21238
- const manifest = JSON.parse(readFileSync29(join34(handle.root, ".project.json"), "utf8"));
21653
+ const manifest = JSON.parse(readFileSync30(join35(handle.root, ".project.json"), "utf8"));
21239
21654
  const agents = manifest.agents;
21240
21655
  if (agents && typeof agents === "object" && !Array.isArray(agents)) {
21241
21656
  return Object.entries(agents).map(([agentHandle, value]) => ({ handle: agentHandle, role: typeof value?.role === "string" ? value.role : "" }));
@@ -21244,17 +21659,22 @@ function projectAgentsFor(handle, registryAgents) {
21244
21659
  }
21245
21660
  return [];
21246
21661
  }
21247
- function keysNamingTab(tab, storedKeys) {
21662
+ function keysNamingTab(tab, storedKeys, scope = {}) {
21248
21663
  const c = canon(tab);
21249
21664
  const out = [];
21250
21665
  if (storedKeys.includes(tab)) out.push(tab);
21251
21666
  for (const key of storedKeys) {
21252
21667
  if (key !== tab && c && canon(key) === c) out.push(key);
21253
21668
  }
21669
+ if (scope.owner) {
21670
+ for (const key of storedKeys) {
21671
+ if (scope.roots?.[key] === scope.owner && !out.includes(key)) out.push(key);
21672
+ }
21673
+ }
21254
21674
  return out;
21255
21675
  }
21256
- function snapshotCrank(store, tab, storedKeys, nowMs, beatLimit = 5) {
21257
- const keys = keysNamingTab(tab, storedKeys);
21676
+ function snapshotCrank(store, tab, storedKeys, nowMs, beatLimit = 5, scope = {}) {
21677
+ const keys = keysNamingTab(tab, storedKeys, scope);
21258
21678
  const readKeys = keys.length ? keys : [tab];
21259
21679
  const enabledValues = keys.map((key) => store.tabEnabled(key)).filter((v) => v !== void 0);
21260
21680
  const enabled2 = enabledValues.length ? keys.length > 0 && keys.every((key) => store.tabEnabled(key) === "1") : void 0;
@@ -21280,12 +21700,13 @@ function snapshotCrank(store, tab, storedKeys, nowMs, beatLimit = 5) {
21280
21700
  }
21281
21701
  function planRenameMigration(input) {
21282
21702
  const target = input.resolution.liveTab;
21283
- if (!target || input.resolution.source !== "live-exact" && input.resolution.source !== "live-prefix") return void 0;
21703
+ if (!target || input.resolution.source !== "live-exact" && input.resolution.source !== "live-prefix" && input.resolution.source !== "live-cwd") return void 0;
21284
21704
  const targetCanon = canon(target);
21285
21705
  const wanted = new Set([targetCanon, ...input.candidates.map((name) => canon(name))].filter(Boolean));
21286
21706
  const otherLive = new Set(input.live.map((tab) => canon(tab.name)).filter((c) => c && c !== targetCanon));
21287
21707
  const oldKeys = [...new Set(input.storedKeys)].filter((key) => {
21288
21708
  if (key === target) return false;
21709
+ if (input.identity && input.roots?.[key] === input.identity) return true;
21289
21710
  const c = canon(key);
21290
21711
  return c !== "" && wanted.has(c) && !otherLive.has(c);
21291
21712
  });
@@ -21316,12 +21737,36 @@ function applyRenameMigration(store, plan) {
21316
21737
  store.deleteKeys(`zcrank:tab:${old}`, ...oldPanes, `zcrank:log:${old}`);
21317
21738
  }
21318
21739
  }
21319
- function buildCrankContext(project, live, storedKeys) {
21320
- return { resolution: resolveTabForProject(project, live, storedKeys), candidates: candidateNames(project), live, storedKeys };
21740
+ function buildCrankContext(project, live, storedKeys, scope = {}) {
21741
+ const roots = scope.roots ?? {};
21742
+ return {
21743
+ resolution: resolveTabForProject(project, live, storedKeys, { identity: scope.identity, roots }),
21744
+ candidates: candidateNames(project),
21745
+ live,
21746
+ storedKeys,
21747
+ identity: scope.identity,
21748
+ roots
21749
+ };
21750
+ }
21751
+ function keyScopeOf(ctx) {
21752
+ return { owner: ctx.resolution.tabOwner, roots: ctx.roots };
21321
21753
  }
21322
21754
  function storedTabKeys(store) {
21323
21755
  return store.tabKeys().map((key) => key.split(":").slice(2).join(":"));
21324
21756
  }
21757
+ function storedTabRoots(store, keys) {
21758
+ const roots = {};
21759
+ for (const key of keys) {
21760
+ let root;
21761
+ try {
21762
+ root = store.tabRoot(key);
21763
+ } catch (error) {
21764
+ if (!(error instanceof Error) || !error.message.startsWith("Redis error reply")) throw error;
21765
+ }
21766
+ if (root) roots[key] = root;
21767
+ }
21768
+ return roots;
21769
+ }
21325
21770
  function enableCrank(store, ctx, options = {}) {
21326
21771
  const now = options.now ?? /* @__PURE__ */ new Date();
21327
21772
  const ts = crankTimestamp(now);
@@ -21331,16 +21776,20 @@ function enableCrank(store, ctx, options = {}) {
21331
21776
  storedKeys: ctx.storedKeys,
21332
21777
  live: ctx.live,
21333
21778
  intervals: (key) => store.tabInterval(key),
21334
- chosenInterval: options.interval
21779
+ chosenInterval: options.interval,
21780
+ identity: ctx.identity,
21781
+ roots: ctx.roots
21335
21782
  });
21336
21783
  const tab = plan?.target ?? ctx.resolution.tab;
21337
21784
  store.setTabEnabled(tab, true);
21785
+ writeRoot(store, tab, ctx.identity);
21338
21786
  if (options.interval) store.setTabInterval(tab, options.interval);
21339
21787
  if (plan) applyRenameMigration(store, plan);
21340
21788
  else {
21341
- for (const sibling of keysNamingTab(tab, ctx.storedKeys)) {
21789
+ for (const sibling of keysNamingTab(tab, ctx.storedKeys, keyScopeOf(ctx))) {
21342
21790
  if (sibling === tab) continue;
21343
21791
  store.setTabEnabled(sibling, true);
21792
+ writeRoot(store, sibling, ctx.identity);
21344
21793
  if (options.interval) store.clearTabInterval(sibling);
21345
21794
  }
21346
21795
  }
@@ -21350,42 +21799,54 @@ function enableCrank(store, ctx, options = {}) {
21350
21799
  for (const { from, to } of renamed) store.pushLog(tab, "renamed", `pj crank enable: ${from} -> ${to}`, ts);
21351
21800
  return { tab, renamed, interval: options.interval ?? plan?.carryInterval };
21352
21801
  }
21353
- function disableCrank(store, tab, now = /* @__PURE__ */ new Date()) {
21802
+ function writeRoot(store, tab, root) {
21803
+ if (root) store.setTabRoot(tab, root);
21804
+ }
21805
+ function disableCrank(store, tab, now = /* @__PURE__ */ new Date(), root) {
21354
21806
  store.setTabEnabled(tab, false);
21807
+ writeRoot(store, tab, root);
21355
21808
  store.pushLog(tab, "disabled", "pj crank disable", crankTimestamp(now));
21356
21809
  }
21357
- function setCrankInterval(store, tab, storedKeys, interval, now = /* @__PURE__ */ new Date()) {
21358
- for (const key of keysNamingTab(tab, storedKeys)) {
21810
+ function setCrankInterval(store, tab, storedKeys, interval, now = /* @__PURE__ */ new Date(), scope = {}) {
21811
+ for (const key of keysNamingTab(tab, storedKeys, scope)) {
21359
21812
  if (key !== tab) store.clearTabInterval(key);
21360
21813
  }
21361
21814
  if (interval) store.setTabInterval(tab, interval);
21362
21815
  else store.clearTabInterval(tab);
21816
+ writeRoot(store, tab, scope.root);
21363
21817
  const action = interval ? `pj crank tui: interval ${interval}` : `pj crank tui: interval reset to global (${store.globalInterval()})`;
21364
21818
  store.pushLog(tab, "interval", action, crankTimestamp(now));
21365
21819
  }
21366
21820
  function buildCrankList(input) {
21367
21821
  const global = input.globalInterval ?? ZCRANK_DEFAULT_INTERVAL;
21368
- const intervalOf = (tab) => effectiveInterval(
21369
- (keysNamingTab(tab, input.storedTabs).length ? keysNamingTab(tab, input.storedTabs) : [tab]).map((key) => input.tabInterval?.(key)),
21370
- global
21371
- );
21822
+ const roots = input.roots ?? {};
21823
+ const intervalOf = (tab, owner) => {
21824
+ const keys = keysNamingTab(tab, input.storedTabs, { owner, roots });
21825
+ return effectiveInterval((keys.length ? keys : [tab]).map((key) => input.tabInterval?.(key)), global);
21826
+ };
21372
21827
  const rows = [];
21373
21828
  const consumed = /* @__PURE__ */ new Set();
21829
+ const consumedKeys = /* @__PURE__ */ new Set();
21374
21830
  for (const project of input.projects) {
21375
- const resolution = resolveTabForProject(project, input.live, input.storedTabs);
21831
+ const identity2 = project.repoPath ? input.identityOf?.(project.repoPath) : void 0;
21832
+ const resolution = resolveTabForProject(project, input.live, input.storedTabs, { identity: identity2, roots });
21376
21833
  consumed.add(canon(resolution.tab));
21377
- const enabled2 = input.tabEnabled(resolution.tab);
21834
+ const keys = keysNamingTab(resolution.tab, input.storedTabs, { owner: resolution.tabOwner, roots });
21835
+ for (const key of keys) consumedKeys.add(key);
21836
+ const values = (keys.length ? keys : [resolution.tab]).map((key) => input.tabEnabled(key)).filter((v) => v !== void 0);
21837
+ const enabled2 = values.length ? values.every((v) => v === "1") ? "1" : "0" : void 0;
21378
21838
  rows.push({
21379
21839
  slug: project.slug,
21380
21840
  name: project.name,
21381
21841
  tab: resolution.tab,
21382
21842
  crank: enabled2 === "1" ? "ON" : enabled2 === "0" ? "OFF" : "\u2014",
21383
21843
  source: resolution.source,
21384
- ...intervalOf(resolution.tab)
21844
+ ...resolution.liveTab !== void 0 ? { liveTab: resolution.liveTab } : {},
21845
+ ...intervalOf(resolution.tab, resolution.tabOwner)
21385
21846
  });
21386
21847
  }
21387
- const orphans = input.storedTabs.filter((stored) => !consumed.has(canon(stored))).map((stored) => ({ tab: stored, enabled: input.tabEnabled(stored) === "1", ...intervalOf(stored) }));
21388
- return { rows, orphans };
21848
+ const orphans = input.storedTabs.filter((stored) => !consumed.has(canon(stored)) && !consumedKeys.has(stored)).map((stored) => ({ tab: stored, enabled: input.tabEnabled(stored) === "1", ...intervalOf(stored) }));
21849
+ return { engine: input.engine, rows, orphans };
21389
21850
  }
21390
21851
  function crankTimestamp(now = /* @__PURE__ */ new Date()) {
21391
21852
  return now.toISOString().replace(/\.\d{3}Z$/, "Z");
@@ -21401,17 +21862,25 @@ function renderLogRow(raw) {
21401
21862
  return null;
21402
21863
  }
21403
21864
  }
21865
+ function tabCell(row) {
21866
+ if (row.source !== "live-cwd" || row.liveTab === void 0) return row.tab;
21867
+ return canon(row.tab) === canon(row.liveTab) ? row.liveTab : `${row.liveTab} (key ${row.tab})`;
21868
+ }
21404
21869
  function formatCrankList(report) {
21405
21870
  const lines = [""];
21406
21871
  lines.push(` ${heading("Crank by project")}`);
21872
+ if (report.engine) {
21873
+ const summary = engineSummary(report.engine);
21874
+ lines.push(summary.tone === "unknown" ? ` ${dim(summary.text)}` : ` ${summary.tone === "ok" ? green("Engine") : yellow("Engine")} ${summary.tone === "warn" ? yellow(summary.text) : dim(summary.text)}`);
21875
+ }
21407
21876
  lines.push("");
21408
21877
  const slugWidth = Math.max("PROJECT".length, ...report.rows.map((row) => row.slug.length));
21409
- const tabWidth = Math.max("TAB".length, ...report.rows.map((row) => row.tab.length), ...report.orphans.map((orphan) => orphan.tab.length));
21878
+ const tabWidth = Math.max("TAB".length, ...report.rows.map((row) => tabCell(row).length), ...report.orphans.map((orphan) => orphan.tab.length));
21410
21879
  const intervalCell = (row) => row.inherited ? dim(`${row.interval} (global)`) : row.interval;
21411
21880
  lines.push(` ${"PROJECT".padEnd(slugWidth)} ${"TAB".padEnd(tabWidth)} ${"CRANK".padEnd(5)} INTERVAL`);
21412
21881
  for (const row of report.rows) {
21413
21882
  const state = row.crank === "ON" ? green("ON") : row.crank === "OFF" ? dim("OFF") : dim("\u2014");
21414
- lines.push(` ${cyan(row.slug.padEnd(slugWidth))} ${row.tab.padEnd(tabWidth)} ${state}${" ".repeat(Math.max(0, 5 - (row.crank === "ON" ? 2 : row.crank === "OFF" ? 3 : 1)))} ${intervalCell(row)}`);
21883
+ lines.push(` ${cyan(row.slug.padEnd(slugWidth))} ${tabCell(row).padEnd(tabWidth)} ${state}${" ".repeat(Math.max(0, 5 - (row.crank === "ON" ? 2 : row.crank === "OFF" ? 3 : 1)))} ${intervalCell(row)}`);
21415
21884
  }
21416
21885
  if (report.orphans.length) {
21417
21886
  lines.push("");
@@ -21426,30 +21895,74 @@ function formatCrankList(report) {
21426
21895
 
21427
21896
  // src/crank/controller.ts
21428
21897
  var LIVE_TABS_TTL_MS = 3e4;
21898
+ var LIVE_OWNERS_TTL_MS = 3e5;
21899
+ function ownerMemoReader(deps = {}) {
21900
+ const clock = deps.clock ?? Date.now;
21901
+ const read = deps.read ?? ((claimed) => liveTabsWithOwners({ claimed }));
21902
+ const listTabs = deps.tabs ?? (() => liveTabs());
21903
+ const readClaimed = () => {
21904
+ try {
21905
+ return deps.claimed?.() ?? [];
21906
+ } catch {
21907
+ return [];
21908
+ }
21909
+ };
21910
+ let memo;
21911
+ return () => {
21912
+ const now = clock();
21913
+ const tabs = listTabs();
21914
+ const claimed = readClaimed();
21915
+ const claimedSig = claimed.join("\n");
21916
+ if (memo && memo.claimed === claimedSig && now - memo.at <= LIVE_OWNERS_TTL_MS && tabs.length && tabs.every((tab) => tab.id !== void 0 && memo?.byId.has(tab.id))) {
21917
+ const known = memo.byId;
21918
+ return tabs.map((tab) => ({ ...tab, ...known.get(tab.id) }));
21919
+ }
21920
+ const full = read(claimed);
21921
+ memo = full.length && full.every((tab) => tab.id !== void 0) ? { at: now, claimed: claimedSig, byId: new Map(full.map((tab) => [tab.id, { owner: tab.owner, ownedPanes: tab.ownedPanes }])) } : void 0;
21922
+ return full;
21923
+ };
21924
+ }
21429
21925
  function createCrankController(deps) {
21430
21926
  const clock = deps.clock ?? Date.now;
21431
- const readLive = deps.live ?? (() => liveTabs());
21927
+ const readLive = deps.live ?? ownerMemoReader({ clock, claimed: () => claimedRoots(deps.store) });
21928
+ const readEngine = (nowMs) => {
21929
+ try {
21930
+ return (deps.engine ?? ((now) => readEngineStatus({ nowMs: now })))(nowMs);
21931
+ } catch {
21932
+ return unknownEngineStatus(nowMs);
21933
+ }
21934
+ };
21432
21935
  let cachedLive;
21433
21936
  const live = (fresh) => {
21434
21937
  const now = clock();
21435
21938
  if (fresh || !cachedLive || now - cachedLive.at > LIVE_TABS_TTL_MS) cachedLive = { at: now, tabs: readLive() };
21436
21939
  return cachedLive.tabs;
21437
21940
  };
21438
- const context = (fresh) => buildCrankContext(deps.project, live(fresh), storedTabKeys(deps.store));
21941
+ const context = (fresh) => {
21942
+ const keys = storedTabKeys(deps.store);
21943
+ return buildCrankContext(deps.project, live(fresh), keys, { identity: deps.identity, roots: storedTabRoots(deps.store, keys) });
21944
+ };
21439
21945
  return {
21440
21946
  slug: deps.slug,
21441
21947
  name: deps.project.name,
21442
21948
  read(nowMs = clock()) {
21443
21949
  const ctx = context(false);
21444
- return { slug: deps.slug, name: deps.project.name, resolution: ctx.resolution, snapshot: snapshotCrank(deps.store, ctx.resolution.tab, ctx.storedKeys, nowMs) };
21950
+ return {
21951
+ slug: deps.slug,
21952
+ name: deps.project.name,
21953
+ resolution: ctx.resolution,
21954
+ snapshot: snapshotCrank(deps.store, ctx.resolution.tab, ctx.storedKeys, nowMs, 5, keyScopeOf(ctx)),
21955
+ engine: readEngine(nowMs)
21956
+ };
21445
21957
  },
21446
21958
  enable: () => enableCrank(deps.store, context(true), { now: new Date(clock()) }),
21447
21959
  disable() {
21448
- disableCrank(deps.store, context(true).resolution.tab, new Date(clock()));
21960
+ const ctx = context(true);
21961
+ disableCrank(deps.store, ctx.resolution.tab, new Date(clock()), ctx.identity);
21449
21962
  },
21450
21963
  setInterval(interval) {
21451
21964
  const ctx = context(true);
21452
- setCrankInterval(deps.store, ctx.resolution.tab, ctx.storedKeys, interval, new Date(clock()));
21965
+ setCrankInterval(deps.store, ctx.resolution.tab, ctx.storedKeys, interval, new Date(clock()), { ...keyScopeOf(ctx), root: ctx.identity });
21453
21966
  }
21454
21967
  };
21455
21968
  }
@@ -21470,13 +21983,20 @@ function currentProjectNames() {
21470
21983
  names: {
21471
21984
  name: projectNameFor(handle, record3?.name),
21472
21985
  agents: projectAgentsFor(handle, record3?.agents)
21473
- }
21986
+ },
21987
+ // PJAN-201: where the project lives (its main worktree), independent of any tab name.
21988
+ identity: projectIdentity(handle.root)
21474
21989
  };
21475
21990
  }
21991
+ function contextFor(project, store) {
21992
+ const keys = storedTabKeys(store);
21993
+ const roots = storedTabRoots(store, keys);
21994
+ return buildCrankContext(project.names, liveTabsWithOwners({ claimed: Object.values(roots) }), keys, { identity: project.identity, roots });
21995
+ }
21476
21996
  function resolveCurrentTab() {
21477
21997
  const project = currentProjectNames();
21478
21998
  const store = redis();
21479
- const ctx = buildCrankContext(project.names, liveTabs(), storedTabKeys(store));
21999
+ const ctx = contextFor(project, store);
21480
22000
  return { slug: project.slug, tab: ctx.resolution.tab, storedAsIs: ctx.resolution.storedAsIs };
21481
22001
  }
21482
22002
  function registerCrankCli(program2) {
@@ -21507,12 +22027,16 @@ function registerCrankCli(program2) {
21507
22027
  role: String(value?.role ?? "")
21508
22028
  }))
21509
22029
  }));
21510
- const live = liveTabs();
21511
22030
  const storedTabs = storedTabKeys(store);
22031
+ const live = liveTabsWithOwners({ claimed: Object.values(storedTabRoots(store, storedTabs)) });
22032
+ const identityCache = /* @__PURE__ */ new Map();
21512
22033
  const report = buildCrankList({
21513
22034
  projects,
21514
22035
  live,
21515
22036
  storedTabs,
22037
+ identityOf: (repoPath) => projectIdentity(repoPath, { cache: identityCache }),
22038
+ roots: storedTabRoots(store, storedTabs),
22039
+ engine: readEngineStatus(),
21516
22040
  tabEnabled: (tab) => store.tabEnabled(tab),
21517
22041
  tabInterval: (tab) => store.tabInterval(tab),
21518
22042
  globalInterval: store.globalInterval()
@@ -21576,9 +22100,9 @@ async function runEnable(options) {
21576
22100
  }
21577
22101
  const project = currentProjectNames();
21578
22102
  const store = liveStore();
21579
- const ctx = buildCrankContext(project.names, liveTabs(), storedTabKeys(store));
22103
+ const ctx = contextFor(project, store);
21580
22104
  if (!chosen && isInteractive()) {
21581
- const snap = snapshotCrank(store, ctx.resolution.tab, ctx.storedKeys, Date.now());
22105
+ const snap = snapshotCrank(store, ctx.resolution.tab, ctx.storedKeys, Date.now(), 5, keyScopeOf(ctx));
21582
22106
  const screens = await loadCrankScreens();
21583
22107
  const picked = await screens.pickInterval({ slug: project.slug, tab: ctx.resolution.liveTab ?? ctx.resolution.tab, effective: snap.interval, inherited: snap.inherited, global: snap.global });
21584
22108
  if (!picked) {
@@ -21592,7 +22116,8 @@ async function runEnable(options) {
21592
22116
  for (const { from, to } of result2.renamed) {
21593
22117
  console.log(` ${yellow(glyph.warn)} tab renamed ${dim(glyph.dot)} ${from} ${dim(glyph.pointer)} ${bold(to)} ${dim("(old key merged into the new one and removed)")}`);
21594
22118
  }
21595
- const live = snapshotCrank(store, result2.tab, storedTabKeys(store), Date.now());
22119
+ const afterKeys = storedTabKeys(store);
22120
+ const live = snapshotCrank(store, result2.tab, afterKeys, Date.now(), 5, { owner: ctx.resolution.tabOwner, roots: storedTabRoots(store, afterKeys) });
21596
22121
  console.log(` ${dim("interval")} ${live.interval}${live.inherited ? dim(` (inherits global ${live.global})`) : ""}`);
21597
22122
  if (ctx.resolution.storedAsIs) {
21598
22123
  console.log(` ${yellow(glyph.warn)} ${dim(`no live zellij tab or zcrank key matches "${project.names.name}" \u2014 stored under the project name as-is`)}`);
@@ -21605,8 +22130,8 @@ function runDisable() {
21605
22130
  try {
21606
22131
  const project = currentProjectNames();
21607
22132
  const store = liveStore();
21608
- const ctx = buildCrankContext(project.names, liveTabs(), storedTabKeys(store));
21609
- disableCrank(store, ctx.resolution.tab);
22133
+ const ctx = contextFor(project, store);
22134
+ disableCrank(store, ctx.resolution.tab, /* @__PURE__ */ new Date(), ctx.identity);
21610
22135
  console.log(` ${dim("OFF")} ${cyan(project.slug)} ${dim(glyph.pointer)} ${bold(ctx.resolution.tab)}`);
21611
22136
  if (ctx.resolution.storedAsIs) {
21612
22137
  console.log(` ${yellow(glyph.warn)} ${dim(`no live zellij tab or zcrank key matches "${project.names.name}" \u2014 stored under the project name as-is`)}`);
@@ -21623,7 +22148,7 @@ async function runTui() {
21623
22148
  }
21624
22149
  const project = currentProjectNames();
21625
22150
  const store = liveStore();
21626
- const controller = createCrankController({ slug: project.slug, project: project.names, store });
22151
+ const controller = createCrankController({ slug: project.slug, project: project.names, store, identity: project.identity });
21627
22152
  const screens = await loadCrankScreens();
21628
22153
  await screens.runCrankDashboard(controller);
21629
22154
  } catch (error) {
@@ -21658,9 +22183,9 @@ async function promptForRuleIds(rules) {
21658
22183
  return selected;
21659
22184
  }
21660
22185
  function readJson2(path) {
21661
- if (!existsSync27(path)) return void 0;
22186
+ if (!existsSync26(path)) return void 0;
21662
22187
  try {
21663
- const parsed = JSON.parse(readFileSync30(path, "utf8"));
22188
+ const parsed = JSON.parse(readFileSync31(path, "utf8"));
21664
22189
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
21665
22190
  } catch {
21666
22191
  return void 0;
@@ -21677,8 +22202,8 @@ function packageNameToProjectName(value) {
21677
22202
  return name.replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()).trim();
21678
22203
  }
21679
22204
  function deriveProjectDefaults(targetDir) {
21680
- const manifest = readJson2(join35(targetDir, ".project.json"));
21681
- const pkg = readJson2(join35(targetDir, "package.json"));
22205
+ const manifest = readJson2(join36(targetDir, ".project.json"));
22206
+ const pkg = readJson2(join36(targetDir, "package.json"));
21682
22207
  const name = String(manifest?.project_name ?? "").trim() || packageNameToProjectName(typeof pkg?.name === "string" ? pkg.name : void 0) || packageNameToProjectName(basename11(targetDir)) || "Project";
21683
22208
  const ticketProvider = manifest?.ticket_provider && typeof manifest.ticket_provider === "object" ? manifest.ticket_provider : {};
21684
22209
  return {
@@ -21753,7 +22278,7 @@ function actionNeedsRun(plan, kind, syncMode) {
21753
22278
  if (!action || action.kind !== "project.write-manifest") return false;
21754
22279
  const next = `${JSON.stringify(action.manifest, null, 2)}
21755
22280
  `;
21756
- return !existsSync27(action.path) || readFileSync30(action.path, "utf8") !== next;
22281
+ return !existsSync26(action.path) || readFileSync31(action.path, "utf8") !== next;
21757
22282
  }
21758
22283
  if (kind === "copier.copy.commonproject") return true;
21759
22284
  if (kind === "ticket-provider.create-or-link") return plan.actions.some((action) => action.kind === kind && action.enabled);
@@ -21810,7 +22335,7 @@ async function resolveProjectInitTarget(name, options) {
21810
22335
  if (!targetDir && cwdGitRoot) {
21811
22336
  targetDir = cwdGitRoot;
21812
22337
  }
21813
- if (!targetDir && !name && existsSync27(join35(cwd, ".project.json"))) {
22338
+ if (!targetDir && !name && existsSync26(join36(cwd, ".project.json"))) {
21814
22339
  targetDir = cwd;
21815
22340
  }
21816
22341
  if (!targetDir && interactive) {
@@ -21821,10 +22346,10 @@ async function resolveProjectInitTarget(name, options) {
21821
22346
  if (!targetDir) {
21822
22347
  throw new Error("Project name or --target-dir is required when project init is not run inside a git repo or a directory carrying .project.json");
21823
22348
  }
21824
- const targetExists = existsSync27(targetDir);
22349
+ const targetExists = existsSync26(targetDir);
21825
22350
  if (targetExists && !statSync9(targetDir).isDirectory()) throw new Error(`Target path is not a directory: ${targetDir}`);
21826
22351
  const targetGitRoot = targetExists ? findGitRoot(targetDir) : void 0;
21827
- const alreadyScaffolded = targetExists && (existsSync27(join35(targetDir, ".project.json")) || existsSync27(join35(targetDir, ".copier-answers.yml")));
22352
+ const alreadyScaffolded = targetExists && (existsSync26(join36(targetDir, ".project.json")) || existsSync26(join36(targetDir, ".copier-answers.yml")));
21828
22353
  const syncMode = Boolean(
21829
22354
  targetGitRoot && resolve25(targetGitRoot) === resolve25(targetDir) || alreadyScaffolded
21830
22355
  );
@@ -22346,7 +22871,7 @@ program.command("reindex").argument("[project-id]", "Canonical project ID (defau
22346
22871
  else if (options.all) result2 = registryRequest(location, "POST", "/v1/reindex", {});
22347
22872
  else {
22348
22873
  const repo = projectId ? getProject(loadProjectRegistry(location), projectId).repo_path : resolveProject(process.cwd()).root;
22349
- result2 = registryRequest(location, "POST", "/v1/index", { manifest_path: join35(repo, ".project.json") });
22874
+ result2 = registryRequest(location, "POST", "/v1/index", { manifest_path: join36(repo, ".project.json") });
22350
22875
  }
22351
22876
  const statuses = result2.__registry_status;
22352
22877
  const stale = Object.entries(statuses ?? {}).filter(([, item]) => item.status !== "ok");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delorenj/pjangler",
3
- "version": "1.5.38",
3
+ "version": "1.5.40",
4
4
  "description": "Project subsystem bootstrapper CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",