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 +1 -0
- package/extensions/index.ts +5 -0
- package/package.json +1 -1
- package/src/delegate.ts +59 -21
- package/src/todo-gate.ts +45 -0
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.
|
package/extensions/index.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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) => {
|
|
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(
|
|
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 (!
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
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
|
-
}
|
|
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
|
|
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) {
|
|
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 =
|
|
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(
|
|
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,
|
|
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);
|
package/src/todo-gate.ts
ADDED
|
@@ -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
|
+
}
|