pi-orchestrator-config 4.2.0 → 4.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 (100) hide show
  1. package/README.md +76 -67
  2. package/agents/code-reviewer-docs.md +11 -1
  3. package/agents/code-reviewer-guidelines.md +11 -1
  4. package/agents/code-reviewer-quality.md +11 -1
  5. package/agents/code-reviewer-security.md +11 -1
  6. package/agents/code-reviewer-spec.md +11 -1
  7. package/agents/git-expert.md +42 -3
  8. package/agents/github-expert.md +11 -0
  9. package/extensions/acpx-provider/runtime-models.ts +18 -6
  10. package/extensions/cli-provider/agents/claude.ts +3 -2
  11. package/extensions/cli-provider/agents/cursor.ts +3 -5
  12. package/extensions/cli-provider/agents/gemini.ts +3 -2
  13. package/extensions/cli-provider/discover.ts +7 -7
  14. package/extensions/cli-provider/index.ts +5 -0
  15. package/extensions/cli-provider/runtime-models.ts +18 -6
  16. package/extensions/cli-provider/session-reaper.ts +5 -7
  17. package/extensions/cli-provider/sessions.ts +4 -0
  18. package/extensions/cli-provider/shared/discover-cache.ts +17 -176
  19. package/extensions/coms/coms-p2p.ts +1719 -423
  20. package/extensions/coms/coms-shared.ts +261 -39
  21. package/extensions/coms/coms-wrapper.ts +100 -62
  22. package/extensions/coms/index.ts +8 -4
  23. package/extensions/coms/mixed-turn.ts +26 -0
  24. package/extensions/coms/probe-socket.ts +42 -0
  25. package/extensions/coms/themeMap.ts +0 -1
  26. package/extensions/image-gen/image-gen.ts +19 -1
  27. package/extensions/orchestrator/async-agents.ts +563 -102
  28. package/extensions/orchestrator/async-capability.ts +8 -8
  29. package/extensions/orchestrator/cron.ts +15 -9
  30. package/extensions/orchestrator/dreaming.ts +32 -25
  31. package/extensions/orchestrator/enforcement-helpers.ts +459 -17
  32. package/extensions/orchestrator/enforcement-rules.ts +5 -4
  33. package/extensions/orchestrator/enforcement.ts +80 -27
  34. package/extensions/orchestrator/extended-autocomplete.ts +7 -0
  35. package/extensions/orchestrator/git-helpers.ts +2 -2
  36. package/extensions/orchestrator/index.ts +78 -10
  37. package/extensions/orchestrator/markdown-transformer.ts +313 -0
  38. package/extensions/orchestrator/memory-embeddings.ts +36 -34
  39. package/extensions/orchestrator/memory-provenance.ts +29 -25
  40. package/extensions/orchestrator/memory-scoring.ts +23 -18
  41. package/extensions/orchestrator/memory-tools.ts +10 -3
  42. package/extensions/orchestrator/overlay-dashboard.ts +17 -5
  43. package/extensions/orchestrator/parse-model-override.ts +29 -0
  44. package/extensions/orchestrator/pi-config-review-state.ts +57 -33
  45. package/extensions/orchestrator/project-settings.ts +333 -127
  46. package/extensions/orchestrator/resolve-agent-model.ts +24 -13
  47. package/extensions/orchestrator/review-porcelain.ts +4 -0
  48. package/extensions/orchestrator/review-ui.ts +63 -15
  49. package/extensions/orchestrator/rule-placeholders.ts +411 -2
  50. package/extensions/orchestrator/rules.ts +80 -132
  51. package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
  52. package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
  53. package/extensions/orchestrator/settings-tui.ts +636 -0
  54. package/extensions/orchestrator/state-jsonl.ts +431 -0
  55. package/extensions/orchestrator/subagent-tool.ts +157 -59
  56. package/extensions/orchestrator/sync-limit.ts +23 -0
  57. package/extensions/orchestrator/task-lifecycle.ts +77 -67
  58. package/extensions/orchestrator/utils.ts +8 -5
  59. package/extensions/pidash/pidash.ts +193 -156
  60. package/extensions/pidiff/pidiff.ts +96 -38
  61. package/extensions/pidiff/spawn-lock.ts +62 -0
  62. package/extensions/pitasks/index.ts +430 -0
  63. package/extensions/pitasks/reminders.ts +37 -0
  64. package/extensions/pitasks/task-store.ts +467 -0
  65. package/extensions/pitasks/task-tools.ts +375 -0
  66. package/extensions/pitasks/task-widget.ts +181 -0
  67. package/extensions/pitasks/types.ts +38 -0
  68. package/extensions/providers/acpx-driver.ts +12 -2
  69. package/extensions/providers/built-in-drivers.ts +6 -6
  70. package/extensions/providers/claude-driver.ts +1 -1
  71. package/extensions/providers/cursor-cli-driver.ts +1 -1
  72. package/extensions/providers/gemini-driver.ts +1 -1
  73. package/extensions/providers/index.ts +144 -26
  74. package/extensions/providers/initialized-guard.ts +27 -0
  75. package/extensions/providers/restore-default-model.ts +596 -0
  76. package/extensions/providers/session-shutdown.ts +39 -0
  77. package/extensions/shared/coms-active.ts +23 -0
  78. package/extensions/shared/create-runtime-provider.ts +2 -0
  79. package/extensions/shared/daemon-manager.ts +0 -8
  80. package/extensions/shared/file-logger.ts +132 -25
  81. package/extensions/shared/logger.ts +50 -0
  82. package/extensions/shared/models-dev.ts +421 -0
  83. package/extensions/shared/oneshot.ts +116 -0
  84. package/extensions/shared/resolve-binary.ts +191 -0
  85. package/extensions/shared/types.ts +3 -0
  86. package/package.json +9 -3
  87. package/prompts/coderabbit-rate-limit.md +2 -0
  88. package/rules/00-orchestrator-core.md +3 -0
  89. package/rules/10-agent-routing.md +12 -1
  90. package/rules/20-code-review-loop.md +52 -25
  91. package/rules/30-prompt-templates.md +4 -0
  92. package/rules/35-memory.md +3 -3
  93. package/rules/40-critical-rules.md +3 -0
  94. package/rules/55-coms-protocol.md +84 -18
  95. package/rules/60-task-tracking.md +2 -0
  96. package/settings-keys.json +563 -0
  97. package/templates/coms-feature-manager-prompt.md +4 -6
  98. package/extensions/coms/coms-net-server.ts +0 -1642
  99. package/extensions/coms/coms-net-wrapper.ts +0 -521
  100. package/extensions/coms/coms-net.ts +0 -1589
@@ -0,0 +1,430 @@
1
+ /**
2
+ * extensions/pitasks/index.ts — Owned task management system.
3
+ * Based on @tintinweb/pi-tasks (MIT license).
4
+ * Removed: TaskExecute, auto-cascade, subagent RPC, reminder cadence.
5
+ * Added: store export for coms direct access.
6
+ */
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import { homedir } from "node:os";
9
+ import { join, resolve } from "node:path";
10
+ import { TaskStore } from "./task-store.js";
11
+ import { TaskWidget } from "./task-widget.js";
12
+ import { registerTaskTools } from "./task-tools.js";
13
+ import { createLogger } from "../shared/logger.js";
14
+ import { getSetting } from "../orchestrator/project-settings.js";
15
+ import { shouldSkipOneshotRegister } from "../shared/oneshot.js";
16
+ import {
17
+ pendingReminderContent,
18
+ staleReminderContent,
19
+ shouldFireReminder,
20
+ selectActiveTasks,
21
+ } from "./reminders.js";
22
+
23
+ // Re-export reminder pure helpers so consumers (and tests) can import from index.
24
+ export {
25
+ pendingReminderContent,
26
+ staleReminderContent,
27
+ shouldFireReminder,
28
+ selectActiveTasks,
29
+ };
30
+
31
+ const log = createLogger("pitasks");
32
+
33
+ // Module-level refs — survive closure replacement on /reload
34
+ let currentUiCtx: any = null;
35
+ let currentWidget: TaskWidget | null = null;
36
+
37
+ /** Exported store instance — set during extension init. */
38
+ export let taskStore: TaskStore;
39
+
40
+ /** Create a task directly — for use by other extensions (e.g., coms auto-create). */
41
+ export function createTask(subject: string, description: string, createdBy?: any, metadata?: Record<string, any>): any {
42
+ return taskStore.create(subject, description, createdBy || { type: "local", origin: "system", session: "", project: "" }, undefined, metadata);
43
+ }
44
+
45
+ /** Get a task by ID — for use by other extensions (e.g., coms heartbeat). */
46
+ export function getTask(id: string): any {
47
+ return taskStore?.get(id);
48
+ }
49
+
50
+ /** List all tasks — for use by other extensions (e.g., coms heartbeat). */
51
+ export function listTasks(): any[] {
52
+ return taskStore?.list() ?? [];
53
+ }
54
+
55
+ /** Update a task — for use by other extensions. */
56
+ export function updateTask(id: string, fields: any): any {
57
+ return taskStore.update(id, fields);
58
+ }
59
+
60
+ /** Delete a task — for use by other extensions. */
61
+ export function deleteTask(id: string): boolean {
62
+ return taskStore.delete(id);
63
+ }
64
+
65
+ // ── Session-targeted task operations (for cross-session/peer access) ────
66
+
67
+ /** Create a task on a specific session's store. Used by coms for sender-side task creation. */
68
+ export function createTaskForSession(sessionId: string, subject: string, description: string, createdBy?: any, metadata?: Record<string, any>, targetCwd?: string, activeForm?: string): any {
69
+ const base = targetCwd || process.cwd();
70
+ const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
71
+ const store = new TaskStore(storePath);
72
+ return store.create(subject, description, createdBy || { type: "local", origin: "system", session: "", project: "" }, activeForm, metadata);
73
+ }
74
+
75
+ /** Create multiple tasks on a specific session's store. Used by coms for bulk task creation. */
76
+ export function createTasksForSession(sessionId: string, tasks: Array<{ subject: string; description: string; createdBy: any; blockedBy?: string[]; activeForm?: string; metadata?: Record<string, any> }>, targetCwd?: string): any[] {
77
+ const base = targetCwd || process.cwd();
78
+ const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
79
+ const store = new TaskStore(storePath);
80
+ return store.createTasks(tasks);
81
+ }
82
+
83
+ /** Get a task from a specific session's store. */
84
+ export function getTaskForSession(sessionId: string, taskId: string, targetCwd?: string): any {
85
+ const base = targetCwd || process.cwd();
86
+ const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
87
+ const store = new TaskStore(storePath);
88
+ return store.get(taskId);
89
+ }
90
+
91
+ /** List tasks from a specific session's store. */
92
+ export function listTasksForSession(sessionId: string, targetCwd?: string): any[] {
93
+ const base = targetCwd || process.cwd();
94
+ const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
95
+ const store = new TaskStore(storePath);
96
+ return store.list();
97
+ }
98
+
99
+ /** Update a task on a specific session's store. */
100
+ export function updateTaskForSession(sessionId: string, taskId: string, fields: any, targetCwd?: string): any {
101
+ const base = targetCwd || process.cwd();
102
+ const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
103
+ const store = new TaskStore(storePath);
104
+ return store.update(taskId, fields);
105
+ }
106
+
107
+ /** Update multiple tasks on a specific session's store. Used by coms for bulk updates. */
108
+ export function updateTasksForSession(sessionId: string, updates: Array<{ id: string; fields: Record<string, any> }>, targetCwd?: string): any[] {
109
+ const base = targetCwd || process.cwd();
110
+ const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
111
+ const store = new TaskStore(storePath);
112
+ return store.updateTasks(updates);
113
+ }
114
+
115
+ /** Delete a task from a specific session's store. */
116
+ export function deleteTaskForSession(sessionId: string, taskId: string, targetCwd?: string): boolean {
117
+ const base = targetCwd || process.cwd();
118
+ const storePath = join(base, ".pi", "tasks", `tasks-${sessionId.replace(/[/\\]/g, "_")}.json`);
119
+ const store = new TaskStore(storePath);
120
+ return store.delete(taskId);
121
+ }
122
+
123
+ export default function (pi: ExtensionAPI) {
124
+ if (process.env.PI_SUBAGENT_CHILD === "1") return;
125
+ if (shouldSkipOneshotRegister(log)) return;
126
+ let shuttingDown = false;
127
+ const taskScope = "session";
128
+ const piTasks = process.env.PI_TASKS;
129
+
130
+ function resolveStorePath(sessionId?: string): string | undefined {
131
+ if (piTasks === "off") return undefined;
132
+ if (piTasks?.startsWith("/")) return piTasks;
133
+ if (piTasks?.startsWith(".")) return resolve(piTasks);
134
+ if (piTasks) return piTasks;
135
+ if (taskScope === "session" && sessionId) {
136
+ return join(process.cwd(), ".pi", "tasks", `tasks-${sessionId}.json`);
137
+ }
138
+ if (taskScope === "session") return undefined;
139
+ return join(process.cwd(), ".pi", "tasks", "tasks.json");
140
+ }
141
+
142
+ let store = new TaskStore(resolveStorePath());
143
+ taskStore = store;
144
+ const widget = new TaskWidget(store);
145
+ currentWidget = widget;
146
+ const instanceId = Math.random().toString(36).slice(2, 8);
147
+ (globalThis as any).__pitasks_active_instance = instanceId;
148
+
149
+ let storeUpgraded = false;
150
+ let persistedTasksShown = false;
151
+
152
+ function upgradeStoreIfNeeded(ctx: any): void {
153
+ if (storeUpgraded) return;
154
+ if (taskScope === "session" && !piTasks) {
155
+ const sessionId = ctx.sessionManager?.getSessionId?.();
156
+ const path = resolveStorePath(sessionId);
157
+ store = new TaskStore(path);
158
+ taskStore = store;
159
+ widget.setStore(store);
160
+ store.setOnChange(() => { widget.update(); });
161
+ log.debug("store upgraded", path);
162
+ }
163
+ storeUpgraded = true;
164
+ }
165
+
166
+ function showPersistedTasks(isResume = false): void {
167
+ if (persistedTasksShown) return;
168
+ persistedTasksShown = true;
169
+ const tasks = store.list();
170
+ if (tasks.length > 0) {
171
+ if (!isResume && tasks.every(t => t.status === "completed")) {
172
+ store.clearCompleted();
173
+ if (taskScope === "session") store.deleteFileIfEmpty();
174
+ } else {
175
+ widget.update();
176
+ }
177
+ }
178
+ }
179
+
180
+ // Register tools
181
+ registerTaskTools(pi, () => store, widget);
182
+
183
+ // Time-based GC: auto-clear completed tasks after configured minutes
184
+ let gcTimer: ReturnType<typeof setInterval> | null = null;
185
+ const startGcTimer = () => {
186
+ if (gcTimer) clearInterval(gcTimer);
187
+ gcTimer = setInterval(() => {
188
+ try {
189
+ if (!getSetting(process.cwd(), "task_auto_clear_enabled")) return;
190
+ const minutes = getSetting(process.cwd(), "task_auto_clear_minutes");
191
+ const threshold = minutes * 60000;
192
+ const now = Date.now();
193
+ const tasks = store.list();
194
+ let cleared = false;
195
+ for (const task of tasks) {
196
+ if (task.status === "completed" && task.statusHistory?.completed_at) {
197
+ if (now - new Date(task.statusHistory.completed_at).getTime() > threshold) {
198
+ store.delete(task.id);
199
+ cleared = true;
200
+ }
201
+ }
202
+ }
203
+ if (cleared) widget.update();
204
+ } catch {}
205
+ }, 60000);
206
+ if (gcTimer.unref) gcTimer.unref();
207
+ };
208
+ startGcTimer();
209
+
210
+ // Time-based reminder: periodic nudge when tasks exist but none in_progress
211
+ let reminderTimer: ReturnType<typeof setInterval> | null = null;
212
+ let lastReminderAt = 0;
213
+ let lastStaleReminderAt = 0;
214
+ let agentBusy = false; // true while the agent is actively running a turn
215
+ const startReminderTimer = () => {
216
+ if (reminderTimer) clearInterval(reminderTimer);
217
+ reminderTimer = setInterval(() => {
218
+ // Pending-only reminder: nudge when tasks exist but none in_progress
219
+ try {
220
+ if (getSetting(process.cwd(), "task_reminder_enabled")) {
221
+ const minutes = getSetting(process.cwd(), "task_reminder_interval_minutes");
222
+ const threshold = minutes * 60000;
223
+ const now = Date.now();
224
+ if (now - lastReminderAt >= threshold) {
225
+ const tasks = store.list();
226
+ const { active, anyInProgress } = selectActiveTasks(tasks);
227
+ if (active.length > 0 && !anyInProgress) {
228
+ if (!shouldFireReminder(agentBusy, active.length)) {
229
+ log.debug("reminder_skipped", "busy");
230
+ } else {
231
+ lastReminderAt = now;
232
+ try {
233
+ pi.sendMessage({
234
+ customType: "task-focus-reminder",
235
+ content: pendingReminderContent(active.length),
236
+ display: true,
237
+ }, { triggerTurn: true });
238
+ log.info("reminder_fired", "active", active.length);
239
+ } catch {}
240
+ }
241
+ }
242
+ }
243
+ }
244
+ } catch {}
245
+
246
+ // Stale in_progress reminder
247
+ try {
248
+ if (!getSetting(process.cwd(), "task_stale_in_progress_enabled")) return;
249
+ const staleMinutes = getSetting(process.cwd(), "task_stale_in_progress_minutes");
250
+ const staleThreshold = staleMinutes * 60000;
251
+ const now2 = Date.now();
252
+ if (now2 - lastStaleReminderAt < staleThreshold) return;
253
+ const allTasks = store.list();
254
+ const staleTasks = allTasks.filter(t =>
255
+ t.status === "in_progress" &&
256
+ t.statusHistory?.in_progress_at &&
257
+ now2 - new Date(t.statusHistory.in_progress_at).getTime() > staleThreshold
258
+ );
259
+ if (staleTasks.length > 0) {
260
+ if (!shouldFireReminder(agentBusy, staleTasks.length)) {
261
+ log.debug("reminder_skipped", "busy");
262
+ } else {
263
+ lastStaleReminderAt = now2;
264
+ try {
265
+ pi.sendMessage({
266
+ customType: "task-focus-reminder",
267
+ content: staleReminderContent(staleTasks.length),
268
+ display: true,
269
+ }, { triggerTurn: true });
270
+ log.info("stale_reminder_fired", "stale", staleTasks.length);
271
+ } catch {}
272
+ }
273
+ }
274
+ } catch {}
275
+ }, 60000);
276
+ if (reminderTimer.unref) reminderTimer.unref();
277
+ };
278
+ startReminderTimer();
279
+
280
+ pi.on("session_shutdown", () => {
281
+ shuttingDown = true;
282
+ if (gcTimer) { clearInterval(gcTimer); gcTimer = null; }
283
+ if (reminderTimer) { clearInterval(reminderTimer); reminderTimer = null; }
284
+ });
285
+
286
+ pi.on("turn_start", async (_event: any, ctx: any) => {
287
+ if (shuttingDown) return;
288
+ agentBusy = true;
289
+ log.debug("agentBusy", "turn_start");
290
+ widget.setUICtx(ctx.ui);
291
+ currentUiCtx = ctx.ui;
292
+ upgradeStoreIfNeeded(ctx);
293
+ });
294
+
295
+ pi.on("turn_end", async (event: any) => {
296
+ if (shuttingDown) return;
297
+ agentBusy = false;
298
+ log.debug("agentBusy", "turn_end");
299
+ const msg = (event as any).message;
300
+ if (msg?.role === "assistant" && msg.usage) {
301
+ widget.addTokenUsage(msg.usage.input ?? 0, msg.usage.output ?? 0);
302
+ }
303
+ });
304
+
305
+ // agent_settled is guaranteed to fire even if turn_end is skipped (abort/error).
306
+ // Reset agentBusy defensively so reminders are never suppressed forever.
307
+ pi.on("agent_settled", async () => {
308
+ if (shuttingDown) return;
309
+ agentBusy = false;
310
+ log.debug("agentBusy", "agent_settled reset");
311
+ });
312
+
313
+ pi.on("session_start", async (event: any, ctx: any) => {
314
+ shuttingDown = false;
315
+ agentBusy = false;
316
+ log.debug("agentBusy", "session_start reset");
317
+ widget.setUICtx(ctx.ui);
318
+ currentUiCtx = ctx.ui;
319
+ const reason = (event as any).reason;
320
+ log.debug("session_start", reason);
321
+ const isSwitch = reason === "new" || reason === "resume" || reason === "fork";
322
+ const forkSeed = reason === "fork" ? store.snapshot() : undefined;
323
+ if (isSwitch) {
324
+ storeUpgraded = false;
325
+ persistedTasksShown = false;
326
+ if (reason === "new") store.clearAll();
327
+ }
328
+ // Re-register command on reload to replace stale registration from old runtime
329
+ if (reason === "reload") {
330
+ log.debug("session_start reload — re-registering command");
331
+ registerTasksCommand();
332
+ }
333
+ upgradeStoreIfNeeded(ctx);
334
+ if (forkSeed?.tasks.length) store.seed(forkSeed);
335
+ showPersistedTasks(reason === "reload" || reason === "resume" || reason === "fork");
336
+ });
337
+
338
+ pi.on("before_agent_start", async (_event: any, ctx: any) => {
339
+ if (shuttingDown) return;
340
+ agentBusy = true;
341
+ log.debug("agentBusy", "before_agent_start");
342
+ widget.setUICtx(ctx.ui);
343
+ currentUiCtx = ctx.ui;
344
+ upgradeStoreIfNeeded(ctx);
345
+ showPersistedTasks();
346
+ });
347
+
348
+ pi.on("tool_execution_start", async (_event: any, ctx: any) => {
349
+ if (shuttingDown) return;
350
+ widget.setUICtx(ctx.ui);
351
+ currentUiCtx = ctx.ui;
352
+ upgradeStoreIfNeeded(ctx);
353
+ widget.update();
354
+ });
355
+
356
+ // /tasks command — extracted so it can be re-registered on reload
357
+ let tasksCmdHandler: any;
358
+ function registerTasksCommand(): void {
359
+ log.debug("registerTasksCommand called");
360
+ pi.registerCommand("tasks", {
361
+ description: "Manage tasks — view, create, clear completed",
362
+ handler: tasksCmdHandler = async (_args: string, ctx: any) => {
363
+ log.debug("/tasks handler called");
364
+ const ui = ctx.ui;
365
+ const mainMenu = async (): Promise<void> => {
366
+ const tasks = taskStore.list();
367
+ const taskCount = tasks.length;
368
+ const completedCount = tasks.filter(t => t.status === "completed").length;
369
+ const choices = [`View all tasks (${taskCount})`, "Create task"];
370
+ if (completedCount > 0) choices.push(`Clear completed (${completedCount})`);
371
+ if (taskCount > 0) choices.push(`Clear all (${taskCount})`);
372
+ const choice = await ui.select("Tasks", choices);
373
+ if (!choice) return;
374
+ if (choice.startsWith("View")) await viewTasks();
375
+ else if (choice === "Create task") await createTask();
376
+ else if (choice.startsWith("Clear completed")) { taskStore.clearCompleted(); if (taskScope === "session") taskStore.deleteFileIfEmpty(); currentWidget?.update(); await mainMenu(); }
377
+ else if (choice.startsWith("Clear all")) { taskStore.clearAll(); if (taskScope === "session") taskStore.deleteFileIfEmpty(); currentWidget?.update(); await mainMenu(); }
378
+ };
379
+ const viewTasks = async (): Promise<void> => {
380
+ const tasks = taskStore.list();
381
+ if (tasks.length === 0) { await ui.select("No tasks", ["← Back"]); return mainMenu(); }
382
+ const statusIcon = (s: string) => s === "completed" ? "✔" : s === "in_progress" ? "◼" : "◻";
383
+ const choices = tasks.map(t => `${statusIcon(t.status)} #${t.id} [${t.status}] ${t.subject}`);
384
+ choices.push("← Back");
385
+ const selected = await ui.select("Tasks", choices);
386
+ if (!selected || selected === "← Back") return mainMenu();
387
+ const match = selected.match(/#(\d+)/);
388
+ if (match) await viewTaskDetail(match[1]);
389
+ else return viewTasks();
390
+ };
391
+ const viewTaskDetail = async (taskId: string): Promise<void> => {
392
+ const task = taskStore.get(taskId);
393
+ if (!task) return viewTasks();
394
+ const actions: string[] = [];
395
+ if (task.status === "pending") actions.push("▸ Start (in_progress)");
396
+ if (task.status === "in_progress") actions.push("✓ Complete");
397
+ actions.push("✗ Delete", "← Back");
398
+ const title = `#${task.id} [${task.status}] ${task.subject}\n${task.description}`;
399
+ const action = await ui.select(title, actions);
400
+ if (action === "▸ Start (in_progress)") { taskStore.update(taskId, { status: "in_progress" }); currentWidget?.setActiveTask(taskId); currentWidget?.update(); return viewTasks(); }
401
+ else if (action === "✓ Complete") { taskStore.update(taskId, { status: "completed" }); currentWidget?.setActiveTask(taskId, false); currentWidget?.update(); return viewTasks(); }
402
+ else if (action === "✗ Delete") { taskStore.update(taskId, { status: "deleted" }); currentWidget?.setActiveTask(taskId, false); currentWidget?.update(); return viewTasks(); }
403
+ return viewTasks();
404
+ };
405
+ const createTask = async (): Promise<void> => {
406
+ const subject = await ui.input("Task subject");
407
+ if (!subject) return mainMenu();
408
+ const description = await ui.input("Task description");
409
+ if (!description) return mainMenu();
410
+ taskStore.create(subject, description, { type: "local", origin: "user", session: "", project: process.cwd() });
411
+ currentWidget?.update();
412
+ return mainMenu();
413
+ };
414
+ await mainMenu();
415
+ },
416
+ });
417
+ }
418
+ registerTasksCommand();
419
+ // Expose handler to pidash for browser command dispatch
420
+ try {
421
+ if ((globalThis as any).__pitasks_pidash_listener) {
422
+ try { pi.events.removeListener("pidash:request-commands", (globalThis as any).__pitasks_pidash_listener); } catch {}
423
+ }
424
+ const registerWithPidash = () => pi.events.emit("pidash:register-command", { name: "tasks", handler: tasksCmdHandler });
425
+ (globalThis as any).__pitasks_pidash_listener = registerWithPidash;
426
+ registerWithPidash();
427
+ pi.events.on("pidash:request-commands", registerWithPidash);
428
+ } catch {}
429
+ log.debug("extension factory complete");
430
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * extensions/pitasks/reminders.ts — Pure helpers for the task-focus reminder.
3
+ * Kept dependency-free so both the extension timer and unit tests can import
4
+ * the SAME production logic (no mirrored copies). The shared logger is
5
+ * dependency-light (node builtins only) so importing it here does not pull in
6
+ * pi-tui and keeps the module test-importable.
7
+ */
8
+
9
+ import { createLogger } from "../shared/logger.js";
10
+
11
+ const log = createLogger("pitasks-reminders");
12
+
13
+ /** Content for the pending-only reminder (tasks exist but none in_progress). */
14
+ export function pendingReminderContent(activeCount: number): string {
15
+ log.debug("pendingReminderContent", activeCount);
16
+ return `⚠️ You have ${activeCount} active task(s) and none are in progress. Check your TaskList and resume your workflow.`;
17
+ }
18
+
19
+ /** Content for the stale in_progress reminder. */
20
+ export function staleReminderContent(staleCount: number): string {
21
+ log.debug("staleReminderContent", staleCount);
22
+ return `⚠️ You have ${staleCount} task(s) stuck in progress. Check your TaskList and update their status.`;
23
+ }
24
+
25
+ /** Gate: fire the reminder only when the agent is idle and there are matching tasks. */
26
+ export function shouldFireReminder(agentBusy: boolean, matchingCount: number): boolean {
27
+ const result = matchingCount > 0 && !agentBusy;
28
+ log.debug("shouldFireReminder", agentBusy, matchingCount, result);
29
+ return result;
30
+ }
31
+
32
+ /** Select active tasks (pending or in_progress) and whether any are in_progress. */
33
+ export function selectActiveTasks(tasks: Array<{ status: string }>): { active: any[]; anyInProgress: boolean } {
34
+ const active = tasks.filter(t => t.status === "pending" || t.status === "in_progress");
35
+ log.debug("selectActiveTasks", tasks.length, active.length);
36
+ return { active, anyInProgress: active.some(t => t.status === "in_progress") };
37
+ }