@trevonistrevon/pi-loop 0.3.1 → 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.
- package/DIFFERENTIAL_REVIEW_REPORT.md +59 -0
- package/README.md +50 -6
- package/dist/index.js +298 -59
- package/dist/task-store.d.ts +22 -0
- package/dist/task-store.js +181 -0
- package/dist/task-types.d.ts +15 -0
- package/dist/task-types.js +1 -0
- package/dist/trigger-system.d.ts +2 -1
- package/dist/trigger-system.js +14 -16
- package/dist/ui/widget.d.ts +8 -4
- package/dist/ui/widget.js +28 -74
- package/package.json +1 -1
- package/src/index.ts +297 -54
- package/src/task-store.ts +171 -0
- package/src/task-types.ts +17 -0
- package/src/trigger-system.ts +14 -16
- package/src/ui/widget.ts +27 -70
|
@@ -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
|
-
##
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
100
|
+
if (!entry.autoTask)
|
|
82
101
|
return undefined;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
unsub()
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
return taskId;
|
|
123
|
+
return taskId;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
104
128
|
}
|
|
105
|
-
|
|
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 (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
178
|
+
if (nativeTaskStore) {
|
|
179
|
+
nativeTaskStore.sweepCompleted();
|
|
180
|
+
widget.update();
|
|
128
181
|
}
|
|
129
182
|
}
|
|
130
183
|
// ── Loop fire handler ──
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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" ? "
|
|
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" ? "
|
|
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", ["
|
|
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" ? "
|
|
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("
|
|
747
|
+
choices.push("< Back");
|
|
692
748
|
const selected = await ui.select("Active Loops", choices);
|
|
693
|
-
if (!selected || selected === "
|
|
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 = ["
|
|
755
|
+
const actions = ["x Delete"];
|
|
700
756
|
if (entry.status === "active")
|
|
701
|
-
actions.unshift("
|
|
757
|
+
actions.unshift("- Pause");
|
|
702
758
|
else if (entry.status === "paused")
|
|
703
|
-
actions.unshift("
|
|
704
|
-
actions.push("
|
|
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 === "
|
|
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 === "
|
|
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 === "
|
|
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
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TaskEntry, TaskStatus } from "./task-types.js";
|
|
2
|
+
export declare class TaskStore {
|
|
3
|
+
private filePath;
|
|
4
|
+
private lockPath;
|
|
5
|
+
private nextId;
|
|
6
|
+
private tasks;
|
|
7
|
+
constructor(listIdOrPath?: string);
|
|
8
|
+
private load;
|
|
9
|
+
private save;
|
|
10
|
+
private withLock;
|
|
11
|
+
create(subject: string, description: string, metadata?: Record<string, unknown>): TaskEntry;
|
|
12
|
+
get(id: string): TaskEntry | undefined;
|
|
13
|
+
list(): TaskEntry[];
|
|
14
|
+
update(id: string, fields: {
|
|
15
|
+
status?: TaskStatus;
|
|
16
|
+
subject?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
}): TaskEntry | undefined;
|
|
19
|
+
delete(id: string): boolean;
|
|
20
|
+
pendingCount(): number;
|
|
21
|
+
sweepCompleted(): number;
|
|
22
|
+
}
|