@trevonistrevon/pi-loop 0.5.9 → 0.6.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/.release-please-manifest.json +1 -1
- package/AGENTS.md +52 -12
- package/CHANGELOG.md +59 -0
- package/CONTRIBUTING.md +13 -3
- package/README.md +47 -0
- package/coverage/coverage-final.json +35 -32
- package/coverage/index.html +64 -49
- package/coverage/src/api.ts.html +193 -0
- package/coverage/src/commands/index.html +32 -32
- package/coverage/src/commands/loop-command.ts.html +170 -170
- package/coverage/src/commands/tasks-command.ts.html +129 -129
- package/coverage/src/coordinator.ts.html +26 -26
- package/coverage/src/index.html +61 -106
- package/coverage/src/index.ts.html +224 -86
- package/coverage/src/loop-parse.ts.html +106 -73
- package/coverage/src/loop-reducer.ts.html +21 -21
- package/coverage/src/monitor-completion-coordinator.ts.html +1 -1
- package/coverage/src/monitor-manager.ts.html +95 -71
- package/coverage/src/monitor-reducer.ts.html +15 -15
- package/coverage/src/notification-reducer.ts.html +6 -6
- package/coverage/src/reducer-backed-store.ts.html +57 -57
- package/coverage/src/rpc/channels.ts.html +376 -0
- package/coverage/src/rpc/cross-extension-rpc.ts.html +541 -0
- package/coverage/src/rpc/index.html +131 -0
- package/coverage/src/runtime/index.html +68 -23
- package/coverage/src/{goal-coordinator.ts.html → runtime/loop-events.ts.html} +79 -73
- package/coverage/src/runtime/monitor-ondone-runtime.ts.html +5 -5
- package/coverage/src/runtime/native-task-rpc.ts.html +502 -0
- package/coverage/src/runtime/notification-runtime.ts.html +22 -22
- package/coverage/src/runtime/scope.ts.html +14 -14
- package/coverage/src/runtime/session-runtime.ts.html +32 -32
- package/coverage/src/runtime/task-backlog-runtime.ts.html +65 -23
- package/coverage/src/runtime/task-events.ts.html +2 -2
- package/coverage/src/runtime/task-mutations.ts.html +394 -0
- package/coverage/src/runtime/task-rpc.ts.html +102 -120
- package/coverage/src/scheduler.ts.html +29 -29
- package/coverage/src/store.ts.html +27 -27
- package/coverage/src/task-backlog-coordinator.ts.html +8 -8
- package/coverage/src/task-reducer.ts.html +42 -42
- package/coverage/src/task-store.ts.html +49 -49
- package/coverage/src/tools/index.html +39 -24
- package/coverage/src/tools/loop-tools.ts.html +12 -21
- package/coverage/src/tools/monitor-tools.ts.html +12 -21
- package/coverage/src/tools/native-task-tools.ts.html +57 -120
- package/coverage/src/tools/tool-result.ts.html +97 -0
- package/coverage/src/trigger-system.ts.html +12 -12
- package/coverage/src/ui/index.html +1 -1
- package/coverage/src/ui/widget.ts.html +23 -23
- package/dist/api.d.ts +11 -0
- package/dist/api.js +10 -0
- package/dist/commands/loop-command.js +2 -2
- package/dist/commands/tasks-command.js +3 -3
- package/dist/coordinator.d.ts +1 -1
- package/dist/index.js +48 -3
- package/dist/loop-parse.js +14 -5
- package/dist/reducer-backed-store.d.ts +1 -1
- package/dist/reducer-backed-store.js +1 -1
- package/dist/rpc/channels.d.ts +73 -0
- package/dist/rpc/channels.js +30 -0
- package/dist/rpc/cross-extension-rpc.d.ts +59 -0
- package/dist/rpc/cross-extension-rpc.js +117 -0
- package/dist/runtime/loop-events.d.ts +26 -0
- package/dist/runtime/loop-events.js +26 -0
- package/dist/runtime/native-task-rpc.d.ts +22 -0
- package/dist/runtime/native-task-rpc.js +50 -0
- package/dist/runtime/task-backlog-runtime.d.ts +3 -0
- package/dist/runtime/task-backlog-runtime.js +4 -1
- package/dist/runtime/task-mutations.d.ts +39 -0
- package/dist/runtime/task-mutations.js +68 -0
- package/dist/runtime/task-rpc.d.ts +4 -0
- package/dist/runtime/task-rpc.js +40 -58
- package/dist/scheduler.js +1 -1
- package/dist/tools/loop-tools.js +1 -3
- package/dist/tools/monitor-tools.js +1 -3
- package/dist/tools/native-task-tools.d.ts +2 -6
- package/dist/tools/native-task-tools.js +25 -55
- package/dist/tools/tool-result.d.ts +8 -0
- package/dist/tools/tool-result.js +4 -0
- package/docs/architecture/state-machine-migration.md +0 -44
- package/docs/architecture/state-machine-reducer-event-model.md +2 -3
- package/package.json +6 -1
- package/src/api.ts +36 -0
- package/src/commands/loop-command.ts +2 -2
- package/src/commands/tasks-command.ts +3 -3
- package/src/coordinator.ts +1 -1
- package/src/index.ts +49 -3
- package/src/loop-parse.ts +18 -7
- package/src/reducer-backed-store.ts +3 -3
- package/src/rpc/channels.ts +97 -0
- package/src/rpc/cross-extension-rpc.ts +152 -0
- package/src/runtime/loop-events.ts +60 -0
- package/src/runtime/native-task-rpc.ts +139 -0
- package/src/runtime/task-backlog-runtime.ts +14 -0
- package/src/runtime/task-mutations.ts +103 -0
- package/src/runtime/task-rpc.ts +48 -54
- package/src/scheduler.ts +1 -1
- package/src/tools/loop-tools.ts +1 -4
- package/src/tools/monitor-tools.ts +1 -4
- package/src/tools/native-task-tools.ts +31 -52
- package/src/tools/tool-result.ts +4 -0
- package/src/trigger-system.ts +4 -4
- package/vitest.config.ts +7 -7
- package/coverage/src/goal-reducer.ts.html +0 -982
- package/coverage/src/goal-store.ts.html +0 -742
- package/coverage/src/goal-verifier.ts.html +0 -808
- package/dist/goal-coordinator.d.ts +0 -22
- package/dist/goal-coordinator.js +0 -28
- package/dist/goal-reducer.d.ts +0 -98
- package/dist/goal-reducer.js +0 -190
- package/dist/goal-store.d.ts +0 -22
- package/dist/goal-store.js +0 -188
- package/dist/goal-types.d.ts +0 -82
- package/dist/goal-types.js +0 -1
- package/dist/goal-verifier.d.ts +0 -20
- package/dist/goal-verifier.js +0 -198
- package/docs/architecture/goal-state-schema.md +0 -505
- package/src/goal-coordinator.ts +0 -58
- package/src/goal-reducer.ts +0 -299
- package/src/goal-store.ts +0 -219
- package/src/goal-types.ts +0 -104
- package/src/goal-verifier.ts +0 -241
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// VENDORED MODULE — canonical copy shared verbatim by pi-loop and pi-orca.
|
|
2
|
+
// If you edit this file, copy it to the sibling repo and bump VENDOR_REV.
|
|
3
|
+
// VENDOR_REV: 2
|
|
4
|
+
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { type PingReply, replyChannel } from "./channels.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Cross-extension request/reply over the in-process pi event bus.
|
|
10
|
+
*
|
|
11
|
+
* Wire contract (matches the pi-mono convention used by @tintinweb/pi-subagents):
|
|
12
|
+
* request: emit(channel, { requestId, ...params })
|
|
13
|
+
* reply: emit(`${channel}:reply:${requestId}`, RpcReply)
|
|
14
|
+
*/
|
|
15
|
+
export const PROTOCOL_VERSION = 2;
|
|
16
|
+
|
|
17
|
+
export type RpcReply<T = void> =
|
|
18
|
+
| { success: true; data?: T }
|
|
19
|
+
| { success: false; error: string };
|
|
20
|
+
|
|
21
|
+
export class RpcError extends Error {
|
|
22
|
+
constructor(
|
|
23
|
+
readonly channel: string,
|
|
24
|
+
message: string,
|
|
25
|
+
readonly timedOut = false,
|
|
26
|
+
) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "RpcError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Minimal event-bus surface — satisfied by pi.events. */
|
|
33
|
+
export interface RpcEventBus {
|
|
34
|
+
on(event: string, handler: (data: unknown) => void): () => void;
|
|
35
|
+
emit(event: string, data: unknown): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Call a remote handler and await its reply.
|
|
40
|
+
*
|
|
41
|
+
* Rejects with RpcError on a failure envelope or on timeout (timedOut=true).
|
|
42
|
+
* Callers that treat failure as "fall back" wrap this in try/catch at the
|
|
43
|
+
* layer that knows the fallback — the RPC layer itself never returns sentinels.
|
|
44
|
+
*/
|
|
45
|
+
export function rpcCall<T = void>(
|
|
46
|
+
bus: RpcEventBus,
|
|
47
|
+
channel: string,
|
|
48
|
+
params: Record<string, unknown> = {},
|
|
49
|
+
timeoutMs = 5000,
|
|
50
|
+
): Promise<T> {
|
|
51
|
+
const requestId = randomUUID();
|
|
52
|
+
return new Promise<T>((resolve, reject) => {
|
|
53
|
+
let settled = false;
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
if (settled) return;
|
|
56
|
+
settled = true;
|
|
57
|
+
unsub();
|
|
58
|
+
reject(new RpcError(channel, `${channel} timed out after ${timeoutMs}ms`, true));
|
|
59
|
+
}, timeoutMs);
|
|
60
|
+
const unsub = bus.on(replyChannel(channel, requestId), (raw) => {
|
|
61
|
+
if (settled) return;
|
|
62
|
+
settled = true;
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
unsub();
|
|
65
|
+
const reply = raw as RpcReply<T> | undefined;
|
|
66
|
+
if (reply?.success) {
|
|
67
|
+
resolve(reply.data as T);
|
|
68
|
+
} else {
|
|
69
|
+
const error = reply && "error" in reply ? reply.error : undefined;
|
|
70
|
+
reject(new RpcError(channel, error ?? `${channel} replied with a malformed envelope`));
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
bus.emit(channel, { requestId, ...params });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (!settled) {
|
|
77
|
+
settled = true;
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
unsub();
|
|
80
|
+
reject(error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Detection probe: resolves undefined when nobody answers the ping channel.
|
|
88
|
+
* Absence of a provider is expected, not an error.
|
|
89
|
+
*
|
|
90
|
+
* First reply wins. If the probing extension also serves this channel, its
|
|
91
|
+
* own reply will settle the probe — in that case listen on the reply channel
|
|
92
|
+
* directly for the full window and filter by PingReply.provider instead.
|
|
93
|
+
*/
|
|
94
|
+
export async function rpcProbe(
|
|
95
|
+
bus: RpcEventBus,
|
|
96
|
+
pingChannel: string,
|
|
97
|
+
timeoutMs = 5000,
|
|
98
|
+
): Promise<PingReply | undefined> {
|
|
99
|
+
try {
|
|
100
|
+
return await rpcCall<PingReply>(bus, pingChannel, {}, timeoutMs);
|
|
101
|
+
} catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface HandleRpcOptions {
|
|
107
|
+
/**
|
|
108
|
+
* When false, the handler is a silent no-op: another extension owns the
|
|
109
|
+
* channel and will reply; emitting a failure here would race its reply.
|
|
110
|
+
*/
|
|
111
|
+
enabled?: () => boolean;
|
|
112
|
+
debug?: (...args: unknown[]) => void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Register a server handler for one RPC channel. Returns the unsubscribe fn.
|
|
117
|
+
*
|
|
118
|
+
* Malformed requests get a failure reply, never a silent drop — the only
|
|
119
|
+
* silent case is a missing requestId, which leaves no reply address.
|
|
120
|
+
* A thrown/rejected fn becomes a failure envelope.
|
|
121
|
+
*/
|
|
122
|
+
export function handleRpc<P extends object, R = unknown>(
|
|
123
|
+
bus: RpcEventBus,
|
|
124
|
+
channel: string,
|
|
125
|
+
fn: (params: P) => R | Promise<R>,
|
|
126
|
+
opts?: HandleRpcOptions,
|
|
127
|
+
): () => void {
|
|
128
|
+
return bus.on(channel, async (raw) => {
|
|
129
|
+
if (opts?.enabled && !opts.enabled()) return;
|
|
130
|
+
|
|
131
|
+
const requestId =
|
|
132
|
+
raw && typeof raw === "object"
|
|
133
|
+
? (raw as { requestId?: unknown }).requestId
|
|
134
|
+
: undefined;
|
|
135
|
+
if (typeof requestId !== "string" || requestId.length === 0) {
|
|
136
|
+
opts?.debug?.(`${channel} — dropped request without requestId`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let reply: RpcReply<R>;
|
|
141
|
+
try {
|
|
142
|
+
const result = await fn(raw as P);
|
|
143
|
+
reply = result === undefined ? { success: true } : { success: true, data: result };
|
|
144
|
+
} catch (error) {
|
|
145
|
+
reply = {
|
|
146
|
+
success: false,
|
|
147
|
+
error: error instanceof Error ? error.message : String(error),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
bus.emit(replyChannel(channel, requestId), reply);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { LoopEntry } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export type LoopAutoDeleteReason = "task_backlog_empty";
|
|
4
|
+
|
|
5
|
+
export interface LoopAutodeletedPayload {
|
|
6
|
+
loopId: string;
|
|
7
|
+
prompt: string;
|
|
8
|
+
trigger: LoopEntry["trigger"];
|
|
9
|
+
recurring: boolean;
|
|
10
|
+
autoTask?: boolean;
|
|
11
|
+
taskBacklog?: boolean;
|
|
12
|
+
readOnly?: boolean;
|
|
13
|
+
maxFires?: number;
|
|
14
|
+
fireCount?: number;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
updatedAt: number;
|
|
17
|
+
expiresAt: number;
|
|
18
|
+
source: "task_backlog_runtime";
|
|
19
|
+
reason: LoopAutoDeleteReason;
|
|
20
|
+
pendingCount: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TaskBacklogEmptyPayload {
|
|
24
|
+
pendingCount: 0;
|
|
25
|
+
deletedLoopIds: string[];
|
|
26
|
+
source: "task_backlog_runtime";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildLoopAutodeletedPayload(
|
|
30
|
+
entry: LoopEntry,
|
|
31
|
+
pendingCount: number,
|
|
32
|
+
): LoopAutodeletedPayload {
|
|
33
|
+
return {
|
|
34
|
+
loopId: entry.id,
|
|
35
|
+
prompt: entry.prompt,
|
|
36
|
+
trigger: entry.trigger,
|
|
37
|
+
recurring: entry.recurring,
|
|
38
|
+
autoTask: entry.autoTask,
|
|
39
|
+
taskBacklog: entry.taskBacklog,
|
|
40
|
+
readOnly: entry.readOnly,
|
|
41
|
+
maxFires: entry.maxFires,
|
|
42
|
+
fireCount: entry.fireCount,
|
|
43
|
+
createdAt: entry.createdAt,
|
|
44
|
+
updatedAt: entry.updatedAt,
|
|
45
|
+
expiresAt: entry.expiresAt,
|
|
46
|
+
source: "task_backlog_runtime",
|
|
47
|
+
reason: "task_backlog_empty",
|
|
48
|
+
pendingCount,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildTaskBacklogEmptyPayload(
|
|
53
|
+
deletedLoopIds: string[],
|
|
54
|
+
): TaskBacklogEmptyPayload {
|
|
55
|
+
return {
|
|
56
|
+
pendingCount: 0,
|
|
57
|
+
deletedLoopIds,
|
|
58
|
+
source: "task_backlog_runtime",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type CleanReply,
|
|
4
|
+
type CreateTaskReply,
|
|
5
|
+
type PendingReply,
|
|
6
|
+
type PingReply,
|
|
7
|
+
TASKS_RPC,
|
|
8
|
+
type UpdateTaskReply,
|
|
9
|
+
} from "../rpc/channels.js";
|
|
10
|
+
import { handleRpc, PROTOCOL_VERSION } from "../rpc/cross-extension-rpc.js";
|
|
11
|
+
import type { TaskStore } from "../task-store.js";
|
|
12
|
+
import type { TaskStatus } from "../task-types.js";
|
|
13
|
+
import {
|
|
14
|
+
cleanTasks,
|
|
15
|
+
createTask,
|
|
16
|
+
type TaskBacklogResult,
|
|
17
|
+
type TaskMutationContext,
|
|
18
|
+
updateTask,
|
|
19
|
+
} from "./task-mutations.js";
|
|
20
|
+
|
|
21
|
+
/** Discriminates pi-loop's own ping replies from an external pi-tasks provider. */
|
|
22
|
+
export const NATIVE_TASKS_PROVIDER = "pi-loop-native";
|
|
23
|
+
|
|
24
|
+
interface CreateTaskRequest {
|
|
25
|
+
requestId: string;
|
|
26
|
+
subject?: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
metadata?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface UpdateTaskRequest {
|
|
32
|
+
requestId: string;
|
|
33
|
+
id?: string;
|
|
34
|
+
status?: TaskStatus;
|
|
35
|
+
subject?: string;
|
|
36
|
+
description?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface NativeTaskRpcOptions {
|
|
40
|
+
pi: ExtensionAPI;
|
|
41
|
+
getNativeTaskStore: () => TaskStore | undefined;
|
|
42
|
+
/** Stands the server down (silent no-op) when an external pi-tasks owns the channels. */
|
|
43
|
+
isEnabled?: () => boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Gates the mutating/reading verbs (create/update/pending/clean) until the
|
|
46
|
+
* pi-tasks detection probe has settled. Ping stays answerable throughout so
|
|
47
|
+
* providers remain discoverable, but no state diverges while an external
|
|
48
|
+
* provider might still claim the channels. Defaults to settled.
|
|
49
|
+
*/
|
|
50
|
+
isDetectionSettled?: () => boolean;
|
|
51
|
+
evaluateTaskBacklog: (
|
|
52
|
+
taskStore: TaskStore,
|
|
53
|
+
pendingCount: number,
|
|
54
|
+
) => Promise<TaskBacklogResult>;
|
|
55
|
+
updateWidget: () => void;
|
|
56
|
+
debug?: (...args: unknown[]) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function registerNativeTaskRpc(options: NativeTaskRpcOptions): void {
|
|
60
|
+
const {
|
|
61
|
+
pi,
|
|
62
|
+
getNativeTaskStore,
|
|
63
|
+
isEnabled,
|
|
64
|
+
isDetectionSettled,
|
|
65
|
+
evaluateTaskBacklog,
|
|
66
|
+
updateWidget,
|
|
67
|
+
debug,
|
|
68
|
+
} = options;
|
|
69
|
+
const rpcOpts = { enabled: isEnabled, debug };
|
|
70
|
+
const settledRpcOpts = {
|
|
71
|
+
enabled: () => (isEnabled ? isEnabled() : true) && (isDetectionSettled ? isDetectionSettled() : true),
|
|
72
|
+
debug,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function requireMutationContext(): TaskMutationContext {
|
|
76
|
+
const taskStore = getNativeTaskStore();
|
|
77
|
+
if (!taskStore) throw new Error("native task store unavailable");
|
|
78
|
+
return { pi, taskStore, evaluateTaskBacklog, updateWidget };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
handleRpc<{ requestId: string }, PingReply>(
|
|
82
|
+
pi.events,
|
|
83
|
+
TASKS_RPC.ping,
|
|
84
|
+
() => ({ version: PROTOCOL_VERSION, provider: NATIVE_TASKS_PROVIDER }),
|
|
85
|
+
rpcOpts,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
handleRpc<{ requestId: string }, PendingReply>(
|
|
89
|
+
pi.events,
|
|
90
|
+
TASKS_RPC.pending,
|
|
91
|
+
() => ({ pending: requireMutationContext().taskStore.pendingCount() }),
|
|
92
|
+
settledRpcOpts,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
handleRpc<CreateTaskRequest, CreateTaskReply>(
|
|
96
|
+
pi.events,
|
|
97
|
+
TASKS_RPC.create,
|
|
98
|
+
async (request) => {
|
|
99
|
+
if (!request.subject || !request.description) {
|
|
100
|
+
throw new Error("subject and description are required");
|
|
101
|
+
}
|
|
102
|
+
const { entry } = await createTask(requireMutationContext(), {
|
|
103
|
+
subject: request.subject,
|
|
104
|
+
description: request.description,
|
|
105
|
+
metadata: request.metadata,
|
|
106
|
+
});
|
|
107
|
+
return { id: entry.id, task: entry };
|
|
108
|
+
},
|
|
109
|
+
settledRpcOpts,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
handleRpc<{ requestId: string }, CleanReply>(
|
|
113
|
+
pi.events,
|
|
114
|
+
TASKS_RPC.clean,
|
|
115
|
+
async () => {
|
|
116
|
+
const pruned = await cleanTasks(requireMutationContext());
|
|
117
|
+
debug?.(`${TASKS_RPC.clean} — pruned ${pruned} completed task(s)`);
|
|
118
|
+
return { pruned };
|
|
119
|
+
},
|
|
120
|
+
settledRpcOpts,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
handleRpc<UpdateTaskRequest, UpdateTaskReply>(
|
|
124
|
+
pi.events,
|
|
125
|
+
TASKS_RPC.update,
|
|
126
|
+
async (request) => {
|
|
127
|
+
if (!request.id) throw new Error("id is required");
|
|
128
|
+
const result = await updateTask(requireMutationContext(), {
|
|
129
|
+
id: request.id,
|
|
130
|
+
status: request.status,
|
|
131
|
+
subject: request.subject,
|
|
132
|
+
description: request.description,
|
|
133
|
+
});
|
|
134
|
+
if (!result) throw new Error(`Task #${request.id} not found`);
|
|
135
|
+
return { task: result.entry };
|
|
136
|
+
},
|
|
137
|
+
settledRpcOpts,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -9,6 +9,12 @@ import {
|
|
|
9
9
|
} from "../task-backlog-coordinator.js";
|
|
10
10
|
import type { TaskStore } from "../task-store.js";
|
|
11
11
|
import type { LoopEntry, Trigger } from "../types.js";
|
|
12
|
+
import {
|
|
13
|
+
buildLoopAutodeletedPayload,
|
|
14
|
+
buildTaskBacklogEmptyPayload,
|
|
15
|
+
type LoopAutodeletedPayload,
|
|
16
|
+
type TaskBacklogEmptyPayload,
|
|
17
|
+
} from "./loop-events.js";
|
|
12
18
|
|
|
13
19
|
export const AUTO_TASK_WORKER_THRESHOLD = 5;
|
|
14
20
|
export const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, complete it. If no pending tasks remain, call LoopDelete on your own loop ID.";
|
|
@@ -27,6 +33,8 @@ export interface TaskBacklogRuntimeOptions {
|
|
|
27
33
|
hasPendingTasks: () => Promise<number>;
|
|
28
34
|
bootstrapTaskLoop: (entry: LoopEntry) => Promise<boolean>;
|
|
29
35
|
triggerHasEventSource: (trigger: Trigger | string, source: string) => boolean;
|
|
36
|
+
emitLoopAutodeleted?: (payload: LoopAutodeletedPayload) => void;
|
|
37
|
+
emitTaskBacklogEmpty?: (payload: TaskBacklogEmptyPayload) => void;
|
|
30
38
|
debug?: (...args: unknown[]) => void;
|
|
31
39
|
}
|
|
32
40
|
|
|
@@ -50,6 +58,8 @@ export function createTaskBacklogRuntime(options: TaskBacklogRuntimeOptions): Ta
|
|
|
50
58
|
hasPendingTasks,
|
|
51
59
|
bootstrapTaskLoop,
|
|
52
60
|
triggerHasEventSource,
|
|
61
|
+
emitLoopAutodeleted,
|
|
62
|
+
emitTaskBacklogEmpty,
|
|
53
63
|
debug,
|
|
54
64
|
} = options;
|
|
55
65
|
|
|
@@ -76,10 +86,14 @@ export function createTaskBacklogRuntime(options: TaskBacklogRuntimeOptions): Ta
|
|
|
76
86
|
const pending = await hasPendingTasks();
|
|
77
87
|
if (pending < 0 || pending > 0) return 0;
|
|
78
88
|
|
|
89
|
+
emitTaskBacklogEmpty?.(
|
|
90
|
+
buildTaskBacklogEmptyPayload(backlogLoops.map((entry) => entry.id)),
|
|
91
|
+
);
|
|
79
92
|
for (const entry of backlogLoops) {
|
|
80
93
|
debug?.(`task backlog loop #${entry.id} — no pending tasks remain, deleting`);
|
|
81
94
|
removeTrigger(entry.id);
|
|
82
95
|
deleteLoop(entry.id);
|
|
96
|
+
emitLoopAutodeleted?.(buildLoopAutodeletedPayload(entry, pending));
|
|
83
97
|
}
|
|
84
98
|
updateWidget();
|
|
85
99
|
return backlogLoops.length;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TaskStore } from "../task-store.js";
|
|
3
|
+
import type { TaskEntry, TaskStatus } from "../task-types.js";
|
|
4
|
+
import { emitNativeTaskEvent } from "./task-events.js";
|
|
5
|
+
|
|
6
|
+
export interface TaskBacklogResult {
|
|
7
|
+
created: boolean;
|
|
8
|
+
entry?: { id: string };
|
|
9
|
+
cleaned?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TaskMutationContext {
|
|
13
|
+
pi: ExtensionAPI;
|
|
14
|
+
taskStore: TaskStore;
|
|
15
|
+
evaluateTaskBacklog: (
|
|
16
|
+
taskStore: TaskStore,
|
|
17
|
+
pendingCount: number,
|
|
18
|
+
) => Promise<TaskBacklogResult>;
|
|
19
|
+
updateWidget: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UpdateTaskFields {
|
|
23
|
+
id: string;
|
|
24
|
+
status?: TaskStatus;
|
|
25
|
+
subject?: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Single source of truth for native task mutations, shared by the TaskCreate/
|
|
31
|
+
* TaskUpdate/TaskDelete tools and the tasks:rpc:* server so both surfaces emit
|
|
32
|
+
* identical events in one canonical order: mutate → emit → widget → backlog.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
async function settle(ctx: TaskMutationContext): Promise<TaskBacklogResult> {
|
|
36
|
+
ctx.updateWidget();
|
|
37
|
+
return await ctx.evaluateTaskBacklog(ctx.taskStore, ctx.taskStore.pendingCount());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function createTask(
|
|
41
|
+
ctx: TaskMutationContext,
|
|
42
|
+
params: { subject: string; description: string; metadata?: Record<string, unknown> },
|
|
43
|
+
): Promise<{ entry: TaskEntry; backlog: TaskBacklogResult }> {
|
|
44
|
+
const entry = ctx.taskStore.create(params.subject, params.description, params.metadata);
|
|
45
|
+
emitNativeTaskEvent(ctx.pi, "tasks:created", entry);
|
|
46
|
+
const backlog = await settle(ctx);
|
|
47
|
+
return { entry, backlog };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function updateTask(
|
|
51
|
+
ctx: TaskMutationContext,
|
|
52
|
+
params: UpdateTaskFields,
|
|
53
|
+
): Promise<{ entry: TaskEntry; backlog: TaskBacklogResult } | undefined> {
|
|
54
|
+
const { id, status, subject, description } = params;
|
|
55
|
+
let entry = ctx.taskStore.get(id);
|
|
56
|
+
if (!entry) return undefined;
|
|
57
|
+
|
|
58
|
+
const previousStatus = entry.status;
|
|
59
|
+
if (status === "in_progress") {
|
|
60
|
+
entry = ctx.taskStore.start(id);
|
|
61
|
+
if (entry) emitNativeTaskEvent(ctx.pi, "tasks:started", entry, previousStatus);
|
|
62
|
+
} else if (status === "completed") {
|
|
63
|
+
entry = ctx.taskStore.complete(id);
|
|
64
|
+
if (entry) emitNativeTaskEvent(ctx.pi, "tasks:completed", entry, previousStatus);
|
|
65
|
+
} else if (status === "pending") {
|
|
66
|
+
entry = ctx.taskStore.reopen(id);
|
|
67
|
+
if (entry) emitNativeTaskEvent(ctx.pi, "tasks:reopened", entry, previousStatus);
|
|
68
|
+
}
|
|
69
|
+
if (!entry) return undefined;
|
|
70
|
+
|
|
71
|
+
if (subject !== undefined || description !== undefined) {
|
|
72
|
+
// A details edit is not a transition: its previousStatus is the status
|
|
73
|
+
// current at edit time (i.e. after any transition above), so consumers
|
|
74
|
+
// never see a fabricated second transition.
|
|
75
|
+
const statusAtEdit = entry.status;
|
|
76
|
+
entry = ctx.taskStore.updateDetails(id, { subject, description });
|
|
77
|
+
if (entry) emitNativeTaskEvent(ctx.pi, "tasks:updated", entry, statusAtEdit);
|
|
78
|
+
}
|
|
79
|
+
if (!entry) return undefined;
|
|
80
|
+
|
|
81
|
+
const backlog = await settle(ctx);
|
|
82
|
+
return { entry, backlog };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function deleteTask(
|
|
86
|
+
ctx: TaskMutationContext,
|
|
87
|
+
id: string,
|
|
88
|
+
): Promise<{ entry: TaskEntry; backlog: TaskBacklogResult } | undefined> {
|
|
89
|
+
const existing = ctx.taskStore.get(id);
|
|
90
|
+
if (!existing || !ctx.taskStore.delete(id)) {
|
|
91
|
+
ctx.updateWidget();
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
emitNativeTaskEvent(ctx.pi, "tasks:deleted", existing, existing.status);
|
|
95
|
+
const backlog = await settle(ctx);
|
|
96
|
+
return { entry: existing, backlog };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function cleanTasks(ctx: TaskMutationContext): Promise<number> {
|
|
100
|
+
const pruned = ctx.taskStore.pruneCompleted();
|
|
101
|
+
await settle(ctx);
|
|
102
|
+
return pruned;
|
|
103
|
+
}
|
package/src/runtime/task-rpc.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
type CleanReply,
|
|
5
|
+
type CreateTaskReply,
|
|
6
|
+
type PendingReply,
|
|
7
|
+
type PingReply,
|
|
8
|
+
replyChannel,
|
|
9
|
+
TASKS_RPC,
|
|
10
|
+
} from "../rpc/channels.js";
|
|
11
|
+
import { type RpcReply, rpcCall } from "../rpc/cross-extension-rpc.js";
|
|
3
12
|
import type { TaskStore } from "../task-store.js";
|
|
4
13
|
import type { LoopEntry } from "../types.js";
|
|
14
|
+
import { NATIVE_TASKS_PROVIDER } from "./native-task-rpc.js";
|
|
5
15
|
import { emitNativeTaskEvent } from "./task-events.js";
|
|
6
16
|
|
|
7
17
|
export interface TaskRuntimeBridgeOptions {
|
|
@@ -11,6 +21,10 @@ export interface TaskRuntimeBridgeOptions {
|
|
|
11
21
|
getNativeTaskStore: () => TaskStore | undefined;
|
|
12
22
|
onNativeTaskCreated?: (taskStore: TaskStore) => void;
|
|
13
23
|
onNativeTasksPruned?: (taskStore: TaskStore) => Promise<void> | void;
|
|
24
|
+
/** Called when a detection window opens. */
|
|
25
|
+
onDetectionStarted?: () => void;
|
|
26
|
+
/** Called when a detection window closes (provider found or probe timed out). */
|
|
27
|
+
onDetectionSettled?: () => void;
|
|
14
28
|
debug?: (...args: unknown[]) => void;
|
|
15
29
|
}
|
|
16
30
|
|
|
@@ -29,48 +43,51 @@ export function createTaskRuntimeBridge(options: TaskRuntimeBridgeOptions): Task
|
|
|
29
43
|
getNativeTaskStore,
|
|
30
44
|
onNativeTaskCreated,
|
|
31
45
|
onNativeTasksPruned,
|
|
46
|
+
onDetectionStarted,
|
|
47
|
+
onDetectionSettled,
|
|
32
48
|
debug,
|
|
33
49
|
} = options;
|
|
50
|
+
let detectionEpoch = 0;
|
|
34
51
|
|
|
35
52
|
function checkTasksVersion() {
|
|
53
|
+
// Not rpcProbe: pi-loop's own native server also answers this ping, and a
|
|
54
|
+
// first-reply-wins probe would always settle on that self-reply. Keep the
|
|
55
|
+
// listener open for the whole window and skip self-replies so a slower
|
|
56
|
+
// external provider (pi-tasks) is still detected.
|
|
57
|
+
const epoch = ++detectionEpoch;
|
|
58
|
+
onDetectionStarted?.();
|
|
59
|
+
const settleCurrentDetection = () => {
|
|
60
|
+
if (epoch !== detectionEpoch) return;
|
|
61
|
+
onDetectionSettled?.();
|
|
62
|
+
};
|
|
36
63
|
const requestId = randomUUID();
|
|
37
64
|
const timer = setTimeout(() => {
|
|
38
65
|
unsub();
|
|
66
|
+
settleCurrentDetection();
|
|
39
67
|
}, 5000);
|
|
40
|
-
const unsub = pi.events.on(
|
|
68
|
+
const unsub = pi.events.on(replyChannel(TASKS_RPC.ping, requestId), (raw: unknown) => {
|
|
69
|
+
const reply = raw as RpcReply<PingReply> | undefined;
|
|
70
|
+
if (!reply?.success || !reply.data) return;
|
|
71
|
+
if (reply.data.provider === NATIVE_TASKS_PROVIDER) return;
|
|
72
|
+
if (reply.data.version === undefined) return;
|
|
41
73
|
unsub();
|
|
42
74
|
clearTimeout(timer);
|
|
43
|
-
|
|
44
|
-
|
|
75
|
+
setTasksAvailable(true);
|
|
76
|
+
settleCurrentDetection();
|
|
45
77
|
});
|
|
46
|
-
pi.events.emit(
|
|
78
|
+
pi.events.emit(TASKS_RPC.ping, { requestId });
|
|
47
79
|
}
|
|
48
80
|
|
|
49
81
|
async function autoCreateTask(entry: LoopEntry): Promise<string | undefined> {
|
|
50
82
|
if (!entry.autoTask) return undefined;
|
|
51
83
|
if (isTasksAvailable()) {
|
|
52
84
|
try {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const unsub = pi.events.on(`tasks:rpc:create:reply:${requestId}`, (raw: unknown) => {
|
|
60
|
-
unsub();
|
|
61
|
-
clearTimeout(timer);
|
|
62
|
-
const reply = raw as { success: boolean; data?: { id: string } };
|
|
63
|
-
if (reply.success && reply.data) resolve(reply.data.id);
|
|
64
|
-
else resolve(undefined);
|
|
65
|
-
});
|
|
66
|
-
pi.events.emit("tasks:rpc:create", {
|
|
67
|
-
requestId,
|
|
68
|
-
subject: entry.prompt.slice(0, 80),
|
|
69
|
-
description: `Auto-created from loop #${entry.id}`,
|
|
70
|
-
metadata: { loopId: entry.id, trigger: entry.trigger },
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
return taskId;
|
|
85
|
+
const reply = await rpcCall<CreateTaskReply>(pi.events, TASKS_RPC.create, {
|
|
86
|
+
subject: entry.prompt.slice(0, 80),
|
|
87
|
+
description: `Auto-created from loop #${entry.id}`,
|
|
88
|
+
metadata: { loopId: entry.id, trigger: entry.trigger },
|
|
89
|
+
}, 5000);
|
|
90
|
+
return reply.id;
|
|
74
91
|
} catch {
|
|
75
92
|
return undefined;
|
|
76
93
|
}
|
|
@@ -90,22 +107,11 @@ export function createTaskRuntimeBridge(options: TaskRuntimeBridgeOptions): Task
|
|
|
90
107
|
|
|
91
108
|
async function hasPendingTasks(): Promise<number> {
|
|
92
109
|
if (isTasksAvailable()) {
|
|
110
|
+
// -1 is this bridge's "unknown" sentinel, consumed by notification-runtime;
|
|
111
|
+
// the RPC layer itself rejects on failure.
|
|
93
112
|
try {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
const timer = setTimeout(() => {
|
|
97
|
-
unsub();
|
|
98
|
-
resolve(-1);
|
|
99
|
-
}, 3000);
|
|
100
|
-
const unsub = pi.events.on(`tasks:rpc:pending:reply:${requestId}`, (raw: unknown) => {
|
|
101
|
-
unsub();
|
|
102
|
-
clearTimeout(timer);
|
|
103
|
-
const reply = raw as { success: boolean; data?: { pending: number } };
|
|
104
|
-
resolve(reply.success && reply.data ? reply.data.pending : -1);
|
|
105
|
-
});
|
|
106
|
-
pi.events.emit("tasks:rpc:pending", { requestId });
|
|
107
|
-
});
|
|
108
|
-
return count;
|
|
113
|
+
const reply = await rpcCall<PendingReply>(pi.events, TASKS_RPC.pending, {}, 3000);
|
|
114
|
+
return reply.pending;
|
|
109
115
|
} catch {
|
|
110
116
|
return -1;
|
|
111
117
|
}
|
|
@@ -117,20 +123,8 @@ export function createTaskRuntimeBridge(options: TaskRuntimeBridgeOptions): Task
|
|
|
117
123
|
async function cleanDoneTasks(): Promise<void> {
|
|
118
124
|
if (isTasksAvailable()) {
|
|
119
125
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const timer = setTimeout(() => {
|
|
123
|
-
unsub();
|
|
124
|
-
resolve();
|
|
125
|
-
}, 3000);
|
|
126
|
-
const unsub = pi.events.on(`tasks:rpc:clean:reply:${requestId}`, () => {
|
|
127
|
-
unsub();
|
|
128
|
-
clearTimeout(timer);
|
|
129
|
-
debug?.("tasks:rpc:clean — done tasks swept");
|
|
130
|
-
resolve();
|
|
131
|
-
});
|
|
132
|
-
pi.events.emit("tasks:rpc:clean", { requestId });
|
|
133
|
-
});
|
|
126
|
+
await rpcCall<CleanReply>(pi.events, TASKS_RPC.clean, {}, 3000);
|
|
127
|
+
debug?.(`${TASKS_RPC.clean} — done tasks swept`);
|
|
134
128
|
} catch {
|
|
135
129
|
// timeout or error, ignore
|
|
136
130
|
}
|
package/src/scheduler.ts
CHANGED
|
@@ -50,7 +50,7 @@ export class CronScheduler {
|
|
|
50
50
|
const _scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : (entry.trigger as { schedule: string }).schedule;
|
|
51
51
|
|
|
52
52
|
const nextFire = computeNextFire(entry);
|
|
53
|
-
const minuteField = _scheduleExpr.trim().split(/\s+/)[0];
|
|
53
|
+
const minuteField = _scheduleExpr.trim().split(/\s+/)[0] ?? "";
|
|
54
54
|
const minuteStep = minuteField.startsWith("*/") ? parseInt(minuteField.slice(2), 10) || 30 : 30;
|
|
55
55
|
const jitter = computeJitter(entry.id, entry.recurring, minuteStep);
|
|
56
56
|
const fireTime = nextFire.getTime() + jitter;
|
package/src/tools/loop-tools.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { parseInterval } from "../loop-parse.js";
|
|
4
4
|
import type { LoopEntry, Trigger } from "../types.js";
|
|
5
|
+
import { textResult } from "./tool-result.js";
|
|
5
6
|
|
|
6
7
|
interface LoopStoreLike {
|
|
7
8
|
list(): LoopEntry[];
|
|
@@ -46,10 +47,6 @@ export interface LoopToolsOptions {
|
|
|
46
47
|
isTaskSystemReady: () => boolean;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
function textResult(msg: string) {
|
|
50
|
-
return { content: [{ type: "text" as const, text: msg }], details: undefined as any };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
50
|
function validateTrigger(trigger: Trigger): string | null {
|
|
54
51
|
if (trigger.type === "cron") {
|
|
55
52
|
const parts = trigger.schedule.trim().split(/\s+/);
|