pi-jar 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -302,6 +302,7 @@ Providers cache a prompt as an ordered prefix: tools, then the system prompt, th
302
302
  { "action": "append", "todos": [{ "content": "Handle newly requested edge case", "status": "pending" }] }
303
303
  ```
304
304
  Use `parent` with `append` to add a genuinely new subtask to active work; `start`, `done`, `open`, `edit`, `list`, `add` and `delete` remain available.
305
+ - **Todo gate** — a turn cannot end while `jar_todo` has pending or in-progress leaf tasks: pi-jar injects a hidden reminder and continues (at most 2 reminders per user message; skipped in plan/goal mode, aborted turns and subagents). Disable with `PI_JAR_TODO_GATE=off`.
305
306
  - **`jar_ask`** — structured questions: numbered options with descriptions, single or multi-select, *Type your own answer* (multi-line, paste-friendly) and *Chat about this* to discuss before choosing.
306
307
  - **`/diff`** — review what the agent (and its editing subagents) changed since the first edit to each file: files with `+/−` counts on the left, a full-path header and OMP-inspired split-first **Before/After** diff on the right (unified below 70 pane columns). Changed lines get a colored rail and soft tinted background, replaced lines highlight changed words with a stronger tint, code is syntax-highlighted for known languages, and collapsed regions are labeled `┄ N unchanged lines ┄`. `/` filters files; `t` switches unified and aligned side-by-side views (unified below 70 columns), `←`/`→` (or `[`/`]`) change the context from 0 lines to the whole file, `w` wraps long lines, and `n`/`p`, `Home`/`End` navigate. Large changes offer summary or explicit windowed review (`v`), with clearly labeled coarse fallback and existing safety limits—not arbitrary-file streaming. See [diff review](docs/DIFF-REVIEW.md). `a` accept (keep, stop tracking), `r` then `y` revert (restore the original, or remove a file it created), `A`/`R` for all. The footer shows `± N files · /diff` while anything is unreviewed; each file counts once no matter who edited it. Only `edit`/`write` changes inside the project are tracked; shell-made changes are not.
307
308
  - **Context ownership** — pi-jar does not prune prior reasoning/tool results, impose a soft context limit, or abort/compact/resume work at task boundaries. The context meter and cache diagnostics remain passive. Pi's native compaction and explicit `/compact` (including the user-selected plan approval action) remain available. Retired `contextDiet` and `contextBudget` settings are ignored and dropped on the next settings/profile save; loading does not rewrite user files.
@@ -16,6 +16,7 @@ import { renderFooterLayout, type ActivityState, type FooterActivity, type Foote
16
16
  import { openJarHistory } from "../src/history-ui.ts";
17
17
  import { GOAL_ENTRY, GoalStore } from "../src/goals.ts";
18
18
  import { GoalLoop } from "../src/goal-loop.ts";
19
+ import { registerTodoGate } from "../src/todo-gate.ts";
19
20
  import { FOOTER_FIELDS } from "../src/footer-settings.ts";
20
21
  import { defaultVisualSettings, loadVisualSettings, saveVisualSettings, type JarVisualSettings } from "../src/settings.ts";
21
22
  import { ProfileStore, PROFILE_FILE, PROFILE_ENTRY, conversationStarted, pinnedProfileId } from "../src/profiles.ts";
@@ -358,6 +359,10 @@ export default function piJar(pi: ExtensionAPI): void {
358
359
  completed: () => composer.flash("complete", 4000)
359
360
  });
360
361
  if (!delegatedChild) goalLoop.register();
362
+ if (!delegatedChild) registerTodoGate(pi, {
363
+ todos: () => todos, suspended: () => planMode.isEnabled() || !!goals?.isActive(),
364
+ enabled: () => process.env.PI_JAR_TODO_GATE !== "off"
365
+ });
361
366
  planMode.setOnEnter((ctx) => goalLoop.pause(ctx, "plan mode is on"));
362
367
  const suggestions = new SuggestionState();
363
368
  composer.attachSuggestions(suggestions);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jar",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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);
@@ -0,0 +1,45 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { Todo, TodoStore } from "./tasks.ts";
3
+
4
+ export const TODO_GATE_TYPE = "pi-jar.todo-gate";
5
+ /** Automatic reminders per user message; keeps a stuck model from looping forever. */
6
+ export const TODO_GATE_MAX_NUDGES = 2;
7
+
8
+ export interface TodoGateOptions {
9
+ todos: () => TodoStore | undefined;
10
+ /** True while plan mode or goal mode owns turn continuation. */
11
+ suspended: () => boolean;
12
+ enabled?: () => boolean;
13
+ }
14
+
15
+ /** Leaf tasks that are still open (parents are derived from their subtasks). */
16
+ export function openLeaves(items: readonly Todo[]): Todo[] {
17
+ const parents = new Set(items.map((item) => item.parentId).filter(Boolean));
18
+ return items.filter((item) => !item.done && !parents.has(item.id));
19
+ }
20
+
21
+ export function todoGateMessage(open: readonly Todo[]): string {
22
+ const lines = open.slice(0, 10).map((item) => `${item.status === "in_progress" ? "[~]" : "[ ]"} ${item.title} (${item.id})`);
23
+ return [
24
+ "[PI-JAR TODO CHECK] jar_todo still has unfinished tasks:",
25
+ ...lines,
26
+ open.length > lines.length ? `… and ${open.length - lines.length} more` : "",
27
+ "Before ending the turn, sync jar_todo: mark verified work completed, or continue with the open task. If a task is obsolete or blocked, remove it or say why."
28
+ ].filter(Boolean).join("\n");
29
+ }
30
+
31
+ /** Refuses to end a turn while jar_todo has pending or in-progress tasks (bounded reminders). */
32
+ export function registerTodoGate(pi: ExtensionAPI, options: TodoGateOptions): void {
33
+ let nudges = 0;
34
+ pi.on("input", (event) => { if (event.source === "interactive") nudges = 0; });
35
+ pi.on("session_start", () => { nudges = 0; });
36
+ pi.on("agent_before_settle", (event) => {
37
+ if (event.outcome !== "completed" || event.continue || event.context?.canContinue === false) return;
38
+ if (options.enabled && !options.enabled()) return;
39
+ if (options.suspended() || nudges >= TODO_GATE_MAX_NUDGES) return;
40
+ const open = openLeaves(options.todos()?.all() ?? []);
41
+ if (!open.length) return;
42
+ nudges++;
43
+ return { entries: [{ type: "custom_message" as const, customType: TODO_GATE_TYPE, display: false, content: todoGateMessage(open) }], continue: true };
44
+ });
45
+ }