@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/src/index.ts CHANGED
@@ -22,6 +22,7 @@ import { parseInterval } from "./loop-parse.js";
22
22
  import { MonitorManager } from "./monitor-manager.js";
23
23
  import { CronScheduler } from "./scheduler.js";
24
24
  import { LoopStore } from "./store.js";
25
+ import { TaskStore } from "./task-store.js";
25
26
  import { TriggerSystem } from "./trigger-system.js";
26
27
  import type { LoopEntry, Trigger } from "./types.js";
27
28
  import { LoopWidget } from "./ui/widget.js";
@@ -49,6 +50,10 @@ interface SessionSwitchEvent {
49
50
  reason?: string;
50
51
  }
51
52
 
53
+ interface PendingNotification extends LoopFireEvent {
54
+ key: string;
55
+ message: string;
56
+ }
52
57
 
53
58
  export default function (pi: ExtensionAPI) {
54
59
  const piLoopEnv = process.env.PI_LOOP;
@@ -68,19 +73,36 @@ export default function (pi: ExtensionAPI) {
68
73
  return join(process.cwd(), ".pi", "loops", "loops.json");
69
74
  }
70
75
 
76
+ function resolveTaskStorePath(): string | undefined {
77
+ if (loopScope === "memory") return undefined;
78
+ return join(process.cwd(), ".pi", "tasks", "tasks.json");
79
+ }
80
+
71
81
  let store = new LoopStore(resolveStorePath());
72
82
  const monitorManager = new MonitorManager(pi);
73
83
  let scheduler: CronScheduler;
74
84
  let triggerSystem: TriggerSystem;
75
- const widget = new LoopWidget(store, undefined, monitorManager);
85
+ const widget = new LoopWidget(store, monitorManager);
86
+ widget.setTaskSummaryProvider(() => {
87
+ if (!nativeTaskStore) return { count: 0 };
88
+ const tasks = nativeTaskStore.list().filter(t => t.status === "pending" || t.status === "in_progress");
89
+ const active = tasks.find(t => t.status === "in_progress");
90
+ const next = tasks.find(t => t.status === "pending");
91
+ const focus = active
92
+ ? `active: ${active.subject.slice(0, 50)}`
93
+ : next
94
+ ? `next: ${next.subject.slice(0, 50)}`
95
+ : undefined;
96
+ return { count: tasks.length, focusText: focus };
97
+ });
76
98
 
77
99
  scheduler = new CronScheduler(store, onLoopFire);
78
- widget.setScheduler(scheduler);
79
- triggerSystem = new TriggerSystem(pi, scheduler, store);
100
+ triggerSystem = new TriggerSystem(pi, scheduler, store, onLoopFire);
80
101
 
81
102
  // ── pi-tasks integration ──
82
103
  let tasksAvailable = false;
83
- const _PROTOCOL_VERSION = 1;
104
+ let nativeTaskStore: TaskStore | undefined;
105
+ let nativeTasksRegistered = false;
84
106
 
85
107
  function checkTasksVersion() {
86
108
  const requestId = randomUUID();
@@ -97,47 +119,171 @@ export default function (pi: ExtensionAPI) {
97
119
  pi.events.on("tasks:ready", () => checkTasksVersion());
98
120
 
99
121
  async function autoCreateTask(entry: LoopEntry): Promise<string | undefined> {
100
- if (!tasksAvailable || !entry.autoTask) return undefined;
101
- try {
102
- const requestId = randomUUID();
103
- const taskId = await new Promise<string | undefined>((resolve, _reject) => {
104
- const timer = setTimeout(() => { unsub(); resolve(undefined); }, 5000);
105
- const unsub = pi.events.on(`tasks:rpc:create:reply:${requestId}`, (raw: unknown) => {
106
- unsub(); clearTimeout(timer);
107
- const reply = raw as { success: boolean; data?: { id: string }; error?: string };
108
- if (reply.success && reply.data) resolve(reply.data.id);
109
- else resolve(undefined);
110
- });
111
- pi.events.emit("tasks:rpc:create", {
112
- requestId,
113
- subject: entry.prompt.slice(0, 80),
114
- description: `Auto-created from loop #${entry.id}`,
115
- metadata: { loopId: entry.id, trigger: entry.trigger },
122
+ if (!entry.autoTask) return undefined;
123
+ if (tasksAvailable) {
124
+ try {
125
+ const requestId = randomUUID();
126
+ const taskId = await new Promise<string | undefined>((resolve, _reject) => {
127
+ const timer = setTimeout(() => { unsub(); resolve(undefined); }, 5000);
128
+ const unsub = pi.events.on(`tasks:rpc:create:reply:${requestId}`, (raw: unknown) => {
129
+ unsub(); clearTimeout(timer);
130
+ const reply = raw as { success: boolean; data?: { id: string }; error?: string };
131
+ if (reply.success && reply.data) resolve(reply.data.id);
132
+ else resolve(undefined);
133
+ });
134
+ pi.events.emit("tasks:rpc:create", {
135
+ requestId,
136
+ subject: entry.prompt.slice(0, 80),
137
+ description: `Auto-created from loop #${entry.id}`,
138
+ metadata: { loopId: entry.id, trigger: entry.trigger },
139
+ });
116
140
  });
117
- });
118
- return taskId;
119
- } catch {
120
- return undefined;
141
+ return taskId;
142
+ } catch {
143
+ return undefined;
144
+ }
121
145
  }
146
+ if (!nativeTaskStore) return undefined;
147
+ const task = nativeTaskStore.create(entry.prompt.slice(0, 80), `Auto-created from loop #${entry.id}`, {
148
+ loopId: entry.id,
149
+ trigger: entry.trigger,
150
+ });
151
+ widget.update();
152
+ return task.id;
122
153
  }
123
154
 
124
155
  async function hasPendingTasks(): Promise<number> {
125
- if (!tasksAvailable) return -1;
126
- try {
127
- const requestId = randomUUID();
128
- const count = await new Promise<number>((resolve) => {
129
- const timer = setTimeout(() => { unsub(); resolve(-1); }, 3000);
130
- const unsub = pi.events.on(`tasks:rpc:pending:reply:${requestId}`, (raw: unknown) => {
131
- unsub(); clearTimeout(timer);
132
- const reply = raw as { success: boolean; data?: { pending: number }; error?: string };
133
- resolve(reply.success && reply.data ? reply.data.pending : -1);
156
+ if (tasksAvailable) {
157
+ try {
158
+ const requestId = randomUUID();
159
+ const count = await new Promise<number>((resolve) => {
160
+ const timer = setTimeout(() => { unsub(); resolve(-1); }, 3000);
161
+ const unsub = pi.events.on(`tasks:rpc:pending:reply:${requestId}`, (raw: unknown) => {
162
+ unsub(); clearTimeout(timer);
163
+ const reply = raw as { success: boolean; data?: { pending: number }; error?: string };
164
+ resolve(reply.success && reply.data ? reply.data.pending : -1);
165
+ });
166
+ pi.events.emit("tasks:rpc:pending", { requestId });
134
167
  });
135
- pi.events.emit("tasks:rpc:pending", { requestId });
136
- });
137
- return count;
138
- } catch {
139
- return -1;
168
+ return count;
169
+ } catch {
170
+ return -1;
171
+ }
172
+ }
173
+ return nativeTaskStore ? nativeTaskStore.pendingCount() : -1;
174
+ }
175
+
176
+ async function cleanDoneTasks(): Promise<void> {
177
+ if (tasksAvailable) {
178
+ try {
179
+ const requestId = randomUUID();
180
+ await new Promise<void>((resolve) => {
181
+ const timer = setTimeout(() => { unsub(); resolve(); }, 3000);
182
+ const unsub = pi.events.on(`tasks:rpc:clean:reply:${requestId}`, () => {
183
+ unsub(); clearTimeout(timer);
184
+ debug("tasks:rpc:clean — done tasks swept");
185
+ resolve();
186
+ });
187
+ pi.events.emit("tasks:rpc:clean", { requestId });
188
+ });
189
+ } catch { /* timeout or error, ignore */ }
190
+ return;
191
+ }
192
+ if (nativeTaskStore) {
193
+ nativeTaskStore.sweepCompleted();
194
+ widget.update();
195
+ }
196
+ }
197
+
198
+ let agentRunning = false;
199
+ const pendingNotifications = new Map<string, PendingNotification>();
200
+ let flushPromise: Promise<void> | undefined;
201
+
202
+ function buildLoopFireMessage(data: LoopFireEvent): string {
203
+ const triggerInfo = typeof data.trigger === "string"
204
+ ? data.trigger
205
+ : data.trigger?.type === "cron"
206
+ ? `schedule: ${data.trigger.schedule}`
207
+ : data.trigger?.type === "event"
208
+ ? `event: ${data.trigger.source}`
209
+ : "hybrid";
210
+
211
+ const loopId = data.loopId || "?";
212
+ const prompt = data.prompt || "loop fired";
213
+ const constraint = data.readOnly
214
+ ? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
215
+ : "";
216
+
217
+ return [
218
+ `[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
219
+ prompt,
220
+ ].join("\n");
221
+ }
222
+
223
+ function buildPendingNotification(data: LoopFireEvent): PendingNotification {
224
+ const key = data.recurring ? `loop:${data.loopId}` : `loop:${data.loopId}:${data.timestamp}`;
225
+ return {
226
+ ...data,
227
+ key,
228
+ message: buildLoopFireMessage(data),
229
+ };
230
+ }
231
+
232
+ async function deliverNotification(notification: PendingNotification): Promise<boolean> {
233
+ if (notification.autoTask) {
234
+ const pending = await hasPendingTasks();
235
+ if (pending === 0) {
236
+ debug(`loop:fire #${notification.loopId} — no pending tasks at delivery time, dropping wake`);
237
+ await cleanDoneTasks();
238
+ return false;
239
+ }
140
240
  }
241
+
242
+ agentRunning = true;
243
+ pi.sendMessage({
244
+ customType: "pi-loop",
245
+ content: notification.message,
246
+ display: false,
247
+ details: {
248
+ loopId: notification.loopId,
249
+ trigger: notification.trigger,
250
+ recurring: notification.recurring,
251
+ readOnly: notification.readOnly,
252
+ autoTask: notification.autoTask,
253
+ timestamp: notification.timestamp,
254
+ },
255
+ }, {
256
+ deliverAs: "steer",
257
+ triggerTurn: true,
258
+ });
259
+ return true;
260
+ }
261
+
262
+ async function flushPendingNotifications(): Promise<void> {
263
+ if (flushPromise) return flushPromise;
264
+
265
+ flushPromise = (async () => {
266
+ if (agentRunning || _latestCtx?.hasPendingMessages()) return;
267
+
268
+ const entries = [...pendingNotifications.entries()]
269
+ .sort(([, left], [, right]) => left.timestamp - right.timestamp);
270
+
271
+ for (const [key, notification] of entries) {
272
+ pendingNotifications.delete(key);
273
+ const delivered = await deliverNotification(notification);
274
+ if (delivered) return;
275
+ }
276
+ })().finally(() => {
277
+ flushPromise = undefined;
278
+ });
279
+
280
+ return flushPromise;
281
+ }
282
+
283
+ async function queueOrDeliverNotification(data: LoopFireEvent): Promise<void> {
284
+ const notification = buildPendingNotification(data);
285
+ pendingNotifications.set(notification.key, notification);
286
+ await flushPendingNotifications();
141
287
  }
142
288
 
143
289
  // ── Loop fire handler ──
@@ -183,8 +329,7 @@ export default function (pi: ExtensionAPI) {
183
329
  store = new LoopStore(path);
184
330
  widget.setStore(store);
185
331
  scheduler = new CronScheduler(store, onLoopFire);
186
- widget.setScheduler(scheduler);
187
- triggerSystem = new TriggerSystem(pi, scheduler, store);
332
+ triggerSystem = new TriggerSystem(pi, scheduler, store, onLoopFire);
188
333
  }
189
334
  storeUpgraded = true;
190
335
  }
@@ -206,6 +351,7 @@ export default function (pi: ExtensionAPI) {
206
351
  _latestCtx = ctx;
207
352
  widget.setUICtx(ctx.ui);
208
353
  upgradeStoreIfNeeded(ctx);
354
+ widget.update();
209
355
  });
210
356
 
211
357
  pi.on("before_agent_start", async (_event, ctx) => {
@@ -213,12 +359,33 @@ export default function (pi: ExtensionAPI) {
213
359
  widget.setUICtx(ctx.ui);
214
360
  upgradeStoreIfNeeded(ctx);
215
361
  showPersistedLoops();
362
+ widget.update();
363
+ });
364
+
365
+ pi.on("agent_start", async (_event, ctx) => {
366
+ agentRunning = true;
367
+ _latestCtx = ctx;
368
+ widget.setUICtx(ctx.ui);
369
+ });
370
+
371
+ pi.on("agent_end", async (_event, ctx) => {
372
+ agentRunning = false;
373
+ _latestCtx = ctx;
374
+ widget.setUICtx(ctx.ui);
375
+ await flushPendingNotifications();
376
+ });
377
+
378
+ pi.on("session_shutdown", async () => {
379
+ agentRunning = false;
380
+ pendingNotifications.clear();
216
381
  });
217
382
 
218
383
  pi.on("session_switch" as any, async (event: SessionSwitchEvent, ctx: ExtensionContext) => {
219
384
  _latestCtx = ctx;
220
385
  widget.setUICtx(ctx.ui);
221
386
  triggerSystem.stop();
387
+ agentRunning = false;
388
+ pendingNotifications.clear();
222
389
 
223
390
  const isResume = event?.reason === "resume";
224
391
  storeUpgraded = false;
@@ -230,43 +397,24 @@ export default function (pi: ExtensionAPI) {
230
397
 
231
398
  upgradeStoreIfNeeded(ctx);
232
399
  showPersistedLoops(isResume);
400
+ widget.update();
233
401
  });
234
402
 
235
- // ── Loop fire handler — sends a user message to re-wake the agent ──
403
+ // ── Loop fire handler — queues an in-memory notification, then injects a custom message when delivery is safe ──
236
404
 
237
405
  pi.events.on("loop:fire", async (event: unknown) => {
238
406
  const data = event as LoopFireEvent;
239
407
 
240
- if (data.recurring && _latestCtx?.hasPendingMessages()) {
241
- debug(`loop:fire #${data.loopId} — agent has pending messages, skipping recurring fire`);
242
- return;
243
- }
244
-
245
408
  if (data.autoTask) {
246
409
  const pending = await hasPendingTasks();
247
410
  if (pending === 0) {
248
- debug(`loop:fire #${data.loopId} — no pending tasks, skipping`);
411
+ debug(`loop:fire #${data.loopId} — no pending tasks, skipping, requesting cleanup`);
412
+ await cleanDoneTasks();
249
413
  return;
250
414
  }
251
415
  }
252
416
 
253
- const triggerInfo = typeof data.trigger === "string"
254
- ? data.trigger
255
- : data.trigger?.type === "cron"
256
- ? `schedule: ${data.trigger.schedule}`
257
- : data.trigger?.type === "event"
258
- ? `event: ${data.trigger.source}`
259
- : `hybrid`;
260
-
261
- const loopId = data.loopId || "?";
262
- const prompt = data.prompt || "loop fired";
263
- 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." : "";
264
- const message = [
265
- `[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
266
- prompt,
267
- ].join("\n");
268
-
269
- pi.sendUserMessage(message, { deliverAs: "followUp" });
417
+ await queueOrDeliverNotification(data);
270
418
  });
271
419
 
272
420
  // ──────────────────────────────────────────────────
@@ -304,7 +452,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
304
452
  - **trigger**: interval like "30s", "5m", "2h", event source, or hybrid spec
305
453
  - **prompt**: what to do when the loop fires (e.g., "check if the build passed")
306
454
  - **recurring**: repeat or fire once (default: true)
307
- - **autoTask**: if pi-tasks is loaded, auto-create a task on each fire
455
+ - **autoTask**: when pi-tasks is loaded or native task fallback is active, auto-create a task on each fire
308
456
  - **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)
309
457
  - **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops`,
310
458
  promptGuidelines: [
@@ -323,7 +471,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
323
471
  "Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
324
472
  "## Task-driven workflows",
325
473
  "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.'",
326
- "When no tasks are pending, the loop skips the follow-up — no tokens burned on empty polls.",
474
+ "When no tasks are pending, the loop skips the wake entirely — no tokens burned on empty polls.",
327
475
  "After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
328
476
  ],
329
477
  parameters: Type.Object({
@@ -401,7 +549,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
401
549
  `Trigger: ${triggerDesc}\n` +
402
550
  `Recurring: ${entry.recurring}\n` +
403
551
  (entry.autoTask ? `Auto-task: enabled\n` : "") +
404
- (tasksAvailable ? "" : "(pi-tasks not detected — autoTask will have no effect)\n") +
552
+ ((tasksAvailable || nativeTasksRegistered) ? "" : "(task system not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available)\n") +
405
553
  `ID: ${entry.id} (use LoopDelete to cancel)`
406
554
  ));
407
555
  },
@@ -474,7 +622,7 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
474
622
  ? scheduler.nextFire(entry.id)
475
623
  : undefined;
476
624
 
477
- const statusIcon = entry.status === "active" ? "" : entry.status === "paused" ? "" : "";
625
+ const statusIcon = entry.status === "active" ? "*" : entry.status === "paused" ? "-" : "x";
478
626
  let line = `${statusIcon} #${entry.id} [${entry.status}] ${entry.prompt.slice(0, 60)}`;
479
627
  line += ` (${triggerDesc})`;
480
628
  if (nextFire) {
@@ -540,7 +688,7 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
540
688
 
541
689
  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).
542
690
 
543
- 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.
691
+ 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.
544
692
 
545
693
  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.
546
694
 
@@ -554,7 +702,7 @@ Default to MonitorCreate for any long-running or background work:\n- Watch a CI/
554
702
 
555
703
  ## onDone — auto-notify on completion
556
704
 
557
- 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"`,
705
+ 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"`,
558
706
  promptGuidelines: [
559
707
  "Default to MonitorCreate for any long-running or background command — releases the agent to keep working on other tasks in parallel.",
560
708
  "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.",
@@ -564,7 +712,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
564
712
  command: Type.String({ description: "Shell command to run in background" }),
565
713
  description: Type.Optional(Type.String({ description: "Human-readable description" })),
566
714
  timeout: Type.Optional(Type.Number({ description: "Auto-stop after N ms (default: 300000, 0 = no timeout)", default: 300000 })),
567
- 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." })),
715
+ 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." })),
568
716
  }),
569
717
 
570
718
  execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
@@ -607,7 +755,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
607
755
 
608
756
  const lines: string[] = [];
609
757
  for (const m of monitors) {
610
- const icon = m.status === "running" ? "" : m.status === "completed" ? "" : "";
758
+ const icon = m.status === "running" ? ">" : m.status === "completed" ? "ok" : "!!";
611
759
  const age = Date.now() - m.startedAt;
612
760
  const ageStr = formatRemaining(age);
613
761
  let line = `${icon} #${m.id} [${m.status}] ${m.command.slice(0, 60)} — ${m.outputLines} lines (${ageStr})`;
@@ -744,45 +892,45 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
744
892
  async function viewLoops(ui: ExtensionUIContext) {
745
893
  const loops = store.list();
746
894
  if (loops.length === 0) {
747
- await ui.select("No active loops", [" Back"]);
895
+ await ui.select("No active loops", ["< Back"]);
748
896
  return;
749
897
  }
750
898
 
751
899
  const choices = loops.map((l: LoopEntry) => {
752
- const icon = l.status === "active" ? "" : l.status === "paused" ? "" : "";
900
+ const icon = l.status === "active" ? "*" : l.status === "paused" ? "-" : "x";
753
901
  const triggerDesc = l.trigger.type === "cron" ? `cron: ${l.trigger.schedule}` : l.trigger.type === "event" ? `event: ${l.trigger.source}` : `hybrid: ${l.trigger.cron}`;
754
902
  return `${icon} #${l.id} [${l.status}] ${l.prompt.slice(0, 50)} (${triggerDesc})`;
755
903
  });
756
- choices.push(" Back");
904
+ choices.push("< Back");
757
905
 
758
906
  const selected = await ui.select("Active Loops", choices);
759
- if (!selected || selected === " Back") return;
907
+ if (!selected || selected === "< Back") return;
760
908
 
761
909
  const match = selected.match(/#(\d+)/);
762
910
  if (match) {
763
911
  const entry = store.get(match[1]);
764
912
  if (entry) {
765
- const actions = [" Delete"];
766
- if (entry.status === "active") actions.unshift(" Pause");
767
- else if (entry.status === "paused") actions.unshift(" Resume");
768
- actions.push(" Back");
913
+ const actions = ["x Delete"];
914
+ if (entry.status === "active") actions.unshift("- Pause");
915
+ else if (entry.status === "paused") actions.unshift("* Resume");
916
+ actions.push("< Back");
769
917
 
770
918
  const action = await ui.select(
771
919
  `#${entry.id}: ${entry.prompt}\nTrigger: ${JSON.stringify(entry.trigger)}`,
772
920
  actions,
773
921
  );
774
922
 
775
- if (action === " Delete") {
923
+ if (action === "x Delete") {
776
924
  triggerSystem.remove(entry.id);
777
925
  store.delete(entry.id);
778
926
  widget.update();
779
927
  ui.notify(`Loop #${entry.id} deleted`, "info");
780
- } else if (action === " Pause") {
928
+ } else if (action === "- Pause") {
781
929
  store.update(entry.id, { status: "paused" });
782
930
  triggerSystem.remove(entry.id);
783
931
  widget.update();
784
932
  ui.notify(`Loop #${entry.id} paused`, "info");
785
- } else if (action === " Resume") {
933
+ } else if (action === "* Resume") {
786
934
  store.update(entry.id, { status: "active" });
787
935
  triggerSystem.add(entry);
788
936
  widget.update();
@@ -799,4 +947,193 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
799
947
  const active = loops.filter(l => l.status === "active").length;
800
948
  ui.notify(`${active}/${loops.length} active loops (max 25)`, "info");
801
949
  }
950
+
951
+ async function createNativeTaskInteractively(ui: ExtensionUIContext) {
952
+ if (!nativeTaskStore) {
953
+ ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
954
+ return;
955
+ }
956
+
957
+ const subject = await ui.input("Task subject");
958
+ if (!subject) return;
959
+ const description = await ui.input("Task description") || subject;
960
+ const entry = nativeTaskStore.create(subject, description);
961
+ widget.update();
962
+ ui.notify(`Task #${entry.id} created`, "info");
963
+ }
964
+
965
+ async function viewNativeTasks(ui: ExtensionUIContext): Promise<void> {
966
+ if (!nativeTaskStore) {
967
+ ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
968
+ return;
969
+ }
970
+
971
+ const tasks = nativeTaskStore.list();
972
+ const choices = tasks.map((task) => {
973
+ const icon = task.status === "in_progress" ? ">" : task.status === "completed" ? "ok" : "*";
974
+ return `${icon} #${task.id} [${task.status}] ${task.subject.slice(0, 60)}`;
975
+ });
976
+ choices.unshift("+ Create task");
977
+ choices.push("< Back");
978
+
979
+ const selected = await ui.select("Native Tasks", choices);
980
+ if (!selected || selected === "< Back") return;
981
+ if (selected === "+ Create task") {
982
+ await createNativeTaskInteractively(ui);
983
+ return viewNativeTasks(ui);
984
+ }
985
+
986
+ const match = selected.match(/#(\d+)/);
987
+ if (!match) return viewNativeTasks(ui);
988
+
989
+ const task = nativeTaskStore.get(match[1]);
990
+ if (!task) return viewNativeTasks(ui);
991
+
992
+ const actions = ["x Delete"];
993
+ if (task.status === "pending") {
994
+ actions.unshift("ok Complete");
995
+ actions.unshift("> Start");
996
+ } else if (task.status === "in_progress") {
997
+ actions.unshift("ok Complete");
998
+ actions.unshift("* Return to pending");
999
+ } else {
1000
+ actions.unshift("* Reopen");
1001
+ }
1002
+ actions.push("< Back");
1003
+
1004
+ const action = await ui.select(`#${task.id}: ${task.subject}\n\n${task.description}`, actions);
1005
+ if (!action || action === "< Back") return viewNativeTasks(ui);
1006
+
1007
+ if (action === "x Delete") {
1008
+ nativeTaskStore.delete(task.id);
1009
+ ui.notify(`Task #${task.id} deleted`, "info");
1010
+ } else if (action === "> Start") {
1011
+ nativeTaskStore.update(task.id, { status: "in_progress" });
1012
+ ui.notify(`Task #${task.id} started`, "info");
1013
+ } else if (action === "ok Complete") {
1014
+ nativeTaskStore.update(task.id, { status: "completed" });
1015
+ ui.notify(`Task #${task.id} completed`, "info");
1016
+ } else if (action === "* Return to pending" || action === "* Reopen") {
1017
+ nativeTaskStore.update(task.id, { status: "pending" });
1018
+ ui.notify(`Task #${task.id} reopened`, "info");
1019
+ }
1020
+
1021
+ widget.update();
1022
+ return viewNativeTasks(ui);
1023
+ }
1024
+
1025
+ // ── Native task tools (only when pi-tasks is absent) ──
1026
+
1027
+ setTimeout(async () => {
1028
+ if (tasksAvailable || nativeTasksRegistered) return;
1029
+ nativeTaskStore = new TaskStore(resolveTaskStorePath());
1030
+ nativeTasksRegistered = true;
1031
+ const taskStore = nativeTaskStore;
1032
+
1033
+ pi.registerCommand("tasks", {
1034
+ description: "View or manage native pi-loop tasks when pi-tasks is not installed",
1035
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
1036
+ const trimmed = args.trim();
1037
+ if (!nativeTaskStore) {
1038
+ ctx.ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
1039
+ return;
1040
+ }
1041
+ if (trimmed) {
1042
+ const entry = nativeTaskStore.create(trimmed.slice(0, 80), trimmed);
1043
+ widget.update();
1044
+ ctx.ui.notify(`Task #${entry.id} created`, "info");
1045
+ return;
1046
+ }
1047
+ await viewNativeTasks(ctx.ui);
1048
+ },
1049
+ });
1050
+
1051
+ pi.registerTool({
1052
+ name: "TaskCreate",
1053
+ label: "TaskCreate",
1054
+ description: `Create a task for tracking work across turns. Use when you need to track progress on complex multi-step tasks.
1055
+
1056
+ Fields:
1057
+ - subject: brief actionable title
1058
+ - description: detailed requirements
1059
+ - metadata: optional tags/metadata`,
1060
+ parameters: Type.Object({
1061
+ subject: Type.String({ description: "Brief actionable title for the task" }),
1062
+ description: Type.String({ description: "Detailed description of what needs to be done" }),
1063
+ }),
1064
+ execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1065
+ const entry = taskStore.create(params.subject, params.description);
1066
+ widget.update();
1067
+ return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}`));
1068
+ },
1069
+ });
1070
+
1071
+ pi.registerTool({
1072
+ name: "TaskList",
1073
+ label: "TaskList",
1074
+ description: `List all tasks with status. Use to check progress and find available work.`,
1075
+ parameters: Type.Object({}),
1076
+ execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
1077
+ const tasks = taskStore.list();
1078
+ if (tasks.length === 0) return Promise.resolve(textResult("No tasks."));
1079
+
1080
+ const lines: string[] = [];
1081
+ const statuses: Record<"pending" | "in_progress" | "completed", number> = {
1082
+ pending: 0,
1083
+ in_progress: 0,
1084
+ completed: 0,
1085
+ };
1086
+ for (const t of tasks) {
1087
+ statuses[t.status]++;
1088
+ const icon = t.status === "completed" ? "ok" : t.status === "in_progress" ? ">" : "*";
1089
+ lines.push(`${icon} #${t.id} [${t.status}] ${t.subject.slice(0, 80)}`);
1090
+ }
1091
+ lines.unshift(`${tasks.length} tasks (${statuses.pending} pending, ${statuses.in_progress} in progress, ${statuses.completed} done)`);
1092
+ return Promise.resolve(textResult(lines.join("\n")));
1093
+ },
1094
+ });
1095
+
1096
+ pi.registerTool({
1097
+ name: "TaskUpdate",
1098
+ label: "TaskUpdate",
1099
+ description: `Update task status or details. Set status to "in_progress" before starting work, "completed" when done.
1100
+
1101
+ Statuses: pending → in_progress → completed`,
1102
+ parameters: Type.Object({
1103
+ id: Type.String({ description: "Task ID to update" }),
1104
+ status: Type.Optional(Type.String({ description: "New status", enum: ["pending", "in_progress", "completed"] })),
1105
+ subject: Type.Optional(Type.String({ description: "New title" })),
1106
+ description: Type.Optional(Type.String({ description: "New description" })),
1107
+ }),
1108
+ execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1109
+ const { id, status, subject, description } = params;
1110
+ const entry = taskStore.update(id, {
1111
+ status: status as "pending" | "in_progress" | "completed" | undefined,
1112
+ subject,
1113
+ description,
1114
+ });
1115
+ if (!entry) return Promise.resolve(textResult(`Task #${id} not found`));
1116
+ widget.update();
1117
+ const statusMsg = status ? ` → ${status}` : "";
1118
+ return Promise.resolve(textResult(`Task #${id} updated${statusMsg}`));
1119
+ },
1120
+ });
1121
+
1122
+ pi.registerTool({
1123
+ name: "TaskDelete",
1124
+ label: "TaskDelete",
1125
+ description: `Delete a task by ID. Use for cleaning up completed or irrelevant tasks.`,
1126
+ parameters: Type.Object({
1127
+ id: Type.String({ description: "Task ID to delete" }),
1128
+ }),
1129
+ execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1130
+ const deleted = taskStore.delete(params.id);
1131
+ widget.update();
1132
+ if (deleted) return Promise.resolve(textResult(`Task #${params.id} deleted`));
1133
+ return Promise.resolve(textResult(`Task #${params.id} not found`));
1134
+ },
1135
+ });
1136
+
1137
+ debug("native task tools registered (pi-tasks not detected)");
1138
+ }, 6000);
802
1139
  }