pi-jar 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/delegate.ts +59 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jar",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "A warm, role-aware Pi terminal workspace with switchable profiles, animated UI, plan and goal workflows, tasks, change review, model roles, background shells and subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/delegate.ts CHANGED
@@ -1361,7 +1361,24 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1361
1361
  const value = options.getMaxSubagents?.() ?? MAX_DELEGATES;
1362
1362
  return SUBAGENT_LIMIT_CHOICES.includes(value as never) ? value : MAX_DELEGATES;
1363
1363
  };
1364
- pi.on?.("session_start", (_event, ctx) => { controllerContext = ctx; });
1364
+ let sessionGeneration = 0;
1365
+ let sessionActive = true;
1366
+ const cancelUpdates = new Set<() => void>();
1367
+ const resetSession = () => {
1368
+ sessionGeneration++;
1369
+ controllerContext = undefined;
1370
+ clearTimeout(wakeTimer);
1371
+ wakeTimer = undefined;
1372
+ watched.clear();
1373
+ pendingEvents.clear();
1374
+ pendingControls.clear();
1375
+ moderatorActive = false;
1376
+ moderatorInterrupted = false;
1377
+ for (const cancel of cancelUpdates) cancel();
1378
+ cancelUpdates.clear();
1379
+ };
1380
+ pi.on?.("session_shutdown", () => { sessionActive = false; resetSession(); });
1381
+ pi.on?.("session_start", (_event, ctx) => { resetSession(); sessionActive = true; controllerContext = ctx; });
1365
1382
  const roleCandidates = (role: string) => {
1366
1383
  const manager = roles as ModelRoleManager & { resolveCandidates?: (name: string) => ReturnType<ModelRoleManager["resolveCandidates"]> };
1367
1384
  if (typeof manager.resolveCandidates === "function") return manager.resolveCandidates(role);
@@ -1451,10 +1468,10 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1451
1468
  };
1452
1469
  };
1453
1470
  const scheduleWake = () => {
1454
- if (moderatorActive || moderatorInterrupted || wakeTimer || !pendingEvents.size) return;
1471
+ if (!sessionActive || moderatorActive || moderatorInterrupted || wakeTimer || !pendingEvents.size) return;
1455
1472
  wakeTimer = setTimeout(() => {
1456
1473
  wakeTimer = undefined;
1457
- if (moderatorActive || moderatorInterrupted) return;
1474
+ if (!sessionActive || moderatorActive || moderatorInterrupted) return;
1458
1475
  const message = takeEvents();
1459
1476
  if (!message) return;
1460
1477
  try { pi.sendMessage?.(message, { triggerTurn: true }); }
@@ -1462,7 +1479,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1462
1479
  }, NOTIFY_COALESCE_MS);
1463
1480
  wakeTimer.unref?.();
1464
1481
  };
1465
- const queueEvent = (id: string, event: SubagentEvent) => { pendingEvents.set(id, event); scheduleWake(); };
1482
+ const queueEvent = (id: string, event: SubagentEvent) => {
1483
+ if (!sessionActive || registry.get(event.record.key) !== event.record) return;
1484
+ pendingEvents.set(id, event);
1485
+ scheduleWake();
1486
+ };
1466
1487
  const boundary = (event: { outcome?: string; context?: { canContinue?: boolean } }) => {
1467
1488
  if (event.outcome !== "completed" || event.context?.canContinue === false) return;
1468
1489
  const message = takeEvents();
@@ -1506,6 +1527,9 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1506
1527
  // Internal council launches use session context, not tool-only nested execution APIs.
1507
1528
  async execute(_id, params, signal, onUpdate, ctx: ExtensionContext) {
1508
1529
  controllerContext = ctx;
1530
+ const generation = sessionGeneration;
1531
+ const currentSession = () => sessionActive && generation === sessionGeneration;
1532
+ const parentCwd = ctx.cwd;
1509
1533
  const requested = params.tasks;
1510
1534
  const parentToolNames = (): string[] | undefined => {
1511
1535
  try {
@@ -1595,6 +1619,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1595
1619
  }
1596
1620
  }
1597
1621
  }
1622
+ if (!currentSession()) return { content: [{ type: "text", text: "Subagent launch cancelled: session changed." }], details: { runs: [], write: false }, isError: true };
1598
1623
  const worktreeTasks = requested.filter((item) => (item.mode ?? params.mode ?? (params.write === true ? "direct" : "scout")) === "worktree").length;
1599
1624
  const recoveryBlock = worktreeTasks ? worktreeAdmission(registry, worktreeTasks) : undefined;
1600
1625
  if (recoveryBlock) return { content: [{ type: "text", text: recoveryBlock }], details: { runs: [], write: false }, isError: true };
@@ -1641,9 +1666,10 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1641
1666
  catch (error) { console.error("pi-jar: direct subagent edits will not join /diff", error); }
1642
1667
  }
1643
1668
  const edited = (path: string) => {
1669
+ if (!currentSession()) return;
1644
1670
  const tracker = options.changes?.();
1645
1671
  if (!baselines || !tracker) return;
1646
- const absolute = isAbsolute(path) ? resolve(path) : resolve(ctx.cwd, path);
1672
+ const absolute = isAbsolute(path) ? resolve(path) : resolve(parentCwd, path);
1647
1673
  const baseline = readChildBaseline(baselines, absolute);
1648
1674
  if (baseline !== undefined && tracker.adopt(absolute, baseline)) options.changed?.();
1649
1675
  };
@@ -1651,19 +1677,20 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1651
1677
  /** Last footer state per record: child streams fire many updates, the status changes rarely. */
1652
1678
  const published = new Map<string, string>();
1653
1679
  const publish = () => {
1654
- if (!ctx.hasUI) return;
1655
- for (const { key, run } of records) {
1656
- const state = run.state === "done" || run.state === "failed" || run.state === "stopped" ? ""
1657
- : run.state === "queued" || run.state === "paused" ? "waiting" : run.state === "idle" || run.state === "hibernated" ? "idle" : "working";
1658
- if (published.get(key) === state) continue;
1659
- published.set(key, state);
1660
- try {
1680
+ if (!currentSession()) return;
1681
+ try {
1682
+ if (!ctx.hasUI) return;
1683
+ for (const { key, run } of records) {
1684
+ const state = run.state === "done" || run.state === "failed" || run.state === "stopped" ? ""
1685
+ : run.state === "queued" || run.state === "paused" ? "waiting" : run.state === "idle" || run.state === "hibernated" ? "idle" : "working";
1686
+ if (published.get(key) === state) continue;
1687
+ published.set(key, state);
1661
1688
  ctx.ui.setStatus(`${ROLE_PREFIX}${key}`, !state ? undefined : JSON.stringify({
1662
1689
  name: run.name, label: run.name.slice(0, 12), state,
1663
1690
  task: cleanText(run.task, 60), expiresAt: Date.now() + 24 * 60 * 60_000
1664
1691
  }));
1665
- } catch { /* status is decoration */ }
1666
- }
1692
+ }
1693
+ } catch { /* status is decoration; even hasUI can throw on an expired context */ }
1667
1694
  };
1668
1695
  const details = (includeOutput = true) => ({
1669
1696
  batch: id,
@@ -1673,18 +1700,29 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1673
1700
  let updateTimer: ReturnType<typeof setTimeout> | undefined;
1674
1701
  let lastUpdateAt = 0;
1675
1702
  let toolReturned = false;
1676
- const emitUpdate = () => {
1703
+ const cancelUpdate = () => {
1704
+ if (updateTimer) clearTimeout(updateTimer);
1677
1705
  updateTimer = undefined;
1706
+ cancelUpdates.delete(cancelUpdate);
1707
+ };
1708
+ const emitUpdate = () => {
1709
+ cancelUpdate();
1710
+ if (!currentSession()) return;
1678
1711
  lastUpdateAt = Date.now();
1679
1712
  publish();
1680
1713
  onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}${run.activity ? " · " + run.activity : ""}`).join("\n") }], details: details(false) });
1681
1714
  };
1682
1715
  const update = () => {
1716
+ if (!currentSession()) return;
1683
1717
  registry.notify();
1684
1718
  if (toolReturned || !onUpdate) { publish(); return; }
1685
1719
  const wait = Math.max(0, LIVE_UPDATE_MS - (Date.now() - lastUpdateAt));
1686
1720
  if (wait === 0) { if (updateTimer) clearTimeout(updateTimer); emitUpdate(); return; }
1687
- if (!updateTimer) { updateTimer = setTimeout(emitUpdate, wait); updateTimer.unref?.(); }
1721
+ if (!updateTimer) {
1722
+ cancelUpdates.add(cancelUpdate);
1723
+ updateTimer = setTimeout(emitUpdate, wait);
1724
+ updateTimer.unref?.();
1725
+ }
1688
1726
  };
1689
1727
  const flushUpdate = () => {
1690
1728
  if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; }
@@ -1736,7 +1774,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1736
1774
  failBeforeStart(run, "fork/worktree mode requires a persisted parent Pi session");
1737
1775
  return failedHandle();
1738
1776
  }
1739
- let cwd = ctx.cwd;
1777
+ let cwd = parentCwd;
1740
1778
  let session: DelegateSessionOptions | undefined;
1741
1779
  const env: Record<string, string> = { [SUBAGENT_KEY_ENV]: key, [SUBAGENT_NAME_ENV]: run.name };
1742
1780
  const effectiveTools = toolsByRun.get(run.index) ?? (run.mode === "worktree" ? WORKTREE_TOOLS : run.mode === "direct" ? undefined : READ_ONLY_TOOLS);
@@ -1759,7 +1797,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1759
1797
  }
1760
1798
  if (run.mode === "worktree") {
1761
1799
  try {
1762
- const worktree = await createDelegateWorktree(ctx.cwd, controllers[index]!.signal);
1800
+ const worktree = await createDelegateWorktree(parentCwd, controllers[index]!.signal);
1763
1801
  worktrees.set(run.index, worktree);
1764
1802
  run.workspace = worktree.root;
1765
1803
  cwd = worktree.cwd;
@@ -1802,7 +1840,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1802
1840
  void handle.closed.then(async () => {
1803
1841
  // The agent ended for good (hibernation never resolves closed): drop its private session and
1804
1842
  // discussion identity. An unreconciled worktree outlives it as lightweight recovery state.
1805
- retireDiscussion(key);
1843
+ if (currentSession()) retireDiscussion(key);
1806
1844
  await cleanupPrivate(run.index, dropsWorktree(index) || run.mode !== "worktree");
1807
1845
  if (worktrees.has(run.index) && !stopRequested.has(index)) releaseForRecovery(run);
1808
1846
  update();
@@ -1852,8 +1890,8 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1852
1890
  catch (error) { run.error = "could not inspect isolated changes: " + cleanText(String(error), 240); }
1853
1891
  if (changed.length && !run.error) {
1854
1892
  try {
1855
- run.appliedFiles = await applyDelegateWorktree(worktree, ctx.cwd, changed, options.changes?.(), controllers[index]!.signal);
1856
- if (run.appliedFiles.length) {
1893
+ run.appliedFiles = await applyDelegateWorktree(worktree, parentCwd, changed, options.changes?.(), controllers[index]!.signal);
1894
+ if (run.appliedFiles.length && currentSession()) {
1857
1895
  try { options.changed?.(); } catch (error) { console.error("pi-jar: could not refresh parent change UI", error); }
1858
1896
  }
1859
1897
  await disposeDelegateWorktree(worktree);