@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/DIFFERENTIAL_REVIEW_REPORT.md +81 -0
- package/README.md +50 -6
- package/dist/index.js +404 -85
- 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 +19 -16
- package/dist/ui/widget.d.ts +9 -7
- package/dist/ui/widget.js +29 -93
- package/package.json +1 -1
- package/src/index.ts +420 -83
- package/src/task-store.ts +171 -0
- package/src/task-types.ts +17 -0
- package/src/trigger-system.ts +20 -16
- package/src/ui/widget.ts +34 -91
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import type { TaskEntry, TaskStatus, TaskStoreData } from "./task-types.js";
|
|
5
|
+
|
|
6
|
+
const TASKS_DIR = join(homedir(), ".pi", "tasks");
|
|
7
|
+
const LOCK_RETRY_MS = 50;
|
|
8
|
+
const LOCK_MAX_RETRIES = 100;
|
|
9
|
+
const MAX_TASKS = 200;
|
|
10
|
+
|
|
11
|
+
function acquireLock(lockPath: string): void {
|
|
12
|
+
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
13
|
+
try {
|
|
14
|
+
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
15
|
+
return;
|
|
16
|
+
} catch (e: any) {
|
|
17
|
+
if (e.code === "EEXIST") {
|
|
18
|
+
try {
|
|
19
|
+
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
|
20
|
+
if (!pid || !isProcessRunning(pid)) {
|
|
21
|
+
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
} catch { /* ignore read errors */ }
|
|
25
|
+
const start = Date.now();
|
|
26
|
+
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
throw e;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`Failed to acquire lock: ${lockPath}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function releaseLock(lockPath: string): void {
|
|
36
|
+
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isProcessRunning(pid: number): boolean {
|
|
40
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class TaskStore {
|
|
44
|
+
private filePath: string | undefined;
|
|
45
|
+
private lockPath: string | undefined;
|
|
46
|
+
|
|
47
|
+
private nextId = 1;
|
|
48
|
+
private tasks = new Map<string, TaskEntry>();
|
|
49
|
+
|
|
50
|
+
constructor(listIdOrPath?: string) {
|
|
51
|
+
if (!listIdOrPath) return;
|
|
52
|
+
const isAbsPath = isAbsolute(listIdOrPath);
|
|
53
|
+
const filePath = isAbsPath ? listIdOrPath : join(TASKS_DIR, `${listIdOrPath}.json`);
|
|
54
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
55
|
+
this.filePath = filePath;
|
|
56
|
+
this.lockPath = filePath + ".lock";
|
|
57
|
+
this.load();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private load(): void {
|
|
61
|
+
if (!this.filePath) return;
|
|
62
|
+
if (!existsSync(this.filePath)) return;
|
|
63
|
+
try {
|
|
64
|
+
const data: TaskStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
65
|
+
this.nextId = data.nextId;
|
|
66
|
+
this.tasks.clear();
|
|
67
|
+
for (const task of data.tasks) {
|
|
68
|
+
this.tasks.set(task.id, task);
|
|
69
|
+
}
|
|
70
|
+
} catch { /* corrupt file — start fresh */ }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private save(): void {
|
|
74
|
+
if (!this.filePath) return;
|
|
75
|
+
const data: TaskStoreData = {
|
|
76
|
+
nextId: this.nextId,
|
|
77
|
+
tasks: Array.from(this.tasks.values()),
|
|
78
|
+
};
|
|
79
|
+
const tmpPath = this.filePath + ".tmp";
|
|
80
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
81
|
+
renameSync(tmpPath, this.filePath);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private withLock<T>(fn: () => T): T {
|
|
85
|
+
if (!this.lockPath) return fn();
|
|
86
|
+
acquireLock(this.lockPath);
|
|
87
|
+
try {
|
|
88
|
+
this.load();
|
|
89
|
+
const result = fn();
|
|
90
|
+
this.save();
|
|
91
|
+
return result;
|
|
92
|
+
} finally {
|
|
93
|
+
releaseLock(this.lockPath);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
create(subject: string, description: string, metadata?: Record<string, unknown>): TaskEntry {
|
|
98
|
+
return this.withLock(() => {
|
|
99
|
+
if (this.tasks.size >= MAX_TASKS) {
|
|
100
|
+
throw new Error(`Maximum of ${MAX_TASKS} tasks reached. Delete some before creating new ones.`);
|
|
101
|
+
}
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
const entry: TaskEntry = {
|
|
104
|
+
id: String(this.nextId++),
|
|
105
|
+
subject,
|
|
106
|
+
description,
|
|
107
|
+
status: "pending",
|
|
108
|
+
createdAt: now,
|
|
109
|
+
updatedAt: now,
|
|
110
|
+
metadata,
|
|
111
|
+
};
|
|
112
|
+
this.tasks.set(entry.id, entry);
|
|
113
|
+
return entry;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get(id: string): TaskEntry | undefined {
|
|
118
|
+
if (this.filePath) this.load();
|
|
119
|
+
return this.tasks.get(id);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
list(): TaskEntry[] {
|
|
123
|
+
if (this.filePath) this.load();
|
|
124
|
+
return Array.from(this.tasks.values()).sort((a, b) => Number(a.id) - Number(b.id));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
update(id: string, fields: { status?: TaskStatus; subject?: string; description?: string }): TaskEntry | undefined {
|
|
128
|
+
return this.withLock(() => {
|
|
129
|
+
const entry = this.tasks.get(id);
|
|
130
|
+
if (!entry) return undefined;
|
|
131
|
+
|
|
132
|
+
if (fields.status !== undefined) {
|
|
133
|
+
entry.status = fields.status;
|
|
134
|
+
if (fields.status === "completed") entry.completedAt = Date.now();
|
|
135
|
+
}
|
|
136
|
+
if (fields.subject !== undefined) entry.subject = fields.subject;
|
|
137
|
+
if (fields.description !== undefined) entry.description = fields.description;
|
|
138
|
+
entry.updatedAt = Date.now();
|
|
139
|
+
return entry;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
delete(id: string): boolean {
|
|
144
|
+
return this.withLock(() => {
|
|
145
|
+
if (!this.tasks.has(id)) return false;
|
|
146
|
+
this.tasks.delete(id);
|
|
147
|
+
return true;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
pendingCount(): number {
|
|
152
|
+
let count = 0;
|
|
153
|
+
for (const t of this.tasks.values()) {
|
|
154
|
+
if (t.status === "pending" || t.status === "in_progress") count++;
|
|
155
|
+
}
|
|
156
|
+
return count;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
sweepCompleted(): number {
|
|
160
|
+
return this.withLock(() => {
|
|
161
|
+
let count = 0;
|
|
162
|
+
for (const [id, entry] of this.tasks) {
|
|
163
|
+
if (entry.status === "completed") {
|
|
164
|
+
this.tasks.delete(id);
|
|
165
|
+
count++;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return count;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type TaskStatus = "pending" | "in_progress" | "completed";
|
|
2
|
+
|
|
3
|
+
export interface TaskEntry {
|
|
4
|
+
id: string;
|
|
5
|
+
subject: string;
|
|
6
|
+
description: string;
|
|
7
|
+
status: TaskStatus;
|
|
8
|
+
createdAt: number;
|
|
9
|
+
updatedAt: number;
|
|
10
|
+
completedAt?: number;
|
|
11
|
+
metadata?: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TaskStoreData {
|
|
15
|
+
nextId: number;
|
|
16
|
+
tasks: TaskEntry[];
|
|
17
|
+
}
|
package/src/trigger-system.ts
CHANGED
|
@@ -12,6 +12,7 @@ export class TriggerSystem {
|
|
|
12
12
|
private pi: ExtensionAPI,
|
|
13
13
|
private scheduler: CronScheduler,
|
|
14
14
|
private store: LoopStore,
|
|
15
|
+
private onFire: (entry: LoopEntry) => void,
|
|
15
16
|
) {}
|
|
16
17
|
|
|
17
18
|
start(): void {
|
|
@@ -94,27 +95,30 @@ export class TriggerSystem {
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
private fireLoop(entry: LoopEntry): void {
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
const current = this.store.get(entry.id);
|
|
99
|
+
if (!current || current.status !== "active") {
|
|
99
100
|
this.remove(entry.id);
|
|
100
101
|
return;
|
|
101
102
|
}
|
|
102
|
-
this.store.update(entry.id, { fireCount: (entry.fireCount ?? 0) + 1 });
|
|
103
|
-
|
|
104
|
-
this.lastFireTime.set(entry.id, Date.now());
|
|
105
|
-
this.pi.events.emit("loop:fire", {
|
|
106
|
-
loopId: entry.id,
|
|
107
|
-
prompt: entry.prompt,
|
|
108
|
-
trigger: entry.trigger,
|
|
109
|
-
timestamp: Date.now(),
|
|
110
|
-
readOnly: entry.readOnly,
|
|
111
|
-
recurring: entry.recurring,
|
|
112
|
-
autoTask: entry.autoTask,
|
|
113
|
-
});
|
|
114
103
|
|
|
115
|
-
|
|
104
|
+
this.lastFireTime.set(current.id, Date.now());
|
|
105
|
+
this.onFire(current);
|
|
106
|
+
|
|
107
|
+
const fresh = this.store.get(entry.id);
|
|
108
|
+
if (!fresh) {
|
|
116
109
|
this.remove(entry.id);
|
|
117
|
-
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (fresh.recurring && fresh.maxFires && (fresh.fireCount ?? 0) >= fresh.maxFires) {
|
|
114
|
+
this.remove(fresh.id);
|
|
115
|
+
this.store.delete(fresh.id);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!fresh.recurring) {
|
|
120
|
+
this.remove(fresh.id);
|
|
121
|
+
this.store.delete(fresh.id);
|
|
118
122
|
}
|
|
119
123
|
}
|
|
120
124
|
|
package/src/ui/widget.ts
CHANGED
|
@@ -1,21 +1,19 @@
|
|
|
1
|
-
import type { ExtensionUIContext
|
|
2
|
-
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
3
|
-
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
1
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
4
2
|
import type { MonitorManager } from "../monitor-manager.js";
|
|
5
|
-
import type { CronScheduler } from "../scheduler.js";
|
|
6
3
|
import type { LoopStore } from "../store.js";
|
|
7
4
|
|
|
8
|
-
|
|
5
|
+
interface TaskSummary {
|
|
6
|
+
count: number;
|
|
7
|
+
focusText?: string;
|
|
8
|
+
}
|
|
9
9
|
|
|
10
10
|
export class LoopWidget {
|
|
11
11
|
private uiCtx: ExtensionUIContext | undefined;
|
|
12
|
-
private tui: TUI | undefined;
|
|
13
|
-
private widgetRegistered = false;
|
|
14
12
|
private interval: ReturnType<typeof setInterval> | undefined;
|
|
13
|
+
private taskSummaryProvider: (() => TaskSummary) | undefined;
|
|
15
14
|
|
|
16
15
|
constructor(
|
|
17
16
|
private store: LoopStore,
|
|
18
|
-
private scheduler: CronScheduler | undefined,
|
|
19
17
|
private monitorManager: MonitorManager,
|
|
20
18
|
) {}
|
|
21
19
|
|
|
@@ -27,108 +25,53 @@ export class LoopWidget {
|
|
|
27
25
|
this.store = store;
|
|
28
26
|
}
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
this.
|
|
28
|
+
setTaskSummaryProvider(provider: (() => TaskSummary) | undefined) {
|
|
29
|
+
this.taskSummaryProvider = provider;
|
|
32
30
|
}
|
|
33
31
|
|
|
34
32
|
update() {
|
|
35
33
|
if (!this.uiCtx) return;
|
|
36
34
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
const hasContent = loops.length > 0 || monitors.length > 0;
|
|
40
|
-
|
|
41
|
-
if (!hasContent) {
|
|
42
|
-
if (this.widgetRegistered) {
|
|
43
|
-
this.uiCtx.setWidget("loops", undefined);
|
|
44
|
-
this.widgetRegistered = false;
|
|
45
|
-
}
|
|
46
|
-
if (this.interval) {
|
|
47
|
-
clearInterval(this.interval);
|
|
48
|
-
this.interval = undefined;
|
|
49
|
-
}
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (!this.interval) {
|
|
35
|
+
const status = this.computeStatus();
|
|
36
|
+
if (status && !this.interval) {
|
|
54
37
|
this.interval = setInterval(() => this.update(), 5000);
|
|
55
38
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
this.
|
|
59
|
-
this.tui = tui;
|
|
60
|
-
return { render: () => this.renderWidget(tui, theme), invalidate: () => {} } as Component & { dispose?(): void };
|
|
61
|
-
}, { placement: "aboveEditor" });
|
|
62
|
-
this.widgetRegistered = true;
|
|
63
|
-
} else if (this.tui) {
|
|
64
|
-
(this.tui as any).requestRender();
|
|
39
|
+
if (!status && this.interval) {
|
|
40
|
+
clearInterval(this.interval);
|
|
41
|
+
this.interval = undefined;
|
|
65
42
|
}
|
|
43
|
+
|
|
44
|
+
this.uiCtx.setStatus("loops", status);
|
|
66
45
|
}
|
|
67
46
|
|
|
68
|
-
private
|
|
47
|
+
private computeStatus(): string | undefined {
|
|
69
48
|
const loops = this.store.list().filter(l => l.status === "active");
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
const allMonitors = [...runningMonitors, ...completedMonitors];
|
|
73
|
-
const w = tui.terminal.columns;
|
|
74
|
-
const trunc = (line: string) => truncateToWidth(line, w);
|
|
75
|
-
|
|
76
|
-
const lines: string[] = [];
|
|
77
|
-
const total = loops.length + allMonitors.length;
|
|
78
|
-
|
|
79
|
-
if (total === 0) return [];
|
|
80
|
-
|
|
81
|
-
const headerParts: string[] = [`⟳ ${loops.length} loops`];
|
|
82
|
-
if (runningMonitors.length > 0) headerParts.push(`${runningMonitors.length} running`);
|
|
83
|
-
if (completedMonitors.length > 0) headerParts.push(`${completedMonitors.length} done`);
|
|
84
|
-
lines.push(trunc(headerParts.join(" · ")));
|
|
49
|
+
const monitors = this.monitorManager.list();
|
|
50
|
+
const taskSummary = this.taskSummaryProvider?.() ?? { count: 0 };
|
|
85
51
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
let schedule = "";
|
|
89
|
-
if (loop.trigger.type === "cron") {
|
|
90
|
-
schedule = loop.trigger.schedule;
|
|
91
|
-
} else if (loop.trigger.type === "event") {
|
|
92
|
-
schedule = `event: ${loop.trigger.source}`;
|
|
93
|
-
} else if (loop.trigger.type === "hybrid") {
|
|
94
|
-
schedule = `hybrid: ${loop.trigger.cron}`;
|
|
95
|
-
}
|
|
96
|
-
const nextFire = this.scheduler?.nextFire(loop.id);
|
|
97
|
-
let timing = "";
|
|
98
|
-
if (nextFire) {
|
|
99
|
-
const remaining = Math.max(0, nextFire - Date.now());
|
|
100
|
-
timing = ` (next: ${formatDuration(remaining)})`;
|
|
101
|
-
}
|
|
102
|
-
lines.push(trunc(` ${icon} #${loop.id} ${loop.prompt.slice(0, 50)} → ${schedule}${timing}`));
|
|
52
|
+
if (loops.length === 0 && monitors.length === 0 && taskSummary.count === 0) {
|
|
53
|
+
return undefined;
|
|
103
54
|
}
|
|
104
55
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
let line = ` ${icon} #${m.id} ${label} ${m.outputLines} lines (${formatDuration(age)})`;
|
|
110
|
-
if (m.exitCode !== undefined && m.status !== "running") line += ` exit=${m.exitCode}`;
|
|
111
|
-
lines.push(trunc(line));
|
|
112
|
-
}
|
|
56
|
+
const parts: string[] = [];
|
|
57
|
+
if (loops.length > 0) parts.push(formatCount(loops.length, "loop"));
|
|
58
|
+
if (monitors.length > 0) parts.push(formatCount(monitors.length, "monitor"));
|
|
59
|
+
if (taskSummary.count > 0) parts.push(formatCount(taskSummary.count, "task"));
|
|
113
60
|
|
|
114
|
-
|
|
61
|
+
let line = parts.join(" · ");
|
|
62
|
+
if (taskSummary.focusText) line += ` | ${taskSummary.focusText}`;
|
|
63
|
+
return line;
|
|
115
64
|
}
|
|
116
65
|
|
|
117
66
|
dispose() {
|
|
118
|
-
if (this.interval) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
67
|
+
if (this.interval) {
|
|
68
|
+
clearInterval(this.interval);
|
|
69
|
+
this.interval = undefined;
|
|
70
|
+
}
|
|
71
|
+
this.uiCtx?.setStatus("loops", undefined);
|
|
122
72
|
}
|
|
123
73
|
}
|
|
124
74
|
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
if (totalSec < 60) return `${totalSec}s`;
|
|
128
|
-
const min = Math.floor(totalSec / 60);
|
|
129
|
-
const sec = totalSec % 60;
|
|
130
|
-
if (min < 60) return sec > 0 ? `${min}m ${sec}s` : `${min}m`;
|
|
131
|
-
const hr = Math.floor(min / 60);
|
|
132
|
-
const remMin = min % 60;
|
|
133
|
-
return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
|
|
75
|
+
function formatCount(count: number, noun: string): string {
|
|
76
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
134
77
|
}
|