@sgtbeatdown/pi-tasks 0.1.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/README.md +514 -0
- package/package.json +52 -0
- package/skills/wolfpack-pi-task-delegation/SKILL.md +75 -0
- package/src/auto-notify.ts +24 -0
- package/src/extension.ts +308 -0
- package/src/protocol.ts +73 -0
- package/src/store.ts +494 -0
- package/src/stores/filesystem.ts +40 -0
- package/src/task-communication.ts +68 -0
- package/src/transports/wolfpack.ts +29 -0
- package/src/types.ts +114 -0
- package/tsconfig.json +30 -0
package/src/extension.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
|
|
6
|
+
import { buildBackgroundTaskNotificationPrompt, selectUnpromptedTerminalTasks } from "./auto-notify";
|
|
7
|
+
import type { TaskCommunicationLayer } from "./task-communication";
|
|
8
|
+
import { createFilesystemTaskStore } from "./stores/filesystem";
|
|
9
|
+
import { createWolfpackTaskTransport } from "./transports/wolfpack";
|
|
10
|
+
import { buildAssignment, compactTaskResult } from "./protocol";
|
|
11
|
+
import type { AgentTaskRecord, TaskResultPayload, TerminalTaskStatus } from "./types";
|
|
12
|
+
import { DEFAULT_TIMEOUT_MS, TASK_PROTOCOL_VERSION } from "./types";
|
|
13
|
+
|
|
14
|
+
const WAIT_POLL_MS = 250;
|
|
15
|
+
const BACKGROUND_POLL_MS = 5000;
|
|
16
|
+
|
|
17
|
+
const SendParams = Type.Object({
|
|
18
|
+
to: Type.String({ description: "Target session, worker, or transport-specific address" }),
|
|
19
|
+
task: Type.String({ description: "Task instructions to send to the target" }),
|
|
20
|
+
timeoutMs: Type.Optional(Type.Integer({ minimum: 1000, maximum: 86_400_000 })),
|
|
21
|
+
idempotencyKey: Type.Optional(Type.String()),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const TaskIdParams = Type.Object({
|
|
25
|
+
taskId: Type.String({ description: "Task id, e.g. task_..." }),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const WaitParams = Type.Object({
|
|
29
|
+
taskId: Type.String({ description: "Task id, e.g. task_..." }),
|
|
30
|
+
timeoutMs: Type.Optional(Type.Integer({ minimum: 1000, maximum: 86_400_000 })),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const InboxParams = Type.Object({
|
|
34
|
+
ack: Type.Optional(Type.Boolean({ default: false })),
|
|
35
|
+
includeAcknowledged: Type.Optional(Type.Boolean({ default: false })),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const CancelParams = Type.Object({
|
|
39
|
+
taskId: Type.String({ description: "Task id, e.g. task_..." }),
|
|
40
|
+
reason: Type.Optional(Type.String()),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const DoneParams = Type.Object({
|
|
44
|
+
taskId: Type.String({ description: "Task id from the assignment" }),
|
|
45
|
+
status: StringEnum(["completed", "failed", "cancelled", "rejected"] as const),
|
|
46
|
+
summary: Type.String({ description: "Compact summary, max 1200 chars" }),
|
|
47
|
+
error: Type.Optional(
|
|
48
|
+
Type.Object({
|
|
49
|
+
code: Type.String(),
|
|
50
|
+
message: Type.String(),
|
|
51
|
+
retryable: Type.Boolean(),
|
|
52
|
+
}),
|
|
53
|
+
),
|
|
54
|
+
result: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
55
|
+
artifacts: Type.Optional(
|
|
56
|
+
Type.Array(
|
|
57
|
+
Type.Object({
|
|
58
|
+
name: Type.String(),
|
|
59
|
+
path: Type.Optional(Type.String()),
|
|
60
|
+
mimeType: Type.Optional(Type.String()),
|
|
61
|
+
description: Type.Optional(Type.String()),
|
|
62
|
+
}),
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export function createDefaultTaskCommunicationLayer(pi: ExtensionAPI): TaskCommunicationLayer {
|
|
68
|
+
return {
|
|
69
|
+
store: createFilesystemTaskStore(),
|
|
70
|
+
transport: createWolfpackTaskTransport({
|
|
71
|
+
exec: (command, args, options) => pi.exec(command, [...args], options),
|
|
72
|
+
}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function registerAgentTaskTools(pi: ExtensionAPI, communication: TaskCommunicationLayer): void {
|
|
77
|
+
const { store, transport } = communication;
|
|
78
|
+
let backgroundTimer: ReturnType<typeof setInterval> | undefined;
|
|
79
|
+
const notifiedTaskIds = new Set<string>();
|
|
80
|
+
const autoPromptedTaskIds = new Set<string>();
|
|
81
|
+
|
|
82
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
83
|
+
const sessionName = transport.getCurrentSessionName(process.env);
|
|
84
|
+
ctx.ui.setStatus("wolfpack-tasks", ctx.ui.theme.fg("dim", `tasks: ${sessionName}`));
|
|
85
|
+
|
|
86
|
+
backgroundTimer = setInterval(() => {
|
|
87
|
+
void refreshInboxStatus(ctx.cwd, sessionName, ctx).catch(() => {
|
|
88
|
+
ctx.ui.setStatus("wolfpack-tasks", ctx.ui.theme.fg("warning", "tasks: unavailable"));
|
|
89
|
+
});
|
|
90
|
+
}, BACKGROUND_POLL_MS);
|
|
91
|
+
|
|
92
|
+
await refreshInboxStatus(ctx.cwd, sessionName, ctx).catch(() => undefined);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
96
|
+
const sessionName = transport.getCurrentSessionName(process.env);
|
|
97
|
+
setTimeout(() => {
|
|
98
|
+
void refreshInboxStatus(ctx.cwd, sessionName, ctx).catch(() => undefined);
|
|
99
|
+
}, 0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
pi.on("session_shutdown", async () => {
|
|
103
|
+
if (backgroundTimer) {
|
|
104
|
+
clearInterval(backgroundTimer);
|
|
105
|
+
backgroundTimer = undefined;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
async function refreshInboxStatus(projectDir: string, sessionName: string, ctx: ExtensionContext): Promise<void> {
|
|
110
|
+
const inbox = await store.listInbox(projectDir, sessionName, { includeAcknowledged: false });
|
|
111
|
+
const count = inbox.length;
|
|
112
|
+
ctx.ui.setStatus("wolfpack-tasks", ctx.ui.theme.fg(count > 0 ? "accent" : "dim", `tasks: ${count} inbox`));
|
|
113
|
+
for (const task of inbox) {
|
|
114
|
+
if (notifiedTaskIds.has(task.id)) continue;
|
|
115
|
+
notifiedTaskIds.add(task.id);
|
|
116
|
+
ctx.ui.notify(`task ${task.id} ${task.status}: ${task.targetSession}`, task.status === "completed" ? "info" : "warning");
|
|
117
|
+
}
|
|
118
|
+
triggerIdleInboxPrompt(inbox, ctx);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function triggerIdleInboxPrompt(inbox: readonly AgentTaskRecord[], ctx: ExtensionContext): void {
|
|
122
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
|
|
123
|
+
|
|
124
|
+
const tasksToPrompt = selectUnpromptedTerminalTasks(inbox, autoPromptedTaskIds);
|
|
125
|
+
if (tasksToPrompt.length === 0) return;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
pi.sendUserMessage(buildBackgroundTaskNotificationPrompt(tasksToPrompt));
|
|
129
|
+
for (const task of tasksToPrompt) {
|
|
130
|
+
autoPromptedTaskIds.add(task.id);
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
ctx.ui.notify("task results ready; parent is busy, will retry", "info");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
pi.registerTool({
|
|
138
|
+
name: "agent_task_send",
|
|
139
|
+
label: "Send Agent Task",
|
|
140
|
+
description: "Send a structured task to another agent/session/worker and return immediately without waiting.",
|
|
141
|
+
promptSnippet: "Send nonblocking structured tasks to other agents/sessions/workers",
|
|
142
|
+
promptGuidelines: [
|
|
143
|
+
"Use agent_task_send instead of natural-language polling when delegating work to another agent/session/worker",
|
|
144
|
+
"After agent_task_send returns, keep working unless the user explicitly asks to wait for the task.",
|
|
145
|
+
],
|
|
146
|
+
parameters: SendParams,
|
|
147
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
148
|
+
const parentSession = transport.getCurrentSessionName(process.env);
|
|
149
|
+
|
|
150
|
+
const createdTask = await store.createOrReuseDispatchedTask({
|
|
151
|
+
projectDir: ctx.cwd,
|
|
152
|
+
parentSession,
|
|
153
|
+
targetSession: params.to,
|
|
154
|
+
taskText: params.task,
|
|
155
|
+
assignment: (taskId: string) => buildAssignment({ taskId, fromSession: parentSession, instructions: params.task }),
|
|
156
|
+
timeoutMs: params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
157
|
+
idempotencyKey: params.idempotencyKey,
|
|
158
|
+
targetTaskProtocol: TASK_PROTOCOL_VERSION,
|
|
159
|
+
});
|
|
160
|
+
const task = createdTask.task;
|
|
161
|
+
if (!createdTask.created) {
|
|
162
|
+
return taskToolResult(compactTaskResult(task, await store.readTaskResult(ctx.cwd, task.id)));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const assignment = buildAssignment({ taskId: task.id, fromSession: parentSession, instructions: params.task });
|
|
166
|
+
const dispatch = await transport.dispatchTask({ projectDir: ctx.cwd, task, target: params.to, assignment, signal });
|
|
167
|
+
if (!dispatch.ok) {
|
|
168
|
+
const rejected = await store.completeTask(ctx.cwd, task.id, "rejected", {
|
|
169
|
+
summary: `dispatch failed: ${dispatch.message}`,
|
|
170
|
+
error: {
|
|
171
|
+
code: "dispatch_failed",
|
|
172
|
+
message: dispatch.message,
|
|
173
|
+
retryable: dispatch.retryable,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
return taskToolResult(compactTaskResult(rejected, await store.readTaskResult(ctx.cwd, task.id)));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return taskToolResult({
|
|
180
|
+
schemaVersion: 1,
|
|
181
|
+
taskId: task.id,
|
|
182
|
+
status: task.status,
|
|
183
|
+
summary: `dispatched to ${params.to}`,
|
|
184
|
+
artifacts: [task.assignmentRef].filter((value): value is string => Boolean(value)),
|
|
185
|
+
error: null,
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
renderResult(result, _options, theme) {
|
|
189
|
+
return new Text(theme.fg("accent", getText(result)));
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
pi.registerTool({
|
|
194
|
+
name: "agent_task_status",
|
|
195
|
+
label: "Task Status",
|
|
196
|
+
description: "Read compact structured status for a task.",
|
|
197
|
+
parameters: TaskIdParams,
|
|
198
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
199
|
+
const task = await store.expireTaskIfOverdue(ctx.cwd, params.taskId);
|
|
200
|
+
return taskToolResult(compactTaskResult(task, await store.readTaskResult(ctx.cwd, params.taskId)));
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
pi.registerTool({
|
|
205
|
+
name: "agent_task_wait",
|
|
206
|
+
label: "Wait Agent Task",
|
|
207
|
+
description: "Wait in tool code for a task to become terminal. Use only when the user wants the result now.",
|
|
208
|
+
parameters: WaitParams,
|
|
209
|
+
async execute(_toolCallId, params, _signal, onUpdate, ctx) {
|
|
210
|
+
const waitMs = params.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
211
|
+
const sessionName = transport.getCurrentSessionName(process.env);
|
|
212
|
+
const progressTimer = setInterval(() => {
|
|
213
|
+
onUpdate?.({ content: [{ type: "text", text: `waiting for ${params.taskId}...` }], details: {} });
|
|
214
|
+
}, 2000);
|
|
215
|
+
try {
|
|
216
|
+
const task = await store.waitForTask(ctx.cwd, params.taskId, { timeoutMs: waitMs, pollMs: WAIT_POLL_MS, ackParentSession: sessionName });
|
|
217
|
+
return taskToolResult(compactTaskResult(task, await store.readTaskResult(ctx.cwd, params.taskId)));
|
|
218
|
+
} catch (error) {
|
|
219
|
+
const task = await store.readTask(ctx.cwd, params.taskId);
|
|
220
|
+
return taskToolResult({
|
|
221
|
+
schemaVersion: 1,
|
|
222
|
+
taskId: task.id,
|
|
223
|
+
status: task.status,
|
|
224
|
+
summary: error instanceof Error ? error.message : "task wait failed",
|
|
225
|
+
artifacts: task.resultRef ? [task.resultRef] : [],
|
|
226
|
+
error: { code: "wait_timeout", message: "task wait timed out", retryable: true },
|
|
227
|
+
});
|
|
228
|
+
} finally {
|
|
229
|
+
clearInterval(progressTimer);
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
pi.registerTool({
|
|
235
|
+
name: "agent_task_inbox",
|
|
236
|
+
label: "Task Inbox",
|
|
237
|
+
description: "List terminal tasks for this parent session. Results are only shown to the model when this tool is called.",
|
|
238
|
+
parameters: InboxParams,
|
|
239
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
240
|
+
const sessionName = transport.getCurrentSessionName(process.env);
|
|
241
|
+
const tasks = await store.listInbox(ctx.cwd, sessionName, { includeAcknowledged: params.includeAcknowledged ?? false });
|
|
242
|
+
const compact = [];
|
|
243
|
+
for (const task of tasks) {
|
|
244
|
+
compact.push(compactTaskResult(task, await store.readTaskResult(ctx.cwd, task.id)));
|
|
245
|
+
if (params.ack) {
|
|
246
|
+
await store.ackTask(ctx.cwd, task.id, sessionName);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return taskToolResult({ tasks: compact });
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
pi.registerTool({
|
|
254
|
+
name: "agent_task_cancel",
|
|
255
|
+
label: "Cancel Agent Task",
|
|
256
|
+
description: "Cancel a non-terminal task in the shared task store.",
|
|
257
|
+
parameters: CancelParams,
|
|
258
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
259
|
+
const task = await store.cancelTask(ctx.cwd, params.taskId, params.reason);
|
|
260
|
+
return taskToolResult(compactTaskResult(task, await store.readTaskResult(ctx.cwd, params.taskId)));
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
pi.registerTool({
|
|
265
|
+
name: "agent_task_done",
|
|
266
|
+
label: "Complete Agent Task",
|
|
267
|
+
description: "Finish an assigned task with structured status. Use exactly once as the final action for assigned tasks.",
|
|
268
|
+
promptSnippet: "Complete an assigned task with a terminating structured result",
|
|
269
|
+
promptGuidelines: [
|
|
270
|
+
"Use agent_task_done exactly once as the final action for pi.task.assignment.v1 tasks.",
|
|
271
|
+
"After calling agent_task_done, do not emit another assistant response in prose.",
|
|
272
|
+
],
|
|
273
|
+
parameters: DoneParams,
|
|
274
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
275
|
+
const status = params.status as TerminalTaskStatus;
|
|
276
|
+
const payload: TaskResultPayload = {
|
|
277
|
+
summary: params.summary,
|
|
278
|
+
...(params.result && { result: params.result }),
|
|
279
|
+
...(params.error && { error: params.error }),
|
|
280
|
+
...(params.artifacts && { artifacts: params.artifacts }),
|
|
281
|
+
};
|
|
282
|
+
const task = await store.completeTask(ctx.cwd, params.taskId, status, payload);
|
|
283
|
+
return {
|
|
284
|
+
...taskToolResult(compactTaskResult(task, await store.readTaskResult(ctx.cwd, params.taskId))),
|
|
285
|
+
terminate: true,
|
|
286
|
+
};
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export default function piTasks(pi: ExtensionAPI): void {
|
|
292
|
+
registerAgentTaskTools(pi, createDefaultTaskCommunicationLayer(pi));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function taskToolResult(details: unknown): AgentToolResult<unknown> {
|
|
296
|
+
return {
|
|
297
|
+
content: [{ type: "text", text: JSON.stringify(details) }],
|
|
298
|
+
details,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function getText(result: { readonly content?: readonly unknown[] }): string {
|
|
303
|
+
const first = result.content?.[0];
|
|
304
|
+
if (!first || typeof first !== "object" || !("type" in first) || first.type !== "text" || !("text" in first)) {
|
|
305
|
+
return "";
|
|
306
|
+
}
|
|
307
|
+
return typeof first.text === "string" ? first.text : "";
|
|
308
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SUMMARY_MAX_CHARS,
|
|
3
|
+
TASK_ASSIGNMENT_TYPE,
|
|
4
|
+
TASK_PROTOCOL_VERSION,
|
|
5
|
+
type AgentTaskRecord,
|
|
6
|
+
type StoredTaskResult,
|
|
7
|
+
type TaskError,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
export interface AssignmentInput {
|
|
11
|
+
readonly taskId: string;
|
|
12
|
+
readonly fromSession: string;
|
|
13
|
+
readonly instructions: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AssignmentEnvelope {
|
|
17
|
+
readonly type: typeof TASK_ASSIGNMENT_TYPE;
|
|
18
|
+
readonly taskId: string;
|
|
19
|
+
readonly fromSession: string;
|
|
20
|
+
readonly instructions: string;
|
|
21
|
+
readonly finishByCalling: "agent_task_done";
|
|
22
|
+
readonly taskProtocol: typeof TASK_PROTOCOL_VERSION;
|
|
23
|
+
readonly resultContract: {
|
|
24
|
+
readonly summaryMaxChars: number;
|
|
25
|
+
readonly noFinalProseAfterTool: true;
|
|
26
|
+
readonly completionIsStructuredOnly: true;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CompactTaskResult {
|
|
31
|
+
readonly schemaVersion: 1;
|
|
32
|
+
readonly taskId: string;
|
|
33
|
+
readonly status: AgentTaskRecord["status"];
|
|
34
|
+
readonly summary: string;
|
|
35
|
+
readonly artifacts: readonly string[];
|
|
36
|
+
readonly error: TaskError | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildAssignment(input: AssignmentInput): string {
|
|
40
|
+
const envelope: AssignmentEnvelope = {
|
|
41
|
+
type: TASK_ASSIGNMENT_TYPE,
|
|
42
|
+
taskId: input.taskId,
|
|
43
|
+
fromSession: input.fromSession,
|
|
44
|
+
instructions: input.instructions,
|
|
45
|
+
finishByCalling: "agent_task_done",
|
|
46
|
+
taskProtocol: TASK_PROTOCOL_VERSION,
|
|
47
|
+
resultContract: {
|
|
48
|
+
summaryMaxChars: SUMMARY_MAX_CHARS,
|
|
49
|
+
noFinalProseAfterTool: true,
|
|
50
|
+
completionIsStructuredOnly: true,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return [
|
|
55
|
+
"structured task assignment:",
|
|
56
|
+
"```json",
|
|
57
|
+
JSON.stringify(envelope, null, 2),
|
|
58
|
+
"```",
|
|
59
|
+
"finish by calling agent_task_done exactly once. do not mark completion in prose.",
|
|
60
|
+
].join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function compactTaskResult(task: AgentTaskRecord, result: StoredTaskResult | undefined): CompactTaskResult {
|
|
64
|
+
return {
|
|
65
|
+
schemaVersion: 1,
|
|
66
|
+
taskId: task.id,
|
|
67
|
+
status: task.status,
|
|
68
|
+
summary: result?.summary ?? task.error?.message ?? task.status,
|
|
69
|
+
artifacts: task.resultRef ? [task.resultRef] : [],
|
|
70
|
+
error: result?.error ?? task.error ?? null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|