@sgtbeatdown/pi-tasks 0.1.1 → 0.1.3

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/src/extension.ts CHANGED
@@ -3,205 +3,308 @@ import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-
3
3
  import { Text } from "@earendil-works/pi-tui";
4
4
  import { Type } from "typebox";
5
5
 
6
- import {
7
- DEFAULT_TASK_TIMEOUT_MS,
8
- GatewayClientError,
9
- MAX_TASK_TIMEOUT_MS,
10
- MIN_TASK_TIMEOUT_MS,
11
- createWolfpackGatewayClient,
12
- type TaskCompletionInput,
13
- type TaskStatus,
14
- type WolfpackGatewayClient,
15
- } from "./gateway-client";
16
- import { deliverTaskInbox, restoredInboxCursor } from "./task-inbox";
6
+ import { deliverTaskInbox, incorporatedTaskEvents } from "./task-inbox";
7
+ import { TaskOutboxDeliveryError, TaskProtocolError } from "./task-protocol";
8
+ import { createWolfpackTaskCore } from "./wolfpack-task-relay";
9
+ import type { TaskCore } from "./task-core";
10
+ import type { TaskEndpoint, TaskSnapshot } from "./task-protocol";
17
11
 
12
+ const DEFAULT_TASK_TIMEOUT_MS = 30 * 60 * 1_000;
13
+ const MIN_TASK_TIMEOUT_MS = 1_000;
14
+ const MAX_TASK_TIMEOUT_MS = 86_400_000;
18
15
  const BACKGROUND_POLL_MS = 5_000;
16
+ const TARGET_NOT_REGISTERED_CODE = "TARGET_NOT_REGISTERED";
19
17
  const WAIT_POLL_MS = 250;
20
18
  const SUMMARY_MAX_CHARS = 1_200;
19
+ const PRE_ASSIGNMENT_TOOLS = new Set(["agent_task_inbox", "agent_task_status", "agent_task_wait"]);
20
+ export const WORKER_GATE_DENIAL_CODE = "PI_TASK_WORKER_ASSIGNMENT_REQUIRED";
21
21
 
22
- const AddressParams = Type.Object({
23
- machine: Type.String({ minLength: 1, description: "local or a canonical Wolfpack machine identity" }),
24
- sessionId: Type.String({ minLength: 1, description: "Stable opaque Wolfpack broker session id" }),
22
+ const EndpointParams = Type.Object({
23
+ relay: Type.String({ minLength: 1, description: "relay identifier" }),
24
+ id: Type.String({ minLength: 1, description: "opaque endpoint identifier" }),
25
25
  });
26
-
27
- const ContextRefParams = Type.Object({
28
- path: Type.String({ minLength: 1 }),
29
- selector: Type.Optional(Type.String()),
30
- purpose: Type.Optional(Type.String()),
31
- });
32
-
33
26
  const SendParams = Type.Object({
34
- to: AddressParams,
27
+ to: EndpointParams,
35
28
  task: Type.String({ minLength: 1, maxLength: 16 * 1024 }),
36
- context: Type.Optional(Type.Object({
37
- summary: Type.Optional(Type.String({ minLength: 1, maxLength: 16 * 1024 })),
38
- refs: Type.Optional(Type.Array(ContextRefParams)),
39
- })),
40
- role: Type.Optional(Type.String()),
41
- preflight: Type.Optional(Type.Object({ requiredProject: Type.Optional(Type.String()) })),
42
- metadata: Type.Optional(Type.Object({
43
- phaseId: Type.Optional(Type.String()),
44
- issueId: Type.Optional(Type.String()),
45
- verificationTier: Type.Optional(Type.String()),
46
- rootCause: Type.Optional(Type.String()),
47
- })),
48
- onCompletePrompt: Type.Optional(Type.String({ maxLength: 4_000 })),
49
29
  timeoutMs: Type.Optional(Type.Integer({ minimum: MIN_TASK_TIMEOUT_MS, maximum: MAX_TASK_TIMEOUT_MS })),
50
- idempotencyKey: Type.Optional(Type.String({ minLength: 1 })),
51
30
  });
52
-
53
31
  const TaskIdParams = Type.Object({ taskId: Type.String({ minLength: 1 }) });
54
32
  const WaitParams = Type.Object({ taskId: Type.String({ minLength: 1 }), timeoutMs: Type.Optional(Type.Integer({ minimum: MIN_TASK_TIMEOUT_MS, maximum: MAX_TASK_TIMEOUT_MS })) });
55
- const InboxParams = Type.Object({ ack: Type.Optional(Type.Boolean({ default: false })), includeAcknowledged: Type.Optional(Type.Boolean({ default: false })) });
56
- const MessageParams = Type.Object({
57
- taskId: Type.String({ minLength: 1 }),
58
- type: StringEnum(["question", "answer", "information"] as const),
59
- message: Type.String({ minLength: 1, maxLength: 16 * 1024 }),
60
- replyToMessageId: Type.Optional(Type.String({ minLength: 1 })),
61
- });
33
+ const MessageParams = Type.Object({ taskId: Type.String({ minLength: 1 }), type: StringEnum(["question", "answer", "information"] as const), message: Type.String({ minLength: 1, maxLength: 16 * 1024 }) });
62
34
  const DoneParams = Type.Object({
63
- taskId: Type.String({ minLength: 1 }),
64
- status: StringEnum(["completed", "failed", "cancelled"] as const),
65
- summary: Type.String({ minLength: 1, maxLength: SUMMARY_MAX_CHARS }),
35
+ taskId: Type.String({ minLength: 1 }), status: StringEnum(["completed", "failed", "cancelled"] as const), summary: Type.String({ minLength: 1, maxLength: SUMMARY_MAX_CHARS }),
66
36
  result: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
67
37
  error: Type.Optional(Type.Object({ code: Type.String(), message: Type.String(), retryable: Type.Boolean() })),
68
- artifacts: Type.Optional(Type.Array(Type.Object({ path: Type.String({ minLength: 1 }), mimeType: Type.Optional(Type.String()), description: Type.Optional(Type.String()) }))),
38
+ artifacts: Type.Optional(Type.Array(Type.Object({ path: Type.String({ minLength: 1, description: "artifact paths are receiver-project-relative regular files" }), mimeType: Type.Optional(Type.String()), description: Type.Optional(Type.String()) }))),
69
39
  });
70
40
 
71
- export function registerAgentTaskTools(pi: ExtensionAPI, client: WolfpackGatewayClient = createWolfpackGatewayClient()): void {
72
- let backgroundTimer: ReturnType<typeof setInterval> | undefined;
41
+ export type ConfiguredCoreFactory = (signal?: AbortSignal) => Promise<TaskCore>;
73
42
 
74
- const refreshInbox = async (ctx: ExtensionContext): Promise<void> => {
75
- const cursor = restoredInboxCursor(ctx.sessionManager.getEntries());
76
- await deliverTaskInbox(pi, client, ctx, cursor);
43
+ /** Reuses a successful configured core while allowing the next lifecycle operation to retry a failed setup. */
44
+ export function createConfiguredCoreLoader(factory: ConfiguredCoreFactory): ConfiguredCoreFactory {
45
+ let corePromise: Promise<TaskCore> | undefined;
46
+ return async (signal?: AbortSignal): Promise<TaskCore> => {
47
+ if (corePromise) return corePromise;
48
+ const candidate = Promise.resolve().then(() => factory(signal));
49
+ corePromise = candidate;
50
+ try {
51
+ return await candidate;
52
+ } catch (error) {
53
+ if (corePromise === candidate) corePromise = undefined;
54
+ throw error;
55
+ }
56
+ };
57
+ }
58
+
59
+ /** Registers v2 endpoint-owned tools using a configured durable Wolfpack relay by default. */
60
+ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefined = undefined, createCore: ConfiguredCoreFactory = (signal) => createWolfpackTaskCore({}, signal)): void {
61
+ let inboxContext: ExtensionContext | undefined;
62
+ let backgroundTimer: ReturnType<typeof setInterval> | undefined;
63
+ let lifecycleEpoch = 0;
64
+ const pendingInsertions = new Set<string>();
65
+ const closingTaskIds = new Set<string>();
66
+ const workerGateEnabled = process.env.PI_TASK_WORKER === "1";
67
+ const configuredCore: ConfiguredCoreFactory = core === undefined ? createConfiguredCoreLoader(createCore) : async (): Promise<TaskCore> => core;
68
+ const refreshInbox = createSingleFlightInboxRefresh(async (signal) => {
69
+ const context = inboxContext;
70
+ if (!context) return undefined;
71
+ const activeCore = await configuredCore(signal);
72
+ if (inboxContext !== context) return undefined;
73
+ let outboxError: unknown;
74
+ try {
75
+ await activeCore.flushOutbox(signal);
76
+ } catch (error) {
77
+ outboxError = error;
78
+ }
79
+ if (inboxContext !== context) return undefined;
80
+ try {
81
+ await activeCore.evaluateTimeouts(signal);
82
+ } catch (error) {
83
+ outboxError ??= error;
84
+ }
85
+ if (inboxContext !== context) return undefined;
86
+ const isCurrent = (): boolean => inboxContext === context;
87
+ const guardedCore: TaskCore = {
88
+ ...activeCore,
89
+ async receive(receiveSignal) {
90
+ const deliveries = await activeCore.receive(receiveSignal);
91
+ return isCurrent() ? deliveries : [];
92
+ },
93
+ async acknowledgeRelayDelivery(cursor, acknowledgementSignal) {
94
+ if (isCurrent()) await activeCore.acknowledgeRelayDelivery(cursor, acknowledgementSignal);
95
+ },
96
+ async recordInsertion(input, insertionSignal) {
97
+ if (isCurrent()) await activeCore.recordInsertion(input, insertionSignal);
98
+ },
99
+ };
100
+ await deliverTaskInbox({
101
+ sendMessage(message, options) { if (isCurrent()) pi.sendMessage(message, options); },
102
+ appendEntry(customType, data) { if (isCurrent()) pi.appendEntry(customType, data); },
103
+ }, guardedCore, {
104
+ hasPendingMessages: (): boolean => !isCurrent() || context.hasPendingMessages(),
105
+ sessionManager: context.sessionManager,
106
+ }, pendingInsertions, signal);
107
+ return outboxError;
108
+ });
109
+ const refreshLifecycle = async (context: ExtensionContext, epoch: number): Promise<void> => {
110
+ const isCurrent = (): boolean => lifecycleEpoch === epoch && inboxContext === context;
111
+ try {
112
+ const activeCore = await configuredCore();
113
+ let connectionError: unknown;
114
+ try {
115
+ await activeCore.connect();
116
+ } catch (error) {
117
+ connectionError = error;
118
+ }
119
+ if (!isCurrent()) return;
120
+ const outboxError = await refreshInbox();
121
+ if (!isCurrent()) return;
122
+ const lifecycleError = outboxError ?? connectionError;
123
+ const status = lifecycleError === undefined ? undefined : outboxFailureStatus(lifecycleError);
124
+ context.ui.setStatus("pi-tasks", status === undefined ? undefined : context.ui.theme.fg("warning", status));
125
+ } catch (error) {
126
+ if (isCurrent()) context.ui.setStatus("pi-tasks", context.ui.theme.fg("warning", outboxFailureStatus(error)));
127
+ }
77
128
  };
78
129
 
79
- pi.on("session_start", async (_event, ctx) => {
80
- ctx.ui.setStatus("wolfpack-tasks", ctx.ui.theme.fg("dim", `tasks: ${client.callerSession}`));
130
+ pi.on("tool_call", async (event, context) => {
131
+ if (!workerGateEnabled || PRE_ASSIGNMENT_TOOLS.has(event.toolName)) return undefined;
132
+ try {
133
+ const activeCore = await configuredCore(context.signal);
134
+ const assignments = assignedWorkerTasks(activeCore, context.sessionManager.getEntries());
135
+ if (event.toolName === "agent_task_done") {
136
+ const taskId = isRecord(event.input) && typeof event.input.taskId === "string" ? event.input.taskId : undefined;
137
+ const assigned = taskId === undefined ? undefined : assignments.find((task) => task.taskId === taskId);
138
+ if (assigned === undefined || (!eligibleWorkerTask(assigned, closingTaskIds) && assigned.terminalDelivery.state === "not_submitted" && !closingTaskIds.has(assigned.taskId))) {
139
+ return { block: true, reason: WORKER_GATE_DENIAL_CODE };
140
+ }
141
+ closingTaskIds.add(assigned.taskId);
142
+ return undefined;
143
+ }
144
+ if (assignments.some((task) => eligibleWorkerTask(task, closingTaskIds))) return undefined;
145
+ } catch {
146
+ // Authorization evidence unavailable or malformed: fail closed with the stable public reason.
147
+ }
148
+ return { block: true, reason: WORKER_GATE_DENIAL_CODE };
149
+ });
150
+
151
+ pi.on("session_start", async (_event, context) => {
152
+ pendingInsertions.clear();
153
+ closingTaskIds.clear();
154
+ const epoch = ++lifecycleEpoch;
155
+ inboxContext = context;
156
+ if (backgroundTimer) clearInterval(backgroundTimer);
81
157
  backgroundTimer = setInterval(() => {
82
- void refreshInbox(ctx).catch(() => ctx.ui.setStatus("wolfpack-tasks", ctx.ui.theme.fg("warning", "tasks: unavailable")));
158
+ const currentContext = inboxContext;
159
+ if (currentContext) void refreshLifecycle(currentContext, epoch);
83
160
  }, BACKGROUND_POLL_MS);
84
- await refreshInbox(ctx).catch(() => undefined);
161
+ await refreshLifecycle(context, epoch);
85
162
  });
86
- pi.on("agent_end", async (_event, ctx) => {
87
- setTimeout(() => { void refreshInbox(ctx).catch(() => undefined); }, 0);
163
+ pi.on("agent_end", async (_event, context) => {
164
+ const epoch = lifecycleEpoch;
165
+ inboxContext = context;
166
+ await refreshLifecycle(context, epoch);
88
167
  });
89
168
  pi.on("session_shutdown", () => {
169
+ pendingInsertions.clear();
170
+ closingTaskIds.clear();
171
+ lifecycleEpoch += 1;
90
172
  if (backgroundTimer) clearInterval(backgroundTimer);
91
173
  backgroundTimer = undefined;
174
+ inboxContext = undefined;
92
175
  });
93
176
 
94
177
  pi.registerTool({
95
- name: "agent_task_send", label: "Send Agent Task", description: "Durably send a bounded task to an existing Wolfpack Pi session.",
96
- promptSnippet: "Send nonblocking structured tasks through the local Wolfpack gateway",
97
- promptGuidelines: ["Use agent_task_send for normal nonblocking delegation; do not wait unless the user explicitly asks."],
98
- parameters: SendParams,
99
- async execute(_toolCallId, params, signal) {
178
+ name: "agent_task_send", label: "Send Agent Task", description: "Persist an endpoint-owned task and submit its opaque assignment envelope to a relay.", parameters: SendParams,
179
+ async execute(_id, params, signal) {
100
180
  try {
101
- const receipt = await client.send(params, signal);
102
- return toolResult(receipt, `## task received\n- task: \`${receipt.taskId}\`\n- target: \`${params.to.machine}/${params.to.sessionId}\`\n${warnings(receipt.warnings)}`);
103
- } catch (error) { return clientError(error); }
104
- }, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
181
+ const sent = await (await configuredCore(signal)).createTask({ target: params.to, task: params.task, timeoutMs: params.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS }, signal);
182
+ return toolResult(sent, `## task accepted\n- task: \`${sent.taskId}\`\n- target: \`${params.to.relay}/${params.to.id}\`\n- delivery: relay acceptance only`);
183
+ } catch (error) { return taskError(error); }
184
+ },
185
+ renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
105
186
  });
106
-
107
187
  pi.registerTool({
108
- name: "agent_task_status", label: "Task Status", description: "Read compact structured task status from Wolfpack.", parameters: TaskIdParams,
109
- async execute(_toolCallId, params, signal) {
110
- try { const status = await client.status(params.taskId, signal); return statusResult(status); } catch (error) { return clientError(error); }
188
+ name: "agent_task_status", label: "Task Status", description: "Read local endpoint-owned task state; status is unavailable while its origin is offline.", parameters: TaskIdParams,
189
+ async execute(_id, params, signal) {
190
+ try {
191
+ const task = (await configuredCore(signal)).getTask(params.taskId);
192
+ return task ? toolResult(task, `## task status\n- task: \`${task.taskId}\`\n- status: ${task.status}`) : taskError(new Error("unknown local task"));
193
+ } catch (error) { return taskError(error); }
111
194
  }, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
112
195
  });
113
-
114
196
  pi.registerTool({
115
- name: "agent_task_wait", label: "Wait Agent Task", description: "Block only when explicitly invoked until a task reaches a terminal state or this wait times out.", parameters: WaitParams,
116
- async execute(_toolCallId, params, signal, onUpdate) {
197
+ name: "agent_task_wait", label: "Wait Agent Task", description: "Wait for a locally-known endpoint-owned task to reach a terminal state.", parameters: WaitParams,
198
+ async execute(_id, params, signal, onUpdate) {
117
199
  const deadline = Date.now() + (params.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS);
118
200
  try {
119
201
  for (;;) {
120
- throwIfAborted(signal);
121
- const status = await client.status(params.taskId, signal);
122
- if (terminal(status.status)) return statusResult(status);
123
- if (Date.now() >= deadline) return toolResult({ taskId: params.taskId, status: status.status, error: { code: "WAIT_TIMEOUT", retryable: true } }, `## task wait\n- task: \`${params.taskId}\`\n- status: ${status.status}\n- wait timed out`);
202
+ await refreshInbox(signal);
203
+ const task = (await configuredCore(signal)).getTask(params.taskId);
204
+ if (!task) return taskError(new Error("unknown local task"));
205
+ if (terminal(task.status)) return toolResult(task, `## task status\n- task: \`${task.taskId}\`\n- status: ${task.status}`);
206
+ if (Date.now() >= deadline) return toolResult({ taskId: params.taskId, status: task.status }, `## task wait\n- task: \`${params.taskId}\`\n- status: ${task.status}\n- wait timed out`);
124
207
  onUpdate?.({ content: [{ type: "text", text: `waiting for ${params.taskId}...` }], details: {} });
125
- await sleep(Math.min(WAIT_POLL_MS, Math.max(1, deadline - Date.now())), signal);
208
+ await sleep(Math.min(WAIT_POLL_MS, deadline - Date.now()), signal);
126
209
  }
127
- } catch (error) { return clientError(error); }
210
+ } catch (error) { return taskError(error); }
128
211
  }, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
129
212
  });
130
-
131
213
  pi.registerTool({
132
- name: "agent_task_inbox", label: "Task Inbox", description: "Read inbox task events and optionally acknowledge terminal parent tasks.", parameters: InboxParams,
133
- async execute(_toolCallId, params, signal) {
214
+ name: "agent_task_inbox", label: "Task Inbox", description: "Read local task records after processing relay deliveries without acknowledging task lifecycle.", parameters: Type.Object({}),
215
+ async execute(_id, _params, signal) {
134
216
  try {
135
- let cursor = "0";
136
- const taskIds = new Set<string>();
137
- for (;;) {
138
- const page = await client.inbox(cursor, params.includeAcknowledged ?? false, signal);
139
- for (const event of page.events) taskIds.add(event.taskId);
140
- if (!page.hasMore) break;
141
- cursor = page.nextCursor;
142
- }
143
- const tasks = await Promise.all([...taskIds].map((taskId) => client.status(taskId, signal)));
144
- if (params.ack) await Promise.all(tasks.filter((task) => terminal(task.status)).map((task) => client.ack(task.task.taskId, signal)));
145
- return toolResult({ tasks }, `## task inbox\n${tasks.length === 0 ? "- empty" : tasks.map((task) => `- \`${task.task.taskId}\`: ${task.status}${task.completion ? ` — ${task.completion.summary}` : ""}`).join("\n")}`);
146
- } catch (error) { return clientError(error); }
147
- }, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
217
+ await refreshInbox(signal);
218
+ const tasks = (await configuredCore(signal)).listTasks();
219
+ return toolResult({ tasks }, `## task inbox\n${tasks.map((task) => `- \`${task.taskId}\`: ${task.status}`).join("\n") || "- empty"}`);
220
+ } catch (error) { return taskError(error); }
221
+ },
222
+ renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
148
223
  });
149
-
150
224
  pi.registerTool({
151
- name: "agent_task_message", label: "Message Agent Task", description: "Durably send a question, answer, or information event for a task.", parameters: MessageParams,
152
- async execute(_toolCallId, params, signal) {
153
- try {
154
- const [identity, status] = await Promise.all([client.sessionStatus(signal), client.status(params.taskId, signal)]);
155
- const receipt = await client.message(params, signal);
156
- const receiverQuestion = params.type === "question" && identity.sessionId === status.task.target.sessionId;
157
- return { ...toolResult(receipt, `## task ${params.type}\n- task: \`${params.taskId}\`\n${params.message}`), ...(receiverQuestion ? { terminate: true } : {}) };
158
- } catch (error) { return clientError(error); }
159
- }, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
225
+ name: "agent_task_message", label: "Message Agent Task", description: "Persist a message intent before relay submission.", parameters: MessageParams,
226
+ async execute(_id, params, signal) { try { await (await configuredCore(signal)).submitIntent({ taskId: params.taskId, type: `task.${params.type}`, payload: { message: params.message } }, signal); return toolResult({ taskId: params.taskId }, `## task ${params.type}\n- task: \`${params.taskId}\``); } catch (error) { return taskError(error); } },
227
+ renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
160
228
  });
161
-
162
229
  pi.registerTool({
163
- name: "agent_task_cancel", label: "Cancel Agent Task", description: "Request cancellation for a non-terminal task through Wolfpack.", parameters: TaskIdParams,
164
- async execute(_toolCallId, params, signal) {
165
- try { const receipt = await client.cancel(params.taskId, signal); return toolResult(receipt, `## task cancellation requested\n- task: \`${params.taskId}\`${warnings(receipt.warnings)}`); } catch (error) { return clientError(error); }
166
- }, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
230
+ name: "agent_task_cancel", label: "Cancel Agent Task", description: "Persist cancellation before relay submission.", parameters: TaskIdParams,
231
+ async execute(_id, params, signal) { try { await (await configuredCore(signal)).submitIntent({ taskId: params.taskId, type: "task.cancelled", payload: {} }, signal); return toolResult({ taskId: params.taskId }, `## task cancellation requested\n- task: \`${params.taskId}\``); } catch (error) { return taskError(error); } },
232
+ renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
167
233
  });
168
-
169
234
  pi.registerTool({
170
- name: "agent_task_done", label: "Complete Agent Task", description: "Finish an assigned task with a structured terminal result. Use exactly once as the final action.",
171
- promptSnippet: "Complete an assigned task with a terminating structured result", promptGuidelines: ["Use agent_task_done exactly once as the final action for an assigned task."], parameters: DoneParams,
172
- async execute(_toolCallId, params, signal) {
235
+ name: "agent_task_ack", label: "Acknowledge Agent Task", description: "Record parent acknowledgement as an origin-owned logical event.", parameters: TaskIdParams,
236
+ async execute(_id, params, signal) { try { await (await configuredCore(signal)).acknowledgeParent(params.taskId, signal); return toolResult({ taskId: params.taskId }, `## task acknowledged\n- task: \`${params.taskId}\``); } catch (error) { return taskError(error); } },
237
+ renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
238
+ });
239
+ pi.registerTool({
240
+ name: "agent_task_done", label: "Complete Agent Task", description: "Persist a terminal task intent before relay submission.", parameters: DoneParams,
241
+ async execute(_id, params, signal) {
173
242
  try {
174
- const result: TaskCompletionInput = { summary: params.summary, ...(params.result && { result: params.result }), ...(params.error && { error: params.error }), ...(params.artifacts && { artifacts: params.artifacts }) };
175
- const receipt = await client.complete({ taskId: params.taskId, status: params.status, result }, signal);
176
- return { ...toolResult(receipt, `## task ${params.status}\n- task: \`${params.taskId}\`\n- ${params.summary}${warnings(receipt.warnings)}`), terminate: true };
177
- } catch (error) { return clientError(error); }
243
+ await (await configuredCore(signal)).submitIntent({ taskId: params.taskId, type: `task.${params.status}`, payload: { summary: params.summary, ...(params.result === undefined ? {} : { result: params.result }), ...(params.error === undefined ? {} : { error: params.error }), ...(params.artifacts === undefined ? {} : { artifacts: params.artifacts }) } }, signal);
244
+ return { ...toolResult({ taskId: params.taskId }, `## task ${params.status}\n- task: \`${params.taskId}\`\n- ${params.summary}`), terminate: true };
245
+ } catch (error) { return taskError(error); }
178
246
  }, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
179
247
  });
180
248
  }
181
249
 
182
- export default function piTasks(pi: ExtensionAPI): void {
183
- registerAgentTaskTools(pi);
250
+ export function createSingleFlightInboxRefresh<TValue>(refresh: (signal?: AbortSignal) => Promise<TValue>): (signal?: AbortSignal) => Promise<TValue> {
251
+ let inFlight: Promise<TValue> | undefined;
252
+ return (signal): Promise<TValue> => {
253
+ if (inFlight) return inFlight;
254
+ const current = refresh(signal).finally(() => {
255
+ if (inFlight === current) inFlight = undefined;
256
+ });
257
+ inFlight = current;
258
+ return current;
259
+ };
184
260
  }
185
261
 
186
- function statusResult(status: TaskStatus): AgentToolResult<unknown> {
187
- return toolResult(status, `## task status\n- task: \`${status.task.taskId}\`\n- status: ${status.status}${status.completion ? `\n- summary: ${status.completion.summary}` : ""}${warnings(status.warnings)}`);
262
+ export default function piTasks(pi: ExtensionAPI): void {
263
+ registerAgentTaskTools(pi);
188
264
  }
189
265
 
190
266
  function toolResult(details: unknown, markdown: string): AgentToolResult<unknown> {
191
267
  return { content: [{ type: "text", text: markdown }], details };
192
268
  }
193
269
 
194
- function clientError(error: unknown): AgentToolResult<unknown> {
195
- const gateway = error instanceof GatewayClientError ? error : new GatewayClientError("CLIENT_ERROR", error instanceof Error ? error.message : "task gateway request failed", true);
196
- return toolResult({ error: { code: gateway.code, message: gateway.message, retryable: gateway.retryable } }, `## task gateway error\n- ${gateway.code}: ${gateway.message}`);
270
+ function taskError(error: unknown): AgentToolResult<unknown> {
271
+ const message = error instanceof Error ? error.message : "task operation failed";
272
+ if (error instanceof TaskProtocolError) {
273
+ return toolResult({ ...error.details, error: { code: error.code, message, retryable: error.retryable } }, `## task error\n- ${message}`);
274
+ }
275
+ return toolResult({ error: { code: "TASK_ERROR", message, retryable: true } }, `## task error\n- ${message}`);
197
276
  }
198
277
 
199
- function warnings(values: readonly { readonly code: string; readonly message: string }[]): string {
200
- return values.length === 0 ? "" : `\n## gateway warnings\n${values.map((value) => `- ${value.code}: ${value.message}`).join("\n")}`;
278
+ function outboxFailureStatus(error: unknown): "tasks: relay unavailable" | "tasks: outbox degraded" {
279
+ return error instanceof TaskOutboxDeliveryError && error.code === TARGET_NOT_REGISTERED_CODE ? "tasks: outbox degraded" : "tasks: relay unavailable";
201
280
  }
202
281
 
203
282
  function terminal(status: string): boolean {
204
- return status === "completed" || status === "failed" || status === "cancelled" || status === "timed_out";
283
+ return ["completed", "failed", "cancelled", "timed_out"].includes(status);
284
+ }
285
+
286
+ function assignedWorkerTasks(core: TaskCore, entries: readonly unknown[]): readonly TaskSnapshot[] {
287
+ const assignments = new Map<string, TaskSnapshot>();
288
+ for (const evidence of incorporatedTaskEvents(entries)) {
289
+ const task = core.getTask(evidence.taskId);
290
+ if (!task || !sameEndpoint(task.target, core.endpoint)) continue;
291
+ const created = task.events.find((event) => event.eventId === evidence.eventId);
292
+ if (!created || created.type !== "task.created" || !sameEndpoint(created.target, core.endpoint) || !sameEndpoint(created.source, task.origin)) continue;
293
+ assignments.set(task.taskId, task);
294
+ }
295
+ return [...assignments.values()];
296
+ }
297
+
298
+ function eligibleWorkerTask(task: TaskSnapshot, closingTaskIds: ReadonlySet<string>): boolean {
299
+ return task.status === "active" && task.terminalDelivery.state === "not_submitted" && !closingTaskIds.has(task.taskId);
300
+ }
301
+
302
+ function sameEndpoint(left: TaskEndpoint, right: TaskEndpoint): boolean {
303
+ return left.relay === right.relay && left.id === right.id;
304
+ }
305
+
306
+ function isRecord(value: unknown): value is Record<string, unknown> {
307
+ return value !== null && typeof value === "object" && !Array.isArray(value);
205
308
  }
206
309
 
207
310
  function text(result: { readonly content?: readonly unknown[] }): string {
@@ -209,14 +312,10 @@ function text(result: { readonly content?: readonly unknown[] }): string {
209
312
  return first && typeof first === "object" && "type" in first && first.type === "text" && "text" in first && typeof first.text === "string" ? first.text : "";
210
313
  }
211
314
 
212
- function throwIfAborted(signal: AbortSignal | undefined): void {
213
- if (signal?.aborted) throw new GatewayClientError("ABORTED", "task wait was cancelled", true);
214
- }
215
-
216
315
  async function sleep(milliseconds: number, signal: AbortSignal | undefined): Promise<void> {
316
+ if (signal?.aborted) throw new Error("task wait was cancelled");
217
317
  await new Promise<void>((resolve, reject) => {
218
- const timeout = setTimeout(resolve, milliseconds);
219
- const abort = (): void => { clearTimeout(timeout); reject(new GatewayClientError("ABORTED", "task wait was cancelled", true)); };
220
- signal?.addEventListener("abort", abort, { once: true });
318
+ const timer = setTimeout(resolve, Math.max(1, milliseconds));
319
+ signal?.addEventListener("abort", () => { clearTimeout(timer); reject(new Error("task wait was cancelled")); }, { once: true });
221
320
  });
222
321
  }
@@ -20,12 +20,14 @@ export type GatewayErrorCode =
20
20
  export class GatewayClientError extends Error {
21
21
  readonly code: GatewayErrorCode;
22
22
  readonly retryable: boolean;
23
+ readonly path?: string;
23
24
 
24
- constructor(code: GatewayErrorCode, message: string, retryable: boolean) {
25
+ constructor(code: GatewayErrorCode, message: string, retryable: boolean, path?: string) {
25
26
  super(message);
26
27
  this.name = "GatewayClientError";
27
28
  this.code = code;
28
29
  this.retryable = retryable;
30
+ this.path = path;
29
31
  }
30
32
  }
31
33
 
@@ -187,7 +189,7 @@ export function createWolfpackGatewayClient(options: WolfpackGatewayClientOption
187
189
  signal: controller.signal,
188
190
  });
189
191
  const parsed = await parseJson(response);
190
- if (isErrorEnvelope(parsed)) throw new GatewayClientError(parsed.error.code, parsed.error.message, parsed.error.retryable);
192
+ if (isErrorEnvelope(parsed)) throw new GatewayClientError(parsed.error.code, parsed.error.message, parsed.error.retryable, parsed.error.path);
191
193
  if (!response.ok) throw new GatewayClientError("MALFORMED_RESPONSE", `wolfpack returned HTTP ${response.status} without a task error envelope`, true);
192
194
  if (!isOkEnvelope(parsed)) throw new GatewayClientError("MALFORMED_RESPONSE", "wolfpack returned an invalid task response", true);
193
195
  return parsed;
@@ -278,11 +280,28 @@ function serializeBody(body: unknown): string {
278
280
  }
279
281
 
280
282
  function validateSend(input: SendTaskInput): void {
281
- if (!isAddress(input.to) || !nonEmpty(input.task) || bytes(input.task) > MAX_TASK_BYTES) throw new GatewayClientError("INVALID_REQUEST", "task and target address are required and bounded", false);
282
- if (input.context?.summary !== undefined && (!nonEmpty(input.context.summary) || bytes(input.context.summary) > MAX_CONTEXT_SUMMARY_BYTES)) throw new GatewayClientError("INVALID_REQUEST", "context summary exceeds the 16KiB initial limit", false);
283
- if (bytes(JSON.stringify(input)) > MAX_ASSIGNMENT_ENVELOPE_BYTES) throw new GatewayClientError("INVALID_REQUEST", "assignment envelope exceeds the 48KiB initial limit", false);
283
+ const path = sendValidationPath(input);
284
+ if (path !== undefined) throw new GatewayClientError("INVALID_REQUEST", "task and target address are required and bounded", false, path);
285
+ if (input.context?.summary !== undefined && (!nonEmpty(input.context.summary) || bytes(input.context.summary) > MAX_CONTEXT_SUMMARY_BYTES)) throw new GatewayClientError("INVALID_REQUEST", "context summary exceeds the 16KiB initial limit", false, "/context/summary");
286
+ if (bytes(JSON.stringify(input)) > MAX_ASSIGNMENT_ENVELOPE_BYTES) throw new GatewayClientError("INVALID_REQUEST", "assignment envelope exceeds the 48KiB initial limit", false, "");
284
287
  const timeoutMs = input.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS;
285
- if (!Number.isInteger(timeoutMs) || timeoutMs < MIN_TASK_TIMEOUT_MS || timeoutMs > MAX_TASK_TIMEOUT_MS) throw new GatewayClientError("INVALID_REQUEST", "task timeout must be from 1000ms through 24h", false);
288
+ if (!Number.isInteger(timeoutMs) || timeoutMs < MIN_TASK_TIMEOUT_MS || timeoutMs > MAX_TASK_TIMEOUT_MS) throw new GatewayClientError("INVALID_REQUEST", "task timeout must be from 1000ms through 24h", false, "/timeoutMs");
289
+ }
290
+
291
+ function sendValidationPath(input: SendTaskInput): string | undefined {
292
+ if (!isRecord(input.to)) return "/to";
293
+ if (!nonEmpty(input.to.machine)) return "/to/machine";
294
+ if (!nonEmpty(input.to.sessionId)) return "/to/sessionId";
295
+ if (!nonEmpty(input.task) || bytes(input.task) > MAX_TASK_BYTES) return "/task";
296
+ if (input.preflight?.requiredProject !== undefined && !nonEmpty(input.preflight.requiredProject)) return "/preflight/requiredProject";
297
+ if (input.context?.refs !== undefined) {
298
+ if (!Array.isArray(input.context.refs)) return "/context/refs";
299
+ for (const [index, ref] of input.context.refs.entries()) {
300
+ if (!isRecord(ref)) return `/context/refs/${index}`;
301
+ if (!nonEmpty(ref.path)) return `/context/refs/${index}/path`;
302
+ }
303
+ }
304
+ return undefined;
286
305
  }
287
306
 
288
307
  function validateMessage(input: MessageTaskInput): void {
@@ -328,8 +347,13 @@ function isOkEnvelope(value: unknown): value is Record<string, unknown> & { read
328
347
  return isRecord(value) && value.ok === true;
329
348
  }
330
349
 
331
- function isErrorEnvelope(value: unknown): value is { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly retryable: boolean } } {
332
- return isRecord(value) && value.ok === false && isRecord(value.error) && nonEmpty(value.error.code) && typeof value.error.message === "string" && typeof value.error.retryable === "boolean";
350
+ function isErrorEnvelope(value: unknown): value is { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly retryable: boolean; readonly path?: string } } {
351
+ return isRecord(value) && value.ok === false && isRecord(value.error) && nonEmpty(value.error.code) && typeof value.error.message === "string" && typeof value.error.retryable === "boolean"
352
+ && (value.error.path === undefined || jsonPointer(value.error.path));
353
+ }
354
+
355
+ function jsonPointer(value: unknown): value is string {
356
+ return typeof value === "string" && /^(?:|\/(?:[^~/]|~[01])*)*$/.test(value);
333
357
  }
334
358
 
335
359
  function assignment(value: unknown): value is TaskAssignment {