@sgtbeatdown/pi-tasks 0.1.4 → 0.1.5

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 CHANGED
@@ -36,6 +36,8 @@ Wolfpack's relay owns durable mailbox delivery and peer forwarding; Pi owns task
36
36
 
37
37
  Set `PI_TASK_WORKER=1` only when launching a task-only worker. The exact value enables a fail-closed model `tool_call` gate; an absent value or any other value leaves ordinary interactive Pi behavior unchanged. Before assignment, only `agent_task_inbox`, `agent_task_status`, and `agent_task_wait` are allowed. Other current and future model tools are blocked with the stable reason `PI_TASK_WORKER_ASSIGNMENT_REQUIRED`. Explicit user `!`/`!!` shell commands are outside Pi's model `tool_call` event and are not intercepted.
38
38
 
39
+ `PI_TASK_WORKER=1` sessions are leaf roles. Workers cannot call `agent_task_send`; workers cannot call `agent_task_cancel`; workers cannot call `agent_task_ack`. These coordinator-tool attempts are blocked with the stable reason `PI_TASK_WORKER_COORDINATION_FORBIDDEN`, not the pre-assignment reason. `agent_task_message` is limited to the eligible incorporated assignment named by its input `taskId`. Generic role-orchestration guidance applies only to non-worker coordinators.
40
+
39
41
  The gate opens only when the current Pi session contains a structured `pi-tasks-event` entry for `task.created` and the same event belongs to a locally persisted active task targeted to the current endpoint. Rendered prompt text, terminal output, unknown events, foreign tasks, and stale terminal tasks do not authorize execution. On restart, durable session evidence reopens the gate only while the matching local task remains eligible.
40
42
 
41
43
  Preflighting `agent_task_done` marks that task as closing before sibling calls are preflighted. Ordinary tools remain blocked for a closing, pending-terminal, accepted-terminal, or `delivery_blocked` task; an idempotent `agent_task_done` retry for that same assigned task remains allowed. Another independently active assignment can still authorize work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sgtbeatdown/pi-tasks",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Durable Pi agent delegation through the Wolfpack task gateway.",
5
5
  "module": "src/index.ts",
6
6
  "exports": {
@@ -29,6 +29,8 @@ The default `agent_task_send` schema is exactly `to`, `task`, and optional `time
29
29
 
30
30
  For a multi-step project phase, retain one persistent implementer and one persistent read-only reviewer. Reuse a healthy role session for corrections and follow-up review; a completed task finishes one assignment, not the underlying session. Do not rotate a healthy role session for routine corrections. Rotate only for phase completion, material context degradation, harness failure, or required specialist independence. Keep at most one active assignment per role unless the user explicitly approves parallel work.
31
31
 
32
+ `PI_TASK_WORKER=1` sessions are leaf roles. Workers cannot call `agent_task_send`; workers cannot call `agent_task_cancel`; workers cannot call `agent_task_ack`. These coordinator-tool attempts are blocked with the stable reason `PI_TASK_WORKER_COORDINATION_FORBIDDEN`, not the pre-assignment reason. They may use `agent_task_message` only for the eligible incorporated assignment named by its input `taskId`. Generic role-orchestration guidance applies only to non-worker coordinators. Task workers must not create, rotate, or close Wolfpack sessions through shell or session-control tools.
33
+
32
34
  After every verified and acknowledged terminal task, explicitly retain a parent-spawned session only when concrete follow-up is likely; otherwise close it through canonical Wolfpack session control. Workers never close their own sessions. terminal `send` is only for explicit human steering; it is not task state, completion evidence, or a substitute for `agent_task_message`.
33
35
 
34
36
  ## delegation lifecycle
package/src/extension.ts CHANGED
@@ -18,7 +18,9 @@ const TARGET_NOT_REGISTERED_CODE = "TARGET_NOT_REGISTERED";
18
18
  const WAIT_POLL_MS = 250;
19
19
  const SUMMARY_MAX_CHARS = 1_200;
20
20
  const PRE_ASSIGNMENT_TOOLS = new Set(["agent_task_inbox", "agent_task_status", "agent_task_wait"]);
21
+ const COORDINATOR_ONLY_TASK_TOOLS = new Set(["agent_task_send", "agent_task_cancel", "agent_task_ack"]);
21
22
  export const WORKER_GATE_DENIAL_CODE = "PI_TASK_WORKER_ASSIGNMENT_REQUIRED";
23
+ export const WORKER_COORDINATION_FORBIDDEN_CODE = "PI_TASK_WORKER_COORDINATION_FORBIDDEN";
22
24
 
23
25
  const EndpointParams = Type.Object({
24
26
  relay: Type.String({ minLength: 1, description: "relay identifier" }),
@@ -130,11 +132,12 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
130
132
 
131
133
  pi.on("tool_call", async (event, context) => {
132
134
  if (!workerGateEnabled || PRE_ASSIGNMENT_TOOLS.has(event.toolName)) return undefined;
135
+ if (COORDINATOR_ONLY_TASK_TOOLS.has(event.toolName)) return { block: true, reason: WORKER_COORDINATION_FORBIDDEN_CODE };
133
136
  try {
134
137
  const activeCore = await configuredCore(context.signal);
135
138
  const assignments = assignedWorkerTasks(activeCore, context.sessionManager.getEntries());
139
+ const taskId = inputTaskId(event.input);
136
140
  if (event.toolName === "agent_task_done") {
137
- const taskId = isRecord(event.input) && typeof event.input.taskId === "string" ? event.input.taskId : undefined;
138
141
  const assigned = taskId === undefined ? undefined : assignments.find((task) => task.taskId === taskId);
139
142
  if (assigned === undefined || (!eligibleWorkerTask(assigned, closingTaskIds) && assigned.terminalDelivery.state === "not_submitted" && !closingTaskIds.has(assigned.taskId))) {
140
143
  return { block: true, reason: WORKER_GATE_DENIAL_CODE };
@@ -142,6 +145,10 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
142
145
  closingTaskIds.add(assigned.taskId);
143
146
  return undefined;
144
147
  }
148
+ if (event.toolName === "agent_task_message") {
149
+ const assigned = taskId === undefined ? undefined : assignments.find((task) => task.taskId === taskId);
150
+ return assigned !== undefined && eligibleWorkerTask(assigned, closingTaskIds) ? undefined : { block: true, reason: WORKER_GATE_DENIAL_CODE };
151
+ }
145
152
  if (assignments.some((task) => eligibleWorkerTask(task, closingTaskIds))) return undefined;
146
153
  } catch {
147
154
  // Authorization evidence unavailable or malformed: fail closed with the stable public reason.
@@ -229,7 +236,17 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
229
236
  });
230
237
  pi.registerTool({
231
238
  name: "agent_task_cancel", label: "Cancel Agent Task", description: "Persist cancellation before relay submission.", parameters: TaskIdParams,
232
- 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); } },
239
+ async execute(_id, params, signal) {
240
+ try {
241
+ const activeCore = await configuredCore(signal);
242
+ try {
243
+ await activeCore.submitIntent({ taskId: params.taskId, type: "task.cancelled", payload: {} }, signal);
244
+ return toolResult({ taskId: params.taskId }, `## task cancellation requested\n- task: \`${params.taskId}\``);
245
+ } catch (error) {
246
+ return blockedCancellationResult(activeCore, params.taskId, error) ?? taskError(error);
247
+ }
248
+ } catch (error) { return taskError(error); }
249
+ },
233
250
  renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
234
251
  });
235
252
  pi.registerTool({
@@ -276,6 +293,29 @@ function taskError(error: unknown): AgentToolResult<unknown> {
276
293
  return toolResult({ error: { code: "TASK_ERROR", message, retryable: true } }, `## task error\n- ${message}`);
277
294
  }
278
295
 
296
+ function blockedCancellationResult(core: TaskCore, taskId: string, error: unknown): AgentToolResult<unknown> | undefined {
297
+ if (!(error instanceof TaskOutboxDeliveryError) || error.code !== TARGET_NOT_REGISTERED_CODE || error.retryable || error.details?.taskId !== taskId) return undefined;
298
+ const task = core.getTask(taskId);
299
+ if (task?.status !== "cancelled" || !sameEndpoint(task.origin, core.endpoint)) return undefined;
300
+ const details = { ...error.details };
301
+ delete details.taskId;
302
+ delete details.envelopeId;
303
+ delete details.target;
304
+ delete details.blockedAt;
305
+ return toolResult({
306
+ taskId,
307
+ status: "cancelled",
308
+ warnings: [{
309
+ code: error.code,
310
+ message: error.message,
311
+ retryable: false,
312
+ delivery: "blocked",
313
+ target: task.target,
314
+ details,
315
+ }],
316
+ }, `## task cancelled\n- task: \`${taskId}\`\n- canonical status: cancelled\n- target delivery: blocked; receiver incorporation not confirmed`);
317
+ }
318
+
279
319
  function outboxFailureStatus(error: unknown): "tasks: relay unavailable" | "tasks: outbox degraded" {
280
320
  return error instanceof TaskOutboxDeliveryError && error.code === TARGET_NOT_REGISTERED_CODE ? "tasks: outbox degraded" : "tasks: relay unavailable";
281
321
  }
@@ -304,6 +344,10 @@ function sameEndpoint(left: TaskEndpoint, right: TaskEndpoint): boolean {
304
344
  return left.relay === right.relay && left.id === right.id;
305
345
  }
306
346
 
347
+ function inputTaskId(input: unknown): string | undefined {
348
+ return isRecord(input) && typeof input.taskId === "string" ? input.taskId : undefined;
349
+ }
350
+
307
351
  function isRecord(value: unknown): value is Record<string, unknown> {
308
352
  return value !== null && typeof value === "object" && !Array.isArray(value);
309
353
  }
package/src/task-core.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  MAX_RELAY_PAYLOAD_BYTES,
3
+ ORIGIN_CANCELLATION_OPERATION,
3
4
  PARENT_ACKNOWLEDGMENT_OPERATION,
4
5
  TASK_PROTOCOL_VERSION,
5
6
  TERMINAL_INTENT_OPERATION,
@@ -141,12 +142,20 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
141
142
  if (!RECEIVER_INTENT_TYPES.has(input.type) || !isRecord(input.payload)) throw new TaskProtocolError("INVALID_INTENT", "intent type or payload is invalid");
142
143
  let persistedEnvelopeIds: readonly string[] = [];
143
144
  let receiverTerminal = false;
145
+ let originCancellation = false;
144
146
  options.store.transaction(() => {
145
147
  const task = requiredTask(options.store, input.taskId);
146
148
  receiverTerminal = !sameEndpoint(options.endpoint, task.origin) && TERMINAL_EVENTS.has(input.type);
149
+ originCancellation = sameEndpoint(options.endpoint, task.origin) && input.type === "task.cancelled";
147
150
  persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, input);
148
151
  });
149
- await flush(options, signal, new Set(persistedEnvelopeIds));
152
+ try {
153
+ await flush(options, signal, new Set(persistedEnvelopeIds));
154
+ } catch (error) {
155
+ if (originCancellation) throwIfCancellationDeliveryBlocked(options, input.taskId, persistedEnvelopeIds);
156
+ throw error;
157
+ }
158
+ if (originCancellation) throwIfCancellationDeliveryBlocked(options, input.taskId, persistedEnvelopeIds);
150
159
  if (receiverTerminal) throwIfTerminalDeliveryBlocked(options.store, input.taskId);
151
160
  },
152
161
  async recordInsertion(input, signal) {
@@ -242,7 +251,8 @@ function persistReceivedEnvelope(options: TaskCoreOptions, now: () => number, id
242
251
 
243
252
  function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, input: SubmitIntentInput): readonly string[] {
244
253
  if (sameEndpoint(options.endpoint, task.origin)) {
245
- return canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type);
254
+ const operation = input.type === "task.cancelled" ? ORIGIN_CANCELLATION_OPERATION : undefined;
255
+ return canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type, operation);
246
256
  }
247
257
  const envelopeId = ids();
248
258
  const intent: TaskIntent = { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload };
@@ -339,6 +349,16 @@ function parsePayload(envelope: RelayEnvelope): unknown {
339
349
  try { return JSON.parse(envelope.payload) as unknown; } catch { throw new TaskProtocolError("INVALID_PAYLOAD", "relay envelope payload is not valid task protocol JSON"); }
340
350
  }
341
351
 
352
+ function throwIfCancellationDeliveryBlocked(options: TaskCoreOptions, taskId: string, envelopeIds: readonly string[]): void {
353
+ const task = requiredTask(options.store, taskId);
354
+ const blocked = options.store.quarantinedOutbox().find((record) => envelopeIds.includes(record.envelope.envelopeId) && sameEndpoint(record.envelope.target, task.target));
355
+ if (blocked === undefined) return;
356
+ throw new TaskOutboxDeliveryError(blocked.errorCode, blocked.reason, {
357
+ retryable: false,
358
+ details: { ...blocked.details, taskId, envelopeId: blocked.envelope.envelopeId, target: task.target, blockedAt: blocked.quarantinedAt },
359
+ });
360
+ }
361
+
342
362
  function throwIfTerminalDeliveryBlocked(store: TaskStore, taskId: string): void {
343
363
  const delivery = requiredTask(store, taskId).terminalDelivery;
344
364
  if (delivery.state !== "delivery_blocked") return;
@@ -1,5 +1,6 @@
1
1
  export const TASK_PROTOCOL_VERSION = "pi-tasks/v2" as const;
2
2
  export const MAX_RELAY_PAYLOAD_BYTES = 48 * 1024;
3
+ export const ORIGIN_CANCELLATION_OPERATION = "origin_cancellation";
3
4
  export const PARENT_ACKNOWLEDGMENT_OPERATION = "parent_acknowledgment";
4
5
  export const TERMINAL_INTENT_OPERATION = "terminal_intent";
5
6