@trevonistrevon/pi-loop 0.3.1 → 0.4.1

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/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { parseInterval } from "./loop-parse.js";
20
20
  import { MonitorManager } from "./monitor-manager.js";
21
21
  import { CronScheduler } from "./scheduler.js";
22
22
  import { LoopStore } from "./store.js";
23
+ import { TaskStore } from "./task-store.js";
23
24
  import { TriggerSystem } from "./trigger-system.js";
24
25
  import { LoopWidget } from "./ui/widget.js";
25
26
  const DEBUG = !!process.env.PI_LOOP_DEBUG;
@@ -52,17 +53,35 @@ export default function (pi) {
52
53
  return undefined;
53
54
  return join(process.cwd(), ".pi", "loops", "loops.json");
54
55
  }
56
+ function resolveTaskStorePath() {
57
+ if (loopScope === "memory")
58
+ return undefined;
59
+ return join(process.cwd(), ".pi", "tasks", "tasks.json");
60
+ }
55
61
  let store = new LoopStore(resolveStorePath());
56
62
  const monitorManager = new MonitorManager(pi);
57
63
  let scheduler;
58
64
  let triggerSystem;
59
- const widget = new LoopWidget(store, undefined, monitorManager);
65
+ const widget = new LoopWidget(store, monitorManager);
66
+ widget.setTaskSummaryProvider(() => {
67
+ if (!nativeTaskStore)
68
+ return { count: 0 };
69
+ const tasks = nativeTaskStore.list().filter(t => t.status === "pending" || t.status === "in_progress");
70
+ const active = tasks.find(t => t.status === "in_progress");
71
+ const next = tasks.find(t => t.status === "pending");
72
+ const focus = active
73
+ ? `active: ${active.subject.slice(0, 50)}`
74
+ : next
75
+ ? `next: ${next.subject.slice(0, 50)}`
76
+ : undefined;
77
+ return { count: tasks.length, focusText: focus };
78
+ });
60
79
  scheduler = new CronScheduler(store, onLoopFire);
61
- widget.setScheduler(scheduler);
62
- triggerSystem = new TriggerSystem(pi, scheduler, store);
80
+ triggerSystem = new TriggerSystem(pi, scheduler, store, onLoopFire);
63
81
  // ── pi-tasks integration ──
64
82
  let tasksAvailable = false;
65
- const _PROTOCOL_VERSION = 1;
83
+ let nativeTaskStore;
84
+ let nativeTasksRegistered = false;
66
85
  function checkTasksVersion() {
67
86
  const requestId = randomUUID();
68
87
  const timer = setTimeout(() => { unsub(); }, 5000);
@@ -78,54 +97,169 @@ export default function (pi) {
78
97
  checkTasksVersion();
79
98
  pi.events.on("tasks:ready", () => checkTasksVersion());
80
99
  async function autoCreateTask(entry) {
81
- if (!tasksAvailable || !entry.autoTask)
100
+ if (!entry.autoTask)
82
101
  return undefined;
83
- try {
84
- const requestId = randomUUID();
85
- const taskId = await new Promise((resolve, _reject) => {
86
- const timer = setTimeout(() => { unsub(); resolve(undefined); }, 5000);
87
- const unsub = pi.events.on(`tasks:rpc:create:reply:${requestId}`, (raw) => {
88
- unsub();
89
- clearTimeout(timer);
90
- const reply = raw;
91
- if (reply.success && reply.data)
92
- resolve(reply.data.id);
93
- else
94
- resolve(undefined);
95
- });
96
- pi.events.emit("tasks:rpc:create", {
97
- requestId,
98
- subject: entry.prompt.slice(0, 80),
99
- description: `Auto-created from loop #${entry.id}`,
100
- metadata: { loopId: entry.id, trigger: entry.trigger },
102
+ if (tasksAvailable) {
103
+ try {
104
+ const requestId = randomUUID();
105
+ const taskId = await new Promise((resolve, _reject) => {
106
+ const timer = setTimeout(() => { unsub(); resolve(undefined); }, 5000);
107
+ const unsub = pi.events.on(`tasks:rpc:create:reply:${requestId}`, (raw) => {
108
+ unsub();
109
+ clearTimeout(timer);
110
+ const reply = raw;
111
+ if (reply.success && reply.data)
112
+ resolve(reply.data.id);
113
+ else
114
+ resolve(undefined);
115
+ });
116
+ pi.events.emit("tasks:rpc:create", {
117
+ requestId,
118
+ subject: entry.prompt.slice(0, 80),
119
+ description: `Auto-created from loop #${entry.id}`,
120
+ metadata: { loopId: entry.id, trigger: entry.trigger },
121
+ });
101
122
  });
102
- });
103
- return taskId;
123
+ return taskId;
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
104
128
  }
105
- catch {
129
+ if (!nativeTaskStore)
106
130
  return undefined;
107
- }
131
+ const task = nativeTaskStore.create(entry.prompt.slice(0, 80), `Auto-created from loop #${entry.id}`, {
132
+ loopId: entry.id,
133
+ trigger: entry.trigger,
134
+ });
135
+ widget.update();
136
+ return task.id;
108
137
  }
109
138
  async function hasPendingTasks() {
110
- if (!tasksAvailable)
111
- return -1;
112
- try {
113
- const requestId = randomUUID();
114
- const count = await new Promise((resolve) => {
115
- const timer = setTimeout(() => { unsub(); resolve(-1); }, 3000);
116
- const unsub = pi.events.on(`tasks:rpc:pending:reply:${requestId}`, (raw) => {
117
- unsub();
118
- clearTimeout(timer);
119
- const reply = raw;
120
- resolve(reply.success && reply.data ? reply.data.pending : -1);
139
+ if (tasksAvailable) {
140
+ try {
141
+ const requestId = randomUUID();
142
+ const count = await new Promise((resolve) => {
143
+ const timer = setTimeout(() => { unsub(); resolve(-1); }, 3000);
144
+ const unsub = pi.events.on(`tasks:rpc:pending:reply:${requestId}`, (raw) => {
145
+ unsub();
146
+ clearTimeout(timer);
147
+ const reply = raw;
148
+ resolve(reply.success && reply.data ? reply.data.pending : -1);
149
+ });
150
+ pi.events.emit("tasks:rpc:pending", { requestId });
121
151
  });
122
- pi.events.emit("tasks:rpc:pending", { requestId });
123
- });
124
- return count;
152
+ return count;
153
+ }
154
+ catch {
155
+ return -1;
156
+ }
157
+ }
158
+ return nativeTaskStore ? nativeTaskStore.pendingCount() : -1;
159
+ }
160
+ async function cleanDoneTasks() {
161
+ if (tasksAvailable) {
162
+ try {
163
+ const requestId = randomUUID();
164
+ await new Promise((resolve) => {
165
+ const timer = setTimeout(() => { unsub(); resolve(); }, 3000);
166
+ const unsub = pi.events.on(`tasks:rpc:clean:reply:${requestId}`, () => {
167
+ unsub();
168
+ clearTimeout(timer);
169
+ debug("tasks:rpc:clean — done tasks swept");
170
+ resolve();
171
+ });
172
+ pi.events.emit("tasks:rpc:clean", { requestId });
173
+ });
174
+ }
175
+ catch { /* timeout or error, ignore */ }
176
+ return;
177
+ }
178
+ if (nativeTaskStore) {
179
+ nativeTaskStore.sweepCompleted();
180
+ widget.update();
125
181
  }
126
- catch {
127
- return -1;
182
+ }
183
+ let agentRunning = false;
184
+ const pendingNotifications = new Map();
185
+ let flushPromise;
186
+ function buildLoopFireMessage(data) {
187
+ const triggerInfo = typeof data.trigger === "string"
188
+ ? data.trigger
189
+ : data.trigger?.type === "cron"
190
+ ? `schedule: ${data.trigger.schedule}`
191
+ : data.trigger?.type === "event"
192
+ ? `event: ${data.trigger.source}`
193
+ : "hybrid";
194
+ const loopId = data.loopId || "?";
195
+ const prompt = data.prompt || "loop fired";
196
+ const constraint = data.readOnly
197
+ ? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
198
+ : "";
199
+ return [
200
+ `[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
201
+ prompt,
202
+ ].join("\n");
203
+ }
204
+ function buildPendingNotification(data) {
205
+ const key = data.recurring ? `loop:${data.loopId}` : `loop:${data.loopId}:${data.timestamp}`;
206
+ return {
207
+ ...data,
208
+ key,
209
+ message: buildLoopFireMessage(data),
210
+ };
211
+ }
212
+ async function deliverNotification(notification) {
213
+ if (notification.autoTask) {
214
+ const pending = await hasPendingTasks();
215
+ if (pending === 0) {
216
+ debug(`loop:fire #${notification.loopId} — no pending tasks at delivery time, dropping wake`);
217
+ await cleanDoneTasks();
218
+ return false;
219
+ }
128
220
  }
221
+ agentRunning = true;
222
+ pi.sendMessage({
223
+ customType: "pi-loop",
224
+ content: notification.message,
225
+ display: false,
226
+ details: {
227
+ loopId: notification.loopId,
228
+ trigger: notification.trigger,
229
+ recurring: notification.recurring,
230
+ readOnly: notification.readOnly,
231
+ autoTask: notification.autoTask,
232
+ timestamp: notification.timestamp,
233
+ },
234
+ }, {
235
+ deliverAs: "steer",
236
+ triggerTurn: true,
237
+ });
238
+ return true;
239
+ }
240
+ async function flushPendingNotifications() {
241
+ if (flushPromise)
242
+ return flushPromise;
243
+ flushPromise = (async () => {
244
+ if (agentRunning || _latestCtx?.hasPendingMessages())
245
+ return;
246
+ const entries = [...pendingNotifications.entries()]
247
+ .sort(([, left], [, right]) => left.timestamp - right.timestamp);
248
+ for (const [key, notification] of entries) {
249
+ pendingNotifications.delete(key);
250
+ const delivered = await deliverNotification(notification);
251
+ if (delivered)
252
+ return;
253
+ }
254
+ })().finally(() => {
255
+ flushPromise = undefined;
256
+ });
257
+ return flushPromise;
258
+ }
259
+ async function queueOrDeliverNotification(data) {
260
+ const notification = buildPendingNotification(data);
261
+ pendingNotifications.set(notification.key, notification);
262
+ await flushPendingNotifications();
129
263
  }
130
264
  // ── Loop fire handler ──
131
265
  function onLoopFire(entry) {
@@ -165,8 +299,7 @@ export default function (pi) {
165
299
  store = new LoopStore(path);
166
300
  widget.setStore(store);
167
301
  scheduler = new CronScheduler(store, onLoopFire);
168
- widget.setScheduler(scheduler);
169
- triggerSystem = new TriggerSystem(pi, scheduler, store);
302
+ triggerSystem = new TriggerSystem(pi, scheduler, store, onLoopFire);
170
303
  }
171
304
  storeUpgraded = true;
172
305
  }
@@ -187,17 +320,36 @@ export default function (pi) {
187
320
  _latestCtx = ctx;
188
321
  widget.setUICtx(ctx.ui);
189
322
  upgradeStoreIfNeeded(ctx);
323
+ widget.update();
190
324
  });
191
325
  pi.on("before_agent_start", async (_event, ctx) => {
192
326
  _latestCtx = ctx;
193
327
  widget.setUICtx(ctx.ui);
194
328
  upgradeStoreIfNeeded(ctx);
195
329
  showPersistedLoops();
330
+ widget.update();
331
+ });
332
+ pi.on("agent_start", async (_event, ctx) => {
333
+ agentRunning = true;
334
+ _latestCtx = ctx;
335
+ widget.setUICtx(ctx.ui);
336
+ });
337
+ pi.on("agent_end", async (_event, ctx) => {
338
+ agentRunning = false;
339
+ _latestCtx = ctx;
340
+ widget.setUICtx(ctx.ui);
341
+ await flushPendingNotifications();
342
+ });
343
+ pi.on("session_shutdown", async () => {
344
+ agentRunning = false;
345
+ pendingNotifications.clear();
196
346
  });
197
347
  pi.on("session_switch", async (event, ctx) => {
198
348
  _latestCtx = ctx;
199
349
  widget.setUICtx(ctx.ui);
200
350
  triggerSystem.stop();
351
+ agentRunning = false;
352
+ pendingNotifications.clear();
201
353
  const isResume = event?.reason === "resume";
202
354
  storeUpgraded = false;
203
355
  persistedShown = false;
@@ -206,36 +358,20 @@ export default function (pi) {
206
358
  }
207
359
  upgradeStoreIfNeeded(ctx);
208
360
  showPersistedLoops(isResume);
361
+ widget.update();
209
362
  });
210
- // ── Loop fire handler — sends a user message to re-wake the agent ──
363
+ // ── Loop fire handler — queues an in-memory notification, then injects a custom message when delivery is safe ──
211
364
  pi.events.on("loop:fire", async (event) => {
212
365
  const data = event;
213
- if (data.recurring && _latestCtx?.hasPendingMessages()) {
214
- debug(`loop:fire #${data.loopId} — agent has pending messages, skipping recurring fire`);
215
- return;
216
- }
217
366
  if (data.autoTask) {
218
367
  const pending = await hasPendingTasks();
219
368
  if (pending === 0) {
220
- debug(`loop:fire #${data.loopId} — no pending tasks, skipping`);
369
+ debug(`loop:fire #${data.loopId} — no pending tasks, skipping, requesting cleanup`);
370
+ await cleanDoneTasks();
221
371
  return;
222
372
  }
223
373
  }
224
- const triggerInfo = typeof data.trigger === "string"
225
- ? data.trigger
226
- : data.trigger?.type === "cron"
227
- ? `schedule: ${data.trigger.schedule}`
228
- : data.trigger?.type === "event"
229
- ? `event: ${data.trigger.source}`
230
- : `hybrid`;
231
- const loopId = data.loopId || "?";
232
- const prompt = data.prompt || "loop fired";
233
- const constraint = data.readOnly ? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, LoopCreate, etc.). No file writes, shell execution, or destructive changes." : "";
234
- const message = [
235
- `[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
236
- prompt,
237
- ].join("\n");
238
- pi.sendUserMessage(message, { deliverAs: "followUp" });
374
+ await queueOrDeliverNotification(data);
239
375
  });
240
376
  // ──────────────────────────────────────────────────
241
377
  // Tool 1: LoopCreate
@@ -271,7 +407,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
271
407
  - **trigger**: interval like "30s", "5m", "2h", event source, or hybrid spec
272
408
  - **prompt**: what to do when the loop fires (e.g., "check if the build passed")
273
409
  - **recurring**: repeat or fire once (default: true)
274
- - **autoTask**: if pi-tasks is loaded, auto-create a task on each fire
410
+ - **autoTask**: when pi-tasks is loaded or native task fallback is active, auto-create a task on each fire
275
411
  - **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)
276
412
  - **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops`,
277
413
  promptGuidelines: [
@@ -290,7 +426,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
290
426
  "Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
291
427
  "## Task-driven workflows",
292
428
  "After creating tasks with TaskCreate, use an event loop with autoTask: true so the system checks for pending tasks before firing: LoopCreate trigger='tasks:created' triggerType='event' autoTask: true maxFires: 30 prompt='Run TaskList, pick the next available pending task, work on it.'",
293
- "When no tasks are pending, the loop skips the follow-up — no tokens burned on empty polls.",
429
+ "When no tasks are pending, the loop skips the wake entirely — no tokens burned on empty polls.",
294
430
  "After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
295
431
  ],
296
432
  parameters: Type.Object({
@@ -361,7 +497,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
361
497
  `Trigger: ${triggerDesc}\n` +
362
498
  `Recurring: ${entry.recurring}\n` +
363
499
  (entry.autoTask ? `Auto-task: enabled\n` : "") +
364
- (tasksAvailable ? "" : "(pi-tasks not detected — autoTask will have no effect)\n") +
500
+ ((tasksAvailable || nativeTasksRegistered) ? "" : "(task system not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available)\n") +
365
501
  `ID: ${entry.id} (use LoopDelete to cancel)`));
366
502
  },
367
503
  });
@@ -430,7 +566,7 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
430
566
  const nextFire = entry.trigger.type !== "event"
431
567
  ? scheduler.nextFire(entry.id)
432
568
  : undefined;
433
- const statusIcon = entry.status === "active" ? "" : entry.status === "paused" ? "" : "";
569
+ const statusIcon = entry.status === "active" ? "*" : entry.status === "paused" ? "-" : "x";
434
570
  let line = `${statusIcon} #${entry.id} [${entry.status}] ${entry.prompt.slice(0, 60)}`;
435
571
  line += ` (${triggerDesc})`;
436
572
  if (nextFire) {
@@ -492,7 +628,7 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
492
628
 
493
629
  Fire off a build check, CI monitor, experiment, script, or any slow command — then keep working. Output streams back as "monitor:output" events. When the process exits, "monitor:done" fires (or "monitor:error" on failure).
494
630
 
495
- If you pass onDone with a prompt, the monitor auto-creates a one-shot completion loop — you get a system reminder with the exit code and output line count. No need to poll or create a separate loop.
631
+ If you pass onDone with a prompt, the monitor auto-creates a one-shot completion loop — you get a completion wake with the exit code and output line count. No need to poll or create a separate loop.
496
632
 
497
633
  DO NOT use raw Bash while/sleep/for loops to watch something. DO NOT run slow commands inline that could be offloaded. Use MonitorCreate to run work in parallel while you continue.
498
634
 
@@ -506,7 +642,7 @@ Default to MonitorCreate for any long-running or background work:\n- Watch a CI/
506
642
 
507
643
  ## onDone — auto-notify on completion
508
644
 
509
- Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits. The system reminder includes the exit code and output line count.\n\nExample: MonitorCreate command="python train.py" onDone="Check training results and report best loss"\nExample: MonitorCreate command="hut builds show 1769753" onDone="Analyze the build result and report status"`,
645
+ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits. The completion wake includes the exit code and output line count.\n\nExample: MonitorCreate command="python train.py" onDone="Check training results and report best loss"\nExample: MonitorCreate command="hut builds show 1769753" onDone="Analyze the build result and report status"`,
510
646
  promptGuidelines: [
511
647
  "Default to MonitorCreate for any long-running or background command — releases the agent to keep working on other tasks in parallel.",
512
648
  "When the user asks to monitor CI builds, watch a build, check a remote job, or run an experiment, use MonitorCreate instead of inline bash/curl/wait.",
@@ -516,7 +652,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
516
652
  command: Type.String({ description: "Shell command to run in background" }),
517
653
  description: Type.Optional(Type.String({ description: "Human-readable description" })),
518
654
  timeout: Type.Optional(Type.Number({ description: "Auto-stop after N ms (default: 300000, 0 = no timeout)", default: 300000 })),
519
- onDone: Type.Optional(Type.String({ description: "Prompt to run when the monitor completes. Auto-creates a one-shot completion loop — no need for a separate LoopCreate." })),
655
+ onDone: Type.Optional(Type.String({ description: "Prompt to run when the monitor completes. Auto-creates a one-shot completion wake — no need for a separate LoopCreate." })),
520
656
  }),
521
657
  execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
522
658
  if (monitorManager.list().filter(m => m.status === "running").length >= 25) {
@@ -550,7 +686,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
550
686
  return Promise.resolve(textResult("No monitors running."));
551
687
  const lines = [];
552
688
  for (const m of monitors) {
553
- const icon = m.status === "running" ? "" : m.status === "completed" ? "" : "";
689
+ const icon = m.status === "running" ? ">" : m.status === "completed" ? "ok" : "!!";
554
690
  const age = Date.now() - m.startedAt;
555
691
  const ageStr = formatRemaining(age);
556
692
  let line = `${icon} #${m.id} [${m.status}] ${m.command.slice(0, 60)} — ${m.outputLines} lines (${ageStr})`;
@@ -680,42 +816,42 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
680
816
  async function viewLoops(ui) {
681
817
  const loops = store.list();
682
818
  if (loops.length === 0) {
683
- await ui.select("No active loops", [" Back"]);
819
+ await ui.select("No active loops", ["< Back"]);
684
820
  return;
685
821
  }
686
822
  const choices = loops.map((l) => {
687
- const icon = l.status === "active" ? "" : l.status === "paused" ? "" : "";
823
+ const icon = l.status === "active" ? "*" : l.status === "paused" ? "-" : "x";
688
824
  const triggerDesc = l.trigger.type === "cron" ? `cron: ${l.trigger.schedule}` : l.trigger.type === "event" ? `event: ${l.trigger.source}` : `hybrid: ${l.trigger.cron}`;
689
825
  return `${icon} #${l.id} [${l.status}] ${l.prompt.slice(0, 50)} (${triggerDesc})`;
690
826
  });
691
- choices.push(" Back");
827
+ choices.push("< Back");
692
828
  const selected = await ui.select("Active Loops", choices);
693
- if (!selected || selected === " Back")
829
+ if (!selected || selected === "< Back")
694
830
  return;
695
831
  const match = selected.match(/#(\d+)/);
696
832
  if (match) {
697
833
  const entry = store.get(match[1]);
698
834
  if (entry) {
699
- const actions = [" Delete"];
835
+ const actions = ["x Delete"];
700
836
  if (entry.status === "active")
701
- actions.unshift(" Pause");
837
+ actions.unshift("- Pause");
702
838
  else if (entry.status === "paused")
703
- actions.unshift(" Resume");
704
- actions.push(" Back");
839
+ actions.unshift("* Resume");
840
+ actions.push("< Back");
705
841
  const action = await ui.select(`#${entry.id}: ${entry.prompt}\nTrigger: ${JSON.stringify(entry.trigger)}`, actions);
706
- if (action === " Delete") {
842
+ if (action === "x Delete") {
707
843
  triggerSystem.remove(entry.id);
708
844
  store.delete(entry.id);
709
845
  widget.update();
710
846
  ui.notify(`Loop #${entry.id} deleted`, "info");
711
847
  }
712
- else if (action === " Pause") {
848
+ else if (action === "- Pause") {
713
849
  store.update(entry.id, { status: "paused" });
714
850
  triggerSystem.remove(entry.id);
715
851
  widget.update();
716
852
  ui.notify(`Loop #${entry.id} paused`, "info");
717
853
  }
718
- else if (action === " Resume") {
854
+ else if (action === "* Resume") {
719
855
  store.update(entry.id, { status: "active" });
720
856
  triggerSystem.add(entry);
721
857
  widget.update();
@@ -730,4 +866,187 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
730
866
  const active = loops.filter(l => l.status === "active").length;
731
867
  ui.notify(`${active}/${loops.length} active loops (max 25)`, "info");
732
868
  }
869
+ async function createNativeTaskInteractively(ui) {
870
+ if (!nativeTaskStore) {
871
+ ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
872
+ return;
873
+ }
874
+ const subject = await ui.input("Task subject");
875
+ if (!subject)
876
+ return;
877
+ const description = await ui.input("Task description") || subject;
878
+ const entry = nativeTaskStore.create(subject, description);
879
+ widget.update();
880
+ ui.notify(`Task #${entry.id} created`, "info");
881
+ }
882
+ async function viewNativeTasks(ui) {
883
+ if (!nativeTaskStore) {
884
+ ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
885
+ return;
886
+ }
887
+ const tasks = nativeTaskStore.list();
888
+ const choices = tasks.map((task) => {
889
+ const icon = task.status === "in_progress" ? ">" : task.status === "completed" ? "ok" : "*";
890
+ return `${icon} #${task.id} [${task.status}] ${task.subject.slice(0, 60)}`;
891
+ });
892
+ choices.unshift("+ Create task");
893
+ choices.push("< Back");
894
+ const selected = await ui.select("Native Tasks", choices);
895
+ if (!selected || selected === "< Back")
896
+ return;
897
+ if (selected === "+ Create task") {
898
+ await createNativeTaskInteractively(ui);
899
+ return viewNativeTasks(ui);
900
+ }
901
+ const match = selected.match(/#(\d+)/);
902
+ if (!match)
903
+ return viewNativeTasks(ui);
904
+ const task = nativeTaskStore.get(match[1]);
905
+ if (!task)
906
+ return viewNativeTasks(ui);
907
+ const actions = ["x Delete"];
908
+ if (task.status === "pending") {
909
+ actions.unshift("ok Complete");
910
+ actions.unshift("> Start");
911
+ }
912
+ else if (task.status === "in_progress") {
913
+ actions.unshift("ok Complete");
914
+ actions.unshift("* Return to pending");
915
+ }
916
+ else {
917
+ actions.unshift("* Reopen");
918
+ }
919
+ actions.push("< Back");
920
+ const action = await ui.select(`#${task.id}: ${task.subject}\n\n${task.description}`, actions);
921
+ if (!action || action === "< Back")
922
+ return viewNativeTasks(ui);
923
+ if (action === "x Delete") {
924
+ nativeTaskStore.delete(task.id);
925
+ ui.notify(`Task #${task.id} deleted`, "info");
926
+ }
927
+ else if (action === "> Start") {
928
+ nativeTaskStore.update(task.id, { status: "in_progress" });
929
+ ui.notify(`Task #${task.id} started`, "info");
930
+ }
931
+ else if (action === "ok Complete") {
932
+ nativeTaskStore.update(task.id, { status: "completed" });
933
+ ui.notify(`Task #${task.id} completed`, "info");
934
+ }
935
+ else if (action === "* Return to pending" || action === "* Reopen") {
936
+ nativeTaskStore.update(task.id, { status: "pending" });
937
+ ui.notify(`Task #${task.id} reopened`, "info");
938
+ }
939
+ widget.update();
940
+ return viewNativeTasks(ui);
941
+ }
942
+ // ── Native task tools (only when pi-tasks is absent) ──
943
+ setTimeout(async () => {
944
+ if (tasksAvailable || nativeTasksRegistered)
945
+ return;
946
+ nativeTaskStore = new TaskStore(resolveTaskStorePath());
947
+ nativeTasksRegistered = true;
948
+ const taskStore = nativeTaskStore;
949
+ pi.registerCommand("tasks", {
950
+ description: "View or manage native pi-loop tasks when pi-tasks is not installed",
951
+ handler: async (args, ctx) => {
952
+ const trimmed = args.trim();
953
+ if (!nativeTaskStore) {
954
+ ctx.ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
955
+ return;
956
+ }
957
+ if (trimmed) {
958
+ const entry = nativeTaskStore.create(trimmed.slice(0, 80), trimmed);
959
+ widget.update();
960
+ ctx.ui.notify(`Task #${entry.id} created`, "info");
961
+ return;
962
+ }
963
+ await viewNativeTasks(ctx.ui);
964
+ },
965
+ });
966
+ pi.registerTool({
967
+ name: "TaskCreate",
968
+ label: "TaskCreate",
969
+ description: `Create a task for tracking work across turns. Use when you need to track progress on complex multi-step tasks.
970
+
971
+ Fields:
972
+ - subject: brief actionable title
973
+ - description: detailed requirements
974
+ - metadata: optional tags/metadata`,
975
+ parameters: Type.Object({
976
+ subject: Type.String({ description: "Brief actionable title for the task" }),
977
+ description: Type.String({ description: "Detailed description of what needs to be done" }),
978
+ }),
979
+ execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
980
+ const entry = taskStore.create(params.subject, params.description);
981
+ widget.update();
982
+ return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}`));
983
+ },
984
+ });
985
+ pi.registerTool({
986
+ name: "TaskList",
987
+ label: "TaskList",
988
+ description: `List all tasks with status. Use to check progress and find available work.`,
989
+ parameters: Type.Object({}),
990
+ execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
991
+ const tasks = taskStore.list();
992
+ if (tasks.length === 0)
993
+ return Promise.resolve(textResult("No tasks."));
994
+ const lines = [];
995
+ const statuses = {
996
+ pending: 0,
997
+ in_progress: 0,
998
+ completed: 0,
999
+ };
1000
+ for (const t of tasks) {
1001
+ statuses[t.status]++;
1002
+ const icon = t.status === "completed" ? "ok" : t.status === "in_progress" ? ">" : "*";
1003
+ lines.push(`${icon} #${t.id} [${t.status}] ${t.subject.slice(0, 80)}`);
1004
+ }
1005
+ lines.unshift(`${tasks.length} tasks (${statuses.pending} pending, ${statuses.in_progress} in progress, ${statuses.completed} done)`);
1006
+ return Promise.resolve(textResult(lines.join("\n")));
1007
+ },
1008
+ });
1009
+ pi.registerTool({
1010
+ name: "TaskUpdate",
1011
+ label: "TaskUpdate",
1012
+ description: `Update task status or details. Set status to "in_progress" before starting work, "completed" when done.
1013
+
1014
+ Statuses: pending → in_progress → completed`,
1015
+ parameters: Type.Object({
1016
+ id: Type.String({ description: "Task ID to update" }),
1017
+ status: Type.Optional(Type.String({ description: "New status", enum: ["pending", "in_progress", "completed"] })),
1018
+ subject: Type.Optional(Type.String({ description: "New title" })),
1019
+ description: Type.Optional(Type.String({ description: "New description" })),
1020
+ }),
1021
+ execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1022
+ const { id, status, subject, description } = params;
1023
+ const entry = taskStore.update(id, {
1024
+ status: status,
1025
+ subject,
1026
+ description,
1027
+ });
1028
+ if (!entry)
1029
+ return Promise.resolve(textResult(`Task #${id} not found`));
1030
+ widget.update();
1031
+ const statusMsg = status ? ` → ${status}` : "";
1032
+ return Promise.resolve(textResult(`Task #${id} updated${statusMsg}`));
1033
+ },
1034
+ });
1035
+ pi.registerTool({
1036
+ name: "TaskDelete",
1037
+ label: "TaskDelete",
1038
+ description: `Delete a task by ID. Use for cleaning up completed or irrelevant tasks.`,
1039
+ parameters: Type.Object({
1040
+ id: Type.String({ description: "Task ID to delete" }),
1041
+ }),
1042
+ execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1043
+ const deleted = taskStore.delete(params.id);
1044
+ widget.update();
1045
+ if (deleted)
1046
+ return Promise.resolve(textResult(`Task #${params.id} deleted`));
1047
+ return Promise.resolve(textResult(`Task #${params.id} not found`));
1048
+ },
1049
+ });
1050
+ debug("native task tools registered (pi-tasks not detected)");
1051
+ }, 6000);
733
1052
  }