Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 66 additions & 40 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
resetCadenceState,
} from "./reminder-cadence.js";
import { TaskStore } from "./task-store.js";
import { loadTasksConfig } from "./tasks-config.js";
import { loadGlobalTasksConfig, loadTasksConfig } from "./tasks-config.js";
import type { Task } from "./types.js";
import { openSettingsMenu } from "./ui/settings-menu.js";
import { TaskWidget, type UICtx } from "./ui/task-widget.js";
Expand Down Expand Up @@ -125,28 +125,38 @@ function buildSystemReminder(tasks: Task[]): string {
}

export default function (pi: ExtensionAPI) {
// Initialize store and config
const cfg = loadTasksConfig();
// Project overrides require ExtensionContext.cwd, which is unavailable while
// the extension factory runs. Start with global defaults, then merge the
// active workspace's overrides on the first context-bearing event.
const cfg = loadGlobalTasksConfig();
const piTasks = process.env.PI_TASKS;
const taskScope = cfg.taskScope ?? "session";

/** Resolve the task store path from env/config (without session ID). */
function resolveStorePath(sessionId?: string): string | undefined {
if (piTasks === "off") return undefined;
if (piTasks?.startsWith("/")) return piTasks;
if (piTasks?.startsWith(".")) return resolve(piTasks);
if (piTasks) return piTasks;
if (taskScope === "memory") return undefined;
let taskScope = cfg.taskScope ?? "session";

/** Resolve both the backing path and a stable identity for the active store. */
function resolveStoreTarget(cwd?: string, sessionId?: string): { key: string; path?: string } {
if (piTasks === "off") return { key: "memory:env" };
if (piTasks?.startsWith("/")) return { key: `path:${piTasks}`, path: piTasks };
if (piTasks?.startsWith(".")) {
const path = cwd ? resolve(cwd, piTasks) : undefined;
return path ? { key: `path:${path}`, path } : { key: "pending:relative" };
}
if (piTasks) return { key: `named:${piTasks}`, path: piTasks };
if (taskScope === "memory") return { key: "memory:config" };
if (!cwd) return { key: "pending:workspace" };
if (taskScope === "session" && sessionId) {
return join(process.cwd(), ".pi", "tasks", `tasks-${sessionId}.json`);
const path = join(cwd, ".pi", "tasks", `tasks-${sessionId}.json`);
return { key: `path:${path}`, path };
}
if (taskScope === "session") return undefined; // no session ID yet, start in-memory
return join(process.cwd(), ".pi", "tasks", "tasks.json");
if (taskScope === "session") return { key: "pending:session" };
const path = join(cwd, ".pi", "tasks", "tasks.json");
return { key: `path:${path}`, path };
}

// For project scope (or env override), create store immediately.
// For session scope, start with in-memory and upgrade once we have the session ID.
let store = new TaskStore(resolveStorePath());
// Project and relative paths need ExtensionContext.cwd, which is unavailable
// while the extension factory runs. Absolute and named PI_TASKS overrides can
// still be opened immediately; all other stores start in memory.
let storeTarget = resolveStoreTarget();
let store = new TaskStore(storeTarget.path);
const tracker = new ProcessTracker();
const widget = new TaskWidget(store, cfg);

Expand Down Expand Up @@ -333,26 +343,40 @@ export default function (pi: ExtensionAPI) {
widget.update();
});

// ── Session-scoped store upgrade ──
// For session scope, the store starts in-memory (no session ID at init time).
// Upgrade to file-backed on first context arrival (turn_start, before_agent_start,
// or tool_execution_start — whichever fires first).
let storeUpgraded = false;
// ── Context-scoped store initialization ──
// Project paths cannot be resolved until an ExtensionContext is available.
// Initialize on the first context-bearing event and reinitialize when a host
// switches this extension instance to a session in another workspace.
let configuredCwd: string | undefined;
let persistedTasksShown = false;
let agentsReattached = false;
function upgradeStoreIfNeeded(ctx: ExtensionContext) {
if (storeUpgraded) return;
if (taskScope === "session" && !piTasks) {
// `pi --no-session` mints a session ID but never a session file. Keying off the
// ID alone would write tasks-<id>.json for a session that can never be resumed
// and is orphaned the moment pi exits: if pi is not persisting the conversation,
// don't persist the task list either.
const sessionId = ctx.sessionManager.getSessionFile() ? ctx.sessionManager.getSessionId() : undefined;
const path = sessionId ? resolveStorePath(sessionId) : undefined;
store = new TaskStore(path);
function initializeStoreForContext(ctx: ExtensionContext, reloadConfig = false) {
// Keep the config object identity stable because the widget and auto-clear
// manager retain references to it, but replace every value so overrides
// from a previous workspace cannot leak into the next one.
if (reloadConfig || configuredCwd !== ctx.cwd) {
for (const key of Object.keys(cfg) as (keyof typeof cfg)[]) delete cfg[key];
Object.assign(cfg, loadTasksConfig(ctx.cwd));
taskScope = cfg.taskScope ?? "session";
}

// `pi --no-session` mints a session ID but never a session file. Keying off the
// ID alone would write tasks-<id>.json for a session that can never be resumed
// and is orphaned the moment pi exits: if pi is not persisting the conversation,
// don't persist the task list either.
const sessionId = taskScope === "session" && !piTasks && ctx.sessionManager.getSessionFile()
? ctx.sessionManager.getSessionId()
: undefined;
const nextTarget = resolveStoreTarget(ctx.cwd, sessionId);
if (nextTarget.key !== storeTarget.key) {
store = new TaskStore(nextTarget.path);
widget.setStore(store);
storeTarget = nextTarget;
// The new store owns a different task list, so the agent map has to be
// rebuilt from it rather than kept from the previous one.
agentsReattached = false;
}
storeUpgraded = true;
configuredCwd = ctx.cwd;
}

/** Re-link persisted in-progress tasks to the subagents still running for them.
Expand Down Expand Up @@ -406,7 +430,7 @@ export default function (pi: ExtensionAPI) {
onTurnStart(cadence);
latestCtx = ctx;
widget.setUICtx(ctx.ui as UICtx);
upgradeStoreIfNeeded(ctx);
initializeStoreForContext(ctx);
if (autoClear.onTurnStart(cadence.currentTurn)) widget.update();
});

Expand Down Expand Up @@ -501,7 +525,6 @@ export default function (pi: ExtensionAPI) {
// copy. Snapshot before the store re-points to the new (empty) session file.
const forkSeed = reason === "fork" ? store.snapshot() : undefined;
if (isSwitch) {
storeUpgraded = false;
persistedTasksShown = false;
agentsReattached = false;
// Task IDs restart at 1 in every session, so a mapping held over from the
Expand All @@ -516,7 +539,7 @@ export default function (pi: ExtensionAPI) {
}
}

upgradeStoreIfNeeded(ctx); // re-points a session store once storeUpgraded is cleared
initializeStoreForContext(ctx, true);
if (forkSeed?.tasks.length) store.seed(forkSeed); // carry the parent's tasks into the fork
reattachAgents(); // subagents outlive a reload; relink them before events arrive
// resume/reload/fork keep tasks; startup/new auto-clear an all-completed list.
Expand All @@ -533,7 +556,7 @@ export default function (pi: ExtensionAPI) {
pi.on("before_agent_start", async (_event, ctx) => {
latestCtx = ctx;
widget.setUICtx(ctx.ui as UICtx);
upgradeStoreIfNeeded(ctx);
initializeStoreForContext(ctx);
reattachAgents();
showPersistedTasks();
if (pendingWarning) {
Expand All @@ -546,7 +569,7 @@ export default function (pi: ExtensionAPI) {
pi.on("tool_execution_start", async (_event, ctx) => {
latestCtx = ctx;
widget.setUICtx(ctx.ui as UICtx);
upgradeStoreIfNeeded(ctx);
initializeStoreForContext(ctx);
widget.update();
});

Expand Down Expand Up @@ -1140,6 +1163,9 @@ Set up task dependencies:
pi.registerCommand("tasks", {
description: "Manage tasks — view, create, clear completed",
handler: async (_args: string, ctx: ExtensionCommandContext) => {
latestCtx = ctx;
widget.setUICtx(ctx.ui as UICtx);
initializeStoreForContext(ctx);
const ui = ctx.ui;

const mainMenu = async (): Promise<void> => {
Expand Down Expand Up @@ -1245,7 +1271,7 @@ Set up task dependencies:
};

const settingsMenu = (): Promise<void> =>
openSettingsMenu(ui, cfg, mainMenu, AUTO_CLEAR_DELAY);
openSettingsMenu(ui, cfg, mainMenu, AUTO_CLEAR_DELAY, ctx.cwd);

const createTask = async (): Promise<void> => {
const subject = await ui.input("Task subject");
Expand Down
12 changes: 8 additions & 4 deletions src/tasks-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,19 @@ function readTasksConfig(configPath: string): TasksConfig {
}
}

export function loadTasksConfig(cwd = process.cwd(), agentDir = getAgentDir()): TasksConfig {
const globalConfig = readTasksConfig(join(agentDir, "tasks-config.json"));
export function loadGlobalTasksConfig(agentDir = getAgentDir()): TasksConfig {
return readTasksConfig(join(agentDir, "tasks-config.json"));
}

export function loadTasksConfig(cwd: string, agentDir = getAgentDir()): TasksConfig {
const globalConfig = loadGlobalTasksConfig(agentDir);
const projectConfig = readTasksConfig(join(cwd, ".pi", "tasks-config.json"));
return { ...globalConfig, ...projectConfig };
}

export function saveTasksConfig(config: TasksConfig, cwd = process.cwd(), agentDir = getAgentDir()): void {
export function saveTasksConfig(config: TasksConfig, cwd: string, agentDir = getAgentDir()): void {
const configPath = join(cwd, ".pi", "tasks-config.json");
const globalConfig = readTasksConfig(join(agentDir, "tasks-config.json"));
const globalConfig = loadGlobalTasksConfig(agentDir);
const projectOverrides = Object.fromEntries(Object.entries(config).filter(([key, value]) => globalConfig[key as keyof TasksConfig] !== value));
mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(configPath, JSON.stringify(projectOverrides, null, 2));
Expand Down
15 changes: 8 additions & 7 deletions src/ui/settings-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export async function openSettingsMenu(
cfg: TasksConfig,
onBack: () => Promise<void>,
clearDelayTurns: number,
cwd: string,
): Promise<void> {
await ui.custom((_tui, theme, _kb, done) => {
const items: SettingItem[] = [
Expand Down Expand Up @@ -105,31 +106,31 @@ export async function openSettingsMenu(
/* onChange */ (id, newValue) => {
if (id === "autoCascade") {
cfg.autoCascade = newValue === "on";
saveTasksConfig(cfg);
saveTasksConfig(cfg, cwd);
}
if (id === "taskScope") {
cfg.taskScope = newValue as "memory" | "session" | "project";
saveTasksConfig(cfg);
saveTasksConfig(cfg, cwd);
}
if (id === "autoClearCompleted") {
cfg.autoClearCompleted = newValue as TasksConfig["autoClearCompleted"];
saveTasksConfig(cfg);
saveTasksConfig(cfg, cwd);
}
if (id === "showAll") {
cfg.showAll = newValue === "on";
saveTasksConfig(cfg);
saveTasksConfig(cfg, cwd);
}
if (id === "maxVisible") {
cfg.maxVisible = Number(newValue);
saveTasksConfig(cfg);
saveTasksConfig(cfg, cwd);
}
if (id === "sortOrder") {
cfg.sortOrder = newValue as TasksConfig["sortOrder"];
saveTasksConfig(cfg);
saveTasksConfig(cfg, cwd);
}
if (id === "hiddenAt") {
cfg.hiddenAt = newValue as "top" | "bottom";
saveTasksConfig(cfg);
saveTasksConfig(cfg, cwd);
}
},
/* onCancel */ () => done(undefined),
Expand Down
1 change: 1 addition & 0 deletions test/agent-reattach.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { flush, installSubagentsMock, mockPi, mockSessionCtx } from "./helpers/m
// these tests exercise.
const config = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
vi.mock("../src/tasks-config.js", () => ({
loadGlobalTasksConfig: () => ({ ...config.current }),
loadTasksConfig: () => ({ ...config.current }),
saveTasksConfig: () => {},
}));
Expand Down
1 change: 1 addition & 0 deletions test/auto-cascade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { flush, installSubagentsMock, mockCtx, mockPi } from "./helpers/mock-pi.
// developer's global <agentDir>/tasks-config.json leak into the results.
const config = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
vi.mock("../src/tasks-config.js", () => ({
loadGlobalTasksConfig: () => ({ ...config.current }),
loadTasksConfig: () => ({ ...config.current }),
saveTasksConfig: () => {},
}));
Expand Down
8 changes: 5 additions & 3 deletions test/helpers/mock-pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,10 @@ export function mockPi() {
export type MockPi = ReturnType<typeof mockPi>;

/** Minimal mock ExtensionContext. */
export function mockCtx() {
export function mockCtx(cwd = process.cwd()) {
return {
// Task paths resolve against the session workspace, not the host process cwd.
cwd,
model: { id: "test-model", name: "Test" },
modelRegistry: {},
ui: {
Expand All @@ -107,10 +109,10 @@ export function mockCtx() {
* persisting (`pi --no-session`, `SessionManager.inMemory()`) reports a session ID
* but no file. Pass `{ persisted: false }` for the latter.
*/
export function mockSessionCtx(sessionId: string, opts?: { persisted?: boolean }) {
export function mockSessionCtx(sessionId: string, opts?: { persisted?: boolean; cwd?: string }) {
const sessionFile = opts?.persisted === false ? undefined : `/sessions/${sessionId}.jsonl`;
return {
...mockCtx(),
...mockCtx(opts?.cwd),
sessionManager: {
getSessionId: vi.fn(() => sessionId),
getSessionFile: vi.fn(() => sessionFile),
Expand Down
7 changes: 6 additions & 1 deletion test/stale-task-reminder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ afterEach(() => { delete process.env.PI_TASKS; });

function mockCtx() {
return {
cwd: process.cwd(),
model: { id: "test-model", name: "Test" },
modelRegistry: {},
ui: {
Expand Down Expand Up @@ -50,14 +51,18 @@ function mockPi() {
async executeTool(name: string, params: any, ctx = mockCtx()) {
const tool = tools.get(name);
if (!tool) throw new Error(`Tool ${name} not registered`);
await this.fireLifecycle("tool_execution_start", { toolName: name }, ctx);
const result = await tool.execute("call-1", params, undefined, undefined, ctx);
await this.fireLifecycle("tool_result", { toolName: name });
return result;
},
async runCommand(name: string, ui: any) {
const cmd = commands.get(name);
if (!cmd) throw new Error(`Command ${name} not registered`);
return cmd.handler("", { ui });
// ExtensionCommandContext extends ExtensionContext: it carries cwd and the same
// ui surface, plus the prompt helpers the command drives.
const ctx = mockCtx();
return cmd.handler("", { ...ctx, ui: { ...ctx.ui, ...ui } });
},
async fireLifecycle(event: string, ...args: any[]) {
let lastResult: any;
Expand Down
Loading
Loading