@trevonistrevon/pi-loop 0.3.0 → 0.4.0

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.
@@ -0,0 +1,59 @@
1
+ # Differential Review Report
2
+
3
+ ## Scope
4
+ Reviewed recent uncommitted changes in:
5
+ - `src/index.ts`
6
+ - `src/ui/widget.ts`
7
+ - `test/index.test.ts`
8
+ - `test/widget.test.ts`
9
+ - `README.md`
10
+
11
+ ## Risk Summary
12
+ - **Overall risk:** Medium
13
+ - **Primary areas affected:** runtime task fallback routing, UI/widget behavior, interactive command registration
14
+ - **Security impact:** Low direct security impact; main risks are state-management regressions and missing coverage around command behavior
15
+
16
+ ## Findings
17
+
18
+ ### 🟡 Warning
19
+ `src/index.ts` - Native task fallback is covered for registration and file persistence, but not for interactive `/tasks` flows or loop-driven task lifecycle integration.
20
+
21
+ **Why it matters:**
22
+ The new `/tasks` command and interactive actions (`Start`, `Complete`, `Reopen`, `Delete`) are stateful and user-facing. Regressions here would not be caught by current tests. Similarly, native fallback behavior for `autoTask`, `hasPendingTasks()`, and `cleanDoneTasks()` is only indirectly covered.
23
+
24
+ **Recommended follow-up:**
25
+ Add tests for:
26
+ - `/tasks` command registration and quick-create path
27
+ - native `autoTask` creation path
28
+ - `hasPendingTasks()` using native fallback
29
+ - completed-task sweep behavior for native tasks
30
+
31
+ ### 🟢 Suggestion
32
+ `src/ui/widget.ts` - Compact widget behavior is appropriately simplified and focus-oriented.
33
+
34
+ **Good pattern:**
35
+ The single-line status approach reduces noise and matches the intended UX. Showing only active/next task focus text is a good constraint.
36
+
37
+ ## Test Coverage Review
38
+
39
+ ### Covered well
40
+ - Native tool registration when `pi-tasks` is absent/present
41
+ - Native task persistence path (`.pi/tasks/tasks.json`)
42
+ - Compact widget states:
43
+ - `none`
44
+ - monitor-only count
45
+ - loop + monitor count
46
+ - active task focus text
47
+ - next task focus text
48
+ - retained widget rendering after content clears
49
+
50
+ ### Coverage gaps
51
+ - No tests for `/tasks` command behavior beyond registration
52
+ - No direct tests for native `TaskUpdate` → widget focus transitions
53
+ - No direct tests for native `cleanDoneTasks()` sweep behavior
54
+ - No end-to-end tests for `LoopCreate(autoTask: true)` using native fallback
55
+
56
+ ## Review Verdict
57
+ - **No merge-blocking issues found**
58
+ - Safe to release after version bump and validation
59
+ - Recommended follow-up: add native task lifecycle command/integration tests in a later pass
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <p align="center">
2
2
  <h1 align="center">@trevonistrevon/pi-loop</h1>
3
- <h6 align="center">Cron and event loops for the pi coding agent. Background monitors, scheduled re-wakes, pi-tasks integration.</h6>
3
+ <h6 align="center">Cron and event loops for the pi coding agent. Background monitors, scheduled re-wakes, pi-tasks integration, and native task fallback.</h6>
4
4
  </p>
5
5
 
6
6
  ## Install
@@ -11,29 +11,45 @@ pi install @trevonistrevon/pi-loop
11
11
 
12
12
  ## Quick start
13
13
 
14
- ```
14
+ ```text
15
15
  LoopCreate trigger="5m" prompt="Check if the build passed"
16
16
  LoopCreate trigger="tool_execution_start" prompt="Log the tool being used" triggerType="event"
17
17
  LoopList
18
18
  LoopDelete id="1"
19
19
  ```
20
20
 
21
- ```
21
+ ```text
22
22
  MonitorCreate command="tail -n0 -f build.log" description="Watch build"
23
23
  MonitorCreate command="python train.py" onDone="Analyze results and report best loss"
24
24
  MonitorList
25
25
  MonitorStop monitorId="1"
26
26
  ```
27
27
 
28
+ When `pi-tasks` is not installed, `pi-loop` also exposes native task tools after startup detection:
29
+
30
+ ```text
31
+ TaskCreate subject="Fix deploy polling" description="Switch deploy check to event-driven loop"
32
+ TaskList
33
+ TaskUpdate id="1" status="in_progress"
34
+ TaskDelete id="1"
35
+ ```
36
+
28
37
  ## Commands
29
38
 
30
39
  `/loop [interval] [prompt]` — interactive loop creation.
31
40
 
32
- ```
41
+ ```text
33
42
  /loop # menu
34
43
  /loop 5m check the deploy # 5-minute cron loop
35
44
  ```
36
45
 
46
+ `/tasks` — interactive native task viewer/manager, only registered when `pi-tasks` is absent.
47
+
48
+ ```text
49
+ /tasks # open native task viewer
50
+ /tasks Write README updates # quick-create native task
51
+ ```
52
+
37
53
  ## Tools
38
54
 
39
55
  | Tool | What it does |
@@ -44,16 +60,44 @@ MonitorStop monitorId="1"
44
60
  | `MonitorCreate` | Run a background command, stream output as `monitor:output` events. Use `onDone` for auto-notify on completion |
45
61
  | `MonitorList` | Show monitors with status, uptime, and output line count |
46
62
  | `MonitorStop` | Stop a monitor (SIGTERM → 5s → SIGKILL) |
63
+ | `TaskCreate` | Create a native fallback task when `pi-tasks` is absent |
64
+ | `TaskList` | List native fallback tasks |
65
+ | `TaskUpdate` | Update native fallback task status/details |
66
+ | `TaskDelete` | Delete a native fallback task |
47
67
 
48
68
  Trigger types: `cron` (`5m`, `1h`, `0 9 * * 1-5`), `event` (any pi event source), or `hybrid` (both, debounced).
49
69
 
50
- ## pi-tasks
70
+ ## Tasks
71
+
72
+ ### With `pi-tasks`
51
73
 
52
74
  Works with [@tintinweb/pi-tasks](https://github.com/tintinweb/pi-tasks). Pass `autoTask: true` on `LoopCreate` and each loop fire auto-creates a tracked task. Detection happens over pi's event bus — no manual wiring.
53
75
 
76
+ ### Without `pi-tasks`
77
+
78
+ If `pi-tasks` does not respond during startup detection, `pi-loop` registers a native fallback task system for the session:
79
+
80
+ - persistent store at `.pi/tasks/tasks.json`
81
+ - `TaskCreate`, `TaskList`, `TaskUpdate`, `TaskDelete`
82
+ - `/tasks` interactive viewer
83
+ - compact widget task tracking
84
+
85
+ This fallback is session-sticky: `pi-loop` decides once at startup whether `pi-tasks` or native tasks own task management for that session.
86
+
54
87
  ## Widget
55
88
 
56
- Active loops and monitors show in a persistent TUI widget above the editor.
89
+ `pi-loop` keeps a compact persistent TUI widget above the editor.
90
+
91
+ It now shows a single focus-friendly status line such as:
92
+
93
+ ```text
94
+ none
95
+ 1 loop · 1 monitor
96
+ 2 tasks | active: Fix deploy polling
97
+ 1 loop · 2 monitors · 3 tasks | next: Update README
98
+ ```
99
+
100
+ Only task counts and the single active/next task are shown in the widget so attention stays on what is currently happening. Use `LoopList`, `MonitorList`, and `/tasks` for detail.
57
101
 
58
102
  ## Configuration
59
103
 
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,53 +97,87 @@ 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);
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
+ });
95
122
  });
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 },
101
- });
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;
125
177
  }
126
- catch {
127
- return -1;
178
+ if (nativeTaskStore) {
179
+ nativeTaskStore.sweepCompleted();
180
+ widget.update();
128
181
  }
129
182
  }
130
183
  // ── Loop fire handler ──
@@ -132,7 +185,7 @@ export default function (pi) {
132
185
  debug(`loop:fire #${entry.id}`, { prompt: entry.prompt.slice(0, 50) });
133
186
  if (entry.maxFires && (entry.fireCount ?? 0) >= entry.maxFires) {
134
187
  debug(`loop #${entry.id} — reached maxFires ${entry.maxFires}, expiring`);
135
- store.update(entry.id, { status: "expired" });
188
+ store.delete(entry.id);
136
189
  return;
137
190
  }
138
191
  store.update(entry.id, { fireCount: (entry.fireCount ?? 0) + 1 });
@@ -165,8 +218,7 @@ export default function (pi) {
165
218
  store = new LoopStore(path);
166
219
  widget.setStore(store);
167
220
  scheduler = new CronScheduler(store, onLoopFire);
168
- widget.setScheduler(scheduler);
169
- triggerSystem = new TriggerSystem(pi, scheduler, store);
221
+ triggerSystem = new TriggerSystem(pi, scheduler, store, onLoopFire);
170
222
  }
171
223
  storeUpgraded = true;
172
224
  }
@@ -187,12 +239,14 @@ export default function (pi) {
187
239
  _latestCtx = ctx;
188
240
  widget.setUICtx(ctx.ui);
189
241
  upgradeStoreIfNeeded(ctx);
242
+ widget.update();
190
243
  });
191
244
  pi.on("before_agent_start", async (_event, ctx) => {
192
245
  _latestCtx = ctx;
193
246
  widget.setUICtx(ctx.ui);
194
247
  upgradeStoreIfNeeded(ctx);
195
248
  showPersistedLoops();
249
+ widget.update();
196
250
  });
197
251
  pi.on("session_switch", async (event, ctx) => {
198
252
  _latestCtx = ctx;
@@ -206,6 +260,7 @@ export default function (pi) {
206
260
  }
207
261
  upgradeStoreIfNeeded(ctx);
208
262
  showPersistedLoops(isResume);
263
+ widget.update();
209
264
  });
210
265
  // ── Loop fire handler — sends a user message to re-wake the agent ──
211
266
  pi.events.on("loop:fire", async (event) => {
@@ -217,7 +272,8 @@ export default function (pi) {
217
272
  if (data.autoTask) {
218
273
  const pending = await hasPendingTasks();
219
274
  if (pending === 0) {
220
- debug(`loop:fire #${data.loopId} — no pending tasks, skipping`);
275
+ debug(`loop:fire #${data.loopId} — no pending tasks, skipping, requesting cleanup`);
276
+ cleanDoneTasks();
221
277
  return;
222
278
  }
223
279
  }
@@ -345,7 +401,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
345
401
  if (monitor && monitor.status !== "running") {
346
402
  debug(`loop #${entry.id} — monitor #${monitorId} already ${monitor.status}, expiring`);
347
403
  triggerSystem.remove(entry.id);
348
- store.update(entry.id, { status: "expired" });
404
+ store.delete(entry.id);
349
405
  }
350
406
  }
351
407
  }
@@ -371,7 +427,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
371
427
  if (monitor && monitor.status !== "running") {
372
428
  debug(`onDone loop #${doneLoop.id} — monitor #${monitorId} already ${monitor.status}, expiring`);
373
429
  triggerSystem.remove(doneLoop.id);
374
- store.update(doneLoop.id, { status: "expired" });
430
+ store.delete(doneLoop.id);
375
431
  }
376
432
  }
377
433
  function validateTrigger(trigger) {
@@ -430,7 +486,7 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
430
486
  const nextFire = entry.trigger.type !== "event"
431
487
  ? scheduler.nextFire(entry.id)
432
488
  : undefined;
433
- const statusIcon = entry.status === "active" ? "" : entry.status === "paused" ? "" : "";
489
+ const statusIcon = entry.status === "active" ? "*" : entry.status === "paused" ? "-" : "x";
434
490
  let line = `${statusIcon} #${entry.id} [${entry.status}] ${entry.prompt.slice(0, 60)}`;
435
491
  line += ` (${triggerDesc})`;
436
492
  if (nextFire) {
@@ -550,7 +606,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
550
606
  return Promise.resolve(textResult("No monitors running."));
551
607
  const lines = [];
552
608
  for (const m of monitors) {
553
- const icon = m.status === "running" ? "" : m.status === "completed" ? "" : "";
609
+ const icon = m.status === "running" ? ">" : m.status === "completed" ? "ok" : "!!";
554
610
  const age = Date.now() - m.startedAt;
555
611
  const ageStr = formatRemaining(age);
556
612
  let line = `${icon} #${m.id} [${m.status}] ${m.command.slice(0, 60)} — ${m.outputLines} lines (${ageStr})`;
@@ -680,42 +736,42 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
680
736
  async function viewLoops(ui) {
681
737
  const loops = store.list();
682
738
  if (loops.length === 0) {
683
- await ui.select("No active loops", [" Back"]);
739
+ await ui.select("No active loops", ["< Back"]);
684
740
  return;
685
741
  }
686
742
  const choices = loops.map((l) => {
687
- const icon = l.status === "active" ? "" : l.status === "paused" ? "" : "";
743
+ const icon = l.status === "active" ? "*" : l.status === "paused" ? "-" : "x";
688
744
  const triggerDesc = l.trigger.type === "cron" ? `cron: ${l.trigger.schedule}` : l.trigger.type === "event" ? `event: ${l.trigger.source}` : `hybrid: ${l.trigger.cron}`;
689
745
  return `${icon} #${l.id} [${l.status}] ${l.prompt.slice(0, 50)} (${triggerDesc})`;
690
746
  });
691
- choices.push(" Back");
747
+ choices.push("< Back");
692
748
  const selected = await ui.select("Active Loops", choices);
693
- if (!selected || selected === " Back")
749
+ if (!selected || selected === "< Back")
694
750
  return;
695
751
  const match = selected.match(/#(\d+)/);
696
752
  if (match) {
697
753
  const entry = store.get(match[1]);
698
754
  if (entry) {
699
- const actions = [" Delete"];
755
+ const actions = ["x Delete"];
700
756
  if (entry.status === "active")
701
- actions.unshift(" Pause");
757
+ actions.unshift("- Pause");
702
758
  else if (entry.status === "paused")
703
- actions.unshift(" Resume");
704
- actions.push(" Back");
759
+ actions.unshift("* Resume");
760
+ actions.push("< Back");
705
761
  const action = await ui.select(`#${entry.id}: ${entry.prompt}\nTrigger: ${JSON.stringify(entry.trigger)}`, actions);
706
- if (action === " Delete") {
762
+ if (action === "x Delete") {
707
763
  triggerSystem.remove(entry.id);
708
764
  store.delete(entry.id);
709
765
  widget.update();
710
766
  ui.notify(`Loop #${entry.id} deleted`, "info");
711
767
  }
712
- else if (action === " Pause") {
768
+ else if (action === "- Pause") {
713
769
  store.update(entry.id, { status: "paused" });
714
770
  triggerSystem.remove(entry.id);
715
771
  widget.update();
716
772
  ui.notify(`Loop #${entry.id} paused`, "info");
717
773
  }
718
- else if (action === " Resume") {
774
+ else if (action === "* Resume") {
719
775
  store.update(entry.id, { status: "active" });
720
776
  triggerSystem.add(entry);
721
777
  widget.update();
@@ -730,4 +786,187 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
730
786
  const active = loops.filter(l => l.status === "active").length;
731
787
  ui.notify(`${active}/${loops.length} active loops (max 25)`, "info");
732
788
  }
789
+ async function createNativeTaskInteractively(ui) {
790
+ if (!nativeTaskStore) {
791
+ ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
792
+ return;
793
+ }
794
+ const subject = await ui.input("Task subject");
795
+ if (!subject)
796
+ return;
797
+ const description = await ui.input("Task description") || subject;
798
+ const entry = nativeTaskStore.create(subject, description);
799
+ widget.update();
800
+ ui.notify(`Task #${entry.id} created`, "info");
801
+ }
802
+ async function viewNativeTasks(ui) {
803
+ if (!nativeTaskStore) {
804
+ ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
805
+ return;
806
+ }
807
+ const tasks = nativeTaskStore.list();
808
+ const choices = tasks.map((task) => {
809
+ const icon = task.status === "in_progress" ? ">" : task.status === "completed" ? "ok" : "*";
810
+ return `${icon} #${task.id} [${task.status}] ${task.subject.slice(0, 60)}`;
811
+ });
812
+ choices.unshift("+ Create task");
813
+ choices.push("< Back");
814
+ const selected = await ui.select("Native Tasks", choices);
815
+ if (!selected || selected === "< Back")
816
+ return;
817
+ if (selected === "+ Create task") {
818
+ await createNativeTaskInteractively(ui);
819
+ return viewNativeTasks(ui);
820
+ }
821
+ const match = selected.match(/#(\d+)/);
822
+ if (!match)
823
+ return viewNativeTasks(ui);
824
+ const task = nativeTaskStore.get(match[1]);
825
+ if (!task)
826
+ return viewNativeTasks(ui);
827
+ const actions = ["x Delete"];
828
+ if (task.status === "pending") {
829
+ actions.unshift("ok Complete");
830
+ actions.unshift("> Start");
831
+ }
832
+ else if (task.status === "in_progress") {
833
+ actions.unshift("ok Complete");
834
+ actions.unshift("* Return to pending");
835
+ }
836
+ else {
837
+ actions.unshift("* Reopen");
838
+ }
839
+ actions.push("< Back");
840
+ const action = await ui.select(`#${task.id}: ${task.subject}\n\n${task.description}`, actions);
841
+ if (!action || action === "< Back")
842
+ return viewNativeTasks(ui);
843
+ if (action === "x Delete") {
844
+ nativeTaskStore.delete(task.id);
845
+ ui.notify(`Task #${task.id} deleted`, "info");
846
+ }
847
+ else if (action === "> Start") {
848
+ nativeTaskStore.update(task.id, { status: "in_progress" });
849
+ ui.notify(`Task #${task.id} started`, "info");
850
+ }
851
+ else if (action === "ok Complete") {
852
+ nativeTaskStore.update(task.id, { status: "completed" });
853
+ ui.notify(`Task #${task.id} completed`, "info");
854
+ }
855
+ else if (action === "* Return to pending" || action === "* Reopen") {
856
+ nativeTaskStore.update(task.id, { status: "pending" });
857
+ ui.notify(`Task #${task.id} reopened`, "info");
858
+ }
859
+ widget.update();
860
+ return viewNativeTasks(ui);
861
+ }
862
+ // ── Native task tools (only when pi-tasks is absent) ──
863
+ setTimeout(async () => {
864
+ if (tasksAvailable || nativeTasksRegistered)
865
+ return;
866
+ nativeTaskStore = new TaskStore(resolveTaskStorePath());
867
+ nativeTasksRegistered = true;
868
+ const taskStore = nativeTaskStore;
869
+ pi.registerCommand("tasks", {
870
+ description: "View or manage native pi-loop tasks when pi-tasks is not installed",
871
+ handler: async (args, ctx) => {
872
+ const trimmed = args.trim();
873
+ if (!nativeTaskStore) {
874
+ ctx.ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
875
+ return;
876
+ }
877
+ if (trimmed) {
878
+ const entry = nativeTaskStore.create(trimmed.slice(0, 80), trimmed);
879
+ widget.update();
880
+ ctx.ui.notify(`Task #${entry.id} created`, "info");
881
+ return;
882
+ }
883
+ await viewNativeTasks(ctx.ui);
884
+ },
885
+ });
886
+ pi.registerTool({
887
+ name: "TaskCreate",
888
+ label: "TaskCreate",
889
+ description: `Create a task for tracking work across turns. Use when you need to track progress on complex multi-step tasks.
890
+
891
+ Fields:
892
+ - subject: brief actionable title
893
+ - description: detailed requirements
894
+ - metadata: optional tags/metadata`,
895
+ parameters: Type.Object({
896
+ subject: Type.String({ description: "Brief actionable title for the task" }),
897
+ description: Type.String({ description: "Detailed description of what needs to be done" }),
898
+ }),
899
+ execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
900
+ const entry = taskStore.create(params.subject, params.description);
901
+ widget.update();
902
+ return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}`));
903
+ },
904
+ });
905
+ pi.registerTool({
906
+ name: "TaskList",
907
+ label: "TaskList",
908
+ description: `List all tasks with status. Use to check progress and find available work.`,
909
+ parameters: Type.Object({}),
910
+ execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
911
+ const tasks = taskStore.list();
912
+ if (tasks.length === 0)
913
+ return Promise.resolve(textResult("No tasks."));
914
+ const lines = [];
915
+ const statuses = {
916
+ pending: 0,
917
+ in_progress: 0,
918
+ completed: 0,
919
+ };
920
+ for (const t of tasks) {
921
+ statuses[t.status]++;
922
+ const icon = t.status === "completed" ? "ok" : t.status === "in_progress" ? ">" : "*";
923
+ lines.push(`${icon} #${t.id} [${t.status}] ${t.subject.slice(0, 80)}`);
924
+ }
925
+ lines.unshift(`${tasks.length} tasks (${statuses.pending} pending, ${statuses.in_progress} in progress, ${statuses.completed} done)`);
926
+ return Promise.resolve(textResult(lines.join("\n")));
927
+ },
928
+ });
929
+ pi.registerTool({
930
+ name: "TaskUpdate",
931
+ label: "TaskUpdate",
932
+ description: `Update task status or details. Set status to "in_progress" before starting work, "completed" when done.
933
+
934
+ Statuses: pending → in_progress → completed`,
935
+ parameters: Type.Object({
936
+ id: Type.String({ description: "Task ID to update" }),
937
+ status: Type.Optional(Type.String({ description: "New status", enum: ["pending", "in_progress", "completed"] })),
938
+ subject: Type.Optional(Type.String({ description: "New title" })),
939
+ description: Type.Optional(Type.String({ description: "New description" })),
940
+ }),
941
+ execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
942
+ const { id, status, subject, description } = params;
943
+ const entry = taskStore.update(id, {
944
+ status: status,
945
+ subject,
946
+ description,
947
+ });
948
+ if (!entry)
949
+ return Promise.resolve(textResult(`Task #${id} not found`));
950
+ widget.update();
951
+ const statusMsg = status ? ` → ${status}` : "";
952
+ return Promise.resolve(textResult(`Task #${id} updated${statusMsg}`));
953
+ },
954
+ });
955
+ pi.registerTool({
956
+ name: "TaskDelete",
957
+ label: "TaskDelete",
958
+ description: `Delete a task by ID. Use for cleaning up completed or irrelevant tasks.`,
959
+ parameters: Type.Object({
960
+ id: Type.String({ description: "Task ID to delete" }),
961
+ }),
962
+ execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
963
+ const deleted = taskStore.delete(params.id);
964
+ widget.update();
965
+ if (deleted)
966
+ return Promise.resolve(textResult(`Task #${params.id} deleted`));
967
+ return Promise.resolve(textResult(`Task #${params.id} not found`));
968
+ },
969
+ });
970
+ debug("native task tools registered (pi-tasks not detected)");
971
+ }, 6000);
733
972
  }