@sgtbeatdown/pi-tasks 0.1.12 → 0.1.14

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
@@ -1,101 +1,189 @@
1
1
  # pi-tasks
2
2
 
3
- Endpoint-owned task coordination for Pi over Wolfpack's memory-only relay. **Both relay mail and endpoint task bookkeeping live in RAM.** Pi's existing session history is the historical record; there is no SQLite database, task-history directory, migration, disk outbox or startup replay.
3
+ **give another pi agent a task, exchange messages, and get a structured result back.**
4
4
 
5
- The [relay control API](https://github.com/almogdepaz/wolfpack/blob/main/docs/control-api-schema.md#pi-tasks-relay-v2-boundary) defines the transport boundary.
5
+ pi-tasks adds coordination tools to [pi](https://pi.dev). a parent agent can hand work to another session, keep working, and receive a follow-up when the worker asks a question or finishes. it works between sessions on one machine and, through wolfpack's trusted tailnet routing, across machines.
6
6
 
7
- ## endpoint-owned relay
7
+ for example: an implementer works on a change while the parent investigates something else. the implementer reports its result; the parent checks the diff and tests, then acknowledges the task.
8
8
 
9
- The default extension requires a compatible Wolfpack memory-owned server. It negotiates `volatile-v1` at `POST /api/task-relay/volatile-v1`. No transport opt-in flag is needed. The old durable relay adapter is removed, not retained as a compatibility mode.
9
+ **pi-tasks coordinates work. it does not create agent sessions or execute the assignment itself.** wolfpack manages sessions and carries messages; the agents do the work.
10
10
 
11
- Set `WOLFPACK_SESSION_NAME` for each Pi process. `WOLFPACK_PORT` defaults to `18790`; `WOLFPACK_SESSION_NAME` resolves the active Pi process to its relay endpoint. Read `taskEndpoint` from structured session creation/status and pass its opaque `{ relay, id }` unchanged. Never derive it from a name, terminal output or broker ID.
11
+ ## how it works
12
12
 
13
- ### minimal valid v2 send envelope
14
-
15
- ```json
16
- {
17
- "to": {
18
- "relay": "wolfpack-pi-tasks-v2",
19
- "id": "target-opaque-endpoint-id"
20
- },
21
- "task": "implement the narrow change and run focused tests"
22
- }
13
+ ```text
14
+ parent agent ── assignment ──> worker agent
15
+ <── questions ──
16
+ ─── answers ───>
17
+ <─── result ────
18
+ ─ acknowledgment >
23
19
  ```
24
20
 
25
- The `agent_task_send` schema is exactly `to`, `task`, and optional `timeoutMs`. Unsupported fields are rejected before admission: a pre-admission validation rejection creates no task. If an outcome is uncertain, idempotency remains necessary within the current lifetime; inspect the returned task ID rather than blindly creating another assignment.
21
+ 1. the parent sends instructions to the worker's **task endpoint**: an opaque address for that running session.
22
+ 2. the relay carries the assignment. the worker's pi extension queues it as a follow-up, without interrupting an active turn.
23
+ 3. the worker does the work, optionally exchanges messages, and submits a structured completion or failure.
24
+ 4. the parent receives the result, verifies it, and acknowledges the task.
26
25
 
27
- ## history versus live state
26
+ completion ends the **task**, not the **session**. keep a worker for another assignment, or close it through wolfpack when you no longer need it.
28
27
 
29
- - Pi requests task-processing turns through its safe `deliverAs: "followUp"` queue. Pi session messages and tool calls/results preserve historical context. Non-waking receipts, parent ACKs and late-terminal facts are archived as `pi-tasks-event-record` custom entries before transport ACK, without waking the model. Received model-visible task events include the complete structured event in `pi-tasks-event` details, including results—not just a rendered summary.
30
- - Active tasks, outbox intentions, individual ACK checkpoints, deduplication and delivery-blocked evidence are RAM-owned. Each endpoint has lower-only limits of16,384 records/32MiB encoded state. Admission fails before exceeding those bounds; they are not a process-RSS guarantee.
31
- - `agent_task_send` confirms relay acceptance, **not execution**. `receiver_recorded` confirms receiver RAM receipt, **not durable persistence**. Pi insertion and wake acceptance are separate evidence, never proof of model execution.
32
- - Session history is not an operational journal. A new process/lifecycle starts empty with a fresh endpoint/generation; it does not reconstruct tasks, replay envelopes, reuse old ACKs or reopen a worker gate from old history.
33
- - A relay restart can lose even accepted mail. A Pi restart loses that endpoint's active task state. Relay-loss evidence marks affected work failed only in the discarded local lifetime; its remote outcome remains explicitly unknown and is never presented as canonical delivery or completion evidence.
28
+ ## use with wolfpack
34
29
 
35
- ## memory-owned lifecycle
30
+ this is the ready-made integration. you need pi, a compatible wolfpack memory-only server, and this package loaded in every participating pi session.
36
31
 
37
- `createConfiguredTaskCore()` owns its transport and `createTaskStore()` RAM state. Startup registers; polling runs every five seconds and at agent settlement. Shutdown fences new work, aborts requests, drains active calls and discards state. Late callbacks cannot mutate a successor lifetime.
32
+ ### 1. install the pi package
38
33
 
39
- The default extension automatically replaces a lifetime only after a structured `RELAY_RESET` or `RELAY_REBIND_REQUIRED`. It fences and discards the unusable RAM state, records each active task in session history as locally failed with `RELAY_STATE_LOST` and an explicitly unknown remote outcome, then binds a fresh empty endpoint. The loss record does not wake the model by itself. Transient availability, authentication, capacity, abort, profile and malformed-response failures never trigger replacement. Historical tasks are not adopted into the successor lifetime.
34
+ ```bash
35
+ pi install npm:@sgtbeatdown/pi-tasks
36
+ ```
40
37
 
41
- `createVolatileTaskSession({ url, callerSession, store })` is the lower-level API for a caller-owned RAM store. Transport reconnect within that same lifetime retains pending immutable retries; `rebind()` clears the store, while `close()` stops transport and the caller then closes its state. Persistence/path options are rejected. Old database files are neither read nor deleted.
38
+ start new pi sessions after installation. wolfpack supplies `WOLFPACK_SESSION_NAME` to its sessions; the extension uses it to register the current process. the local control port defaults to `18790` (`WOLFPACK_PORT` overrides it).
42
39
 
43
- Full immutable content, including the original creation timestamp, is reused for retries. Destination-confirmed acceptance is required. Conflicting content, exhausted delivery and reset cannot become successful acceptance. Individual ACKs preserve sparse bigint cursors and cannot skip earlier pending mail. Bounds/deadlines apply through complete response bodies, including noncooperative injected transports.
40
+ ### 2. open a worker
44
41
 
45
- ## trusted Tailnet and optional authentication
42
+ from a pi parent running under wolfpack:
46
43
 
47
- The intended deployment trusts its Tailnet machines; Tailscale access control is the network boundary. Do not expose owner APIs through the public internet/Funnel or an untrusted proxy. No additional JWT or peer-signature scheme is required by the task protocol.
44
+ ```bash
45
+ # choose an available pi model explicitly.
46
+ MODEL="openai-codex/gpt-6.1-sol:medium"
47
+ wolfpack agent spawn --project-dir /absolute/worktree \
48
+ --name task-implementation --model "$MODEL" \
49
+ --task-worker --readiness-timeout-ms 30000 --json
50
+ ```
48
51
 
49
- Existing global Wolfpack JWT enforcement remains **optional**. If the owner configures `WOLFPACK_JWT_SECRET`, the configured Pi factory supports it with short-lived tokens only for HTTP loopback. It never discovers/distributes secrets or automatically forwards them to arbitrary HTTPS URLs. HTTP401 reports `RELAY_AUTH_REQUIRED`, not an epoch reset.
52
+ replace `/absolute/worktree` with the directory the worker should use. this starts a prompt-free worker and returns a stable `sessionId` and a registered `taskEndpoint`. registration means the session can receive mail—not that its model has executed anything.
50
53
 
51
- Remote task endpoints must be locally resolved aliases. The paired CLI qualifies a selected remote endpoint through the local coordinator; remote lists expose explicit remote metadata. Failed qualification preserves any created session/ID but removes unsafe `taskEndpoint` output and reports `taskEndpointError`.
54
+ for an existing session, use `wolfpack session status <sessionId> --json`. copy its returned `taskEndpoint` unchanged; never construct an endpoint from a session name.
52
55
 
53
- ## delegation workflow
56
+ ### 3. assign work
54
57
 
55
- Configure `WOLFPACK_IMPLEMENTER_MODEL` and `WOLFPACK_REVIEWER_MODEL`; defaults are `openai-codex/gpt-5.6-terra` and `openai-codex/gpt-5.6-sol`. Explicit user or project choices override those defaults.
58
+ ask the parent agent to call `agent_task_send` with that endpoint:
56
59
 
57
- ```bash
58
- IMPLEMENTER_MODEL="${WOLFPACK_IMPLEMENTER_MODEL:-openai-codex/gpt-5.6-terra}"
59
- REVIEWER_MODEL="${WOLFPACK_REVIEWER_MODEL:-openai-codex/gpt-5.6-sol}"
60
+ ```json
61
+ {
62
+ "to": {
63
+ "relay": "wolfpack-pi-tasks-v2",
64
+ "id": "target-opaque-endpoint-id"
65
+ },
66
+ "task": "implement the narrow change and run focused tests",
67
+ "timeoutMs": 3600000
68
+ }
60
69
  ```
61
70
 
62
- 1. Use one assignment mode: full-startup instructions, or an endpoint assignment—not both. For a task worker: `wolfpack agent spawn --project-dir /absolute/worktree --name <task-role> --model "$IMPLEMENTER_MODEL" --task-worker --readiness-timeout-ms 30000 --json` (or `"$REVIEWER_MODEL"`). This mode rejects prompts/plans and `--notify-parent`; put all instructions in `agent_task_send.task`.
63
- 2. Verify exact session ID/root/Pi harness and registered `taskEndpoint`. `TASK_WORKER_PREFLIGHT_FAILED` precedes creation. `TASK_WORKER_NOT_READY` retains `createdSession`/`cleanup`; inspect `unconfirmed` cleanup by exact ID before retry. Registration is not model execution.
64
- 3. Submit the assignment and continue useful work. When none remains, yield to structured follow-up. Do not poll `agent_task_status` or `agent_task_inbox` merely to wait; use `agent_task_wait` only when explicitly asked to block.
65
- 4. Use `agent_task_message` for questions, answers and information. The receiver's last action is `agent_task_done`, not completion prose.
66
- 5. Independently verify, then acknowledge the terminal task and deliberately retain or clean up the parent-owned role.
71
+ replace `to` with the actual returned `{ relay, id }`. put the complete assignment in `task`; do not also give the worker a startup prompt. the timeout is a failure deadline, not a polling interval. allow at least an hour for coding work.
67
72
 
68
- Endpoint assignments require terminal completion and one `agent_task_ack`; full-startup children have no task ID, so use explicit completion/block notification and parent verification instead. Close only sessions the parent spawned using `wolfpack kill <stable-session-id> --json`, then verify that ID absent from `wolfpack list --json`. Never use `wolfpack session send`, `/exit`, or `/quit` for cleanup.
73
+ ### 4. receive and verify the result
69
74
 
70
- ## worker-only execution gate
75
+ continue useful work after sending. when none remains, yield the turn: structured follow-ups bring questions and results back to the parent. do not repeatedly poll just to wait.
71
76
 
72
- The exact value `PI_TASK_WORKER=1` enables a fail-closed model tool gate. Before assignment only inbox/status/wait are allowed; other tools receive `PI_TASK_WORKER_ASSIGNMENT_REQUIRED`. Ordinary interactive Pi and explicit user shell commands are outside this gate.
77
+ | tool | purpose |
78
+ | --- | --- |
79
+ | `agent_task_send` | assign work to an endpoint |
80
+ | `agent_task_message` | send a question, answer, or progress update |
81
+ | `agent_task_done` | worker submits its final status and result |
82
+ | `agent_task_ack` | parent acknowledges a terminal task after verification |
83
+ | `agent_task_status` / `agent_task_inbox` | inspect locally known task state |
84
+ | `agent_task_cancel` | parent cancels its task |
85
+ | `agent_task_wait` | explicitly block for a terminal result when requested |
73
86
 
74
- `PI_TASK_WORKER=1` sessions are leaf roles. Generic role-orchestration guidance applies only to non-worker coordinators. The spawning coordinator owns each child's complete lifecycle.
87
+ workers report changed source paths in `result.changedFiles`. artifact declarations point to files for the parent to inspect; they do **not** transfer file contents.
75
88
 
76
- Workers cannot call `agent_task_send`, `agent_task_cancel`, or `agent_task_ack` (`PI_TASK_WORKER_COORDINATION_FORBIDDEN`). `agent_task_message` must name their eligible incorporated assignment. A structured session event alone is insufficient: its task must also be active in this endpoint's current RAM state. Old session history never authorizes work after restart.
89
+ when finished with a session you created, run `wolfpack kill <sessionId> --json`, then verify that exact ID is absent from `wolfpack list --json`.
77
90
 
78
- 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.
91
+ ### across machines
79
92
 
80
- ## terminal delivery and acknowledgment
93
+ select a configured tailnet peer with `wolfpack --machine <peer>`. remote child spawning requires a parent on that machine; for a worker coordinated from here, use remote top-level creation:
81
94
 
82
- During the active lifetime `terminalDelivery` reports `not_submitted`, `pending`, `accepted`, or `delivery_blocked`. The blocked variant includes stable intent/envelope identities, origin endpoint, timestamp, and structured non-retryable relay error. Canonical task status is never changed to `delivery_blocked`. Retryable failures reuse the original intent; permanent failures are not rearmed. This evidence is lost on endpoint restart.
83
-
84
- `agent_task_ack` is terminal-only and origin-owned. Sequential/concurrent retries in one lifetime reuse the logical event and envelope IDs. There is no restart recovery. Report changed source paths in `result.changedFiles`; artifacts are receiver-project-relative regular files, not the changed-file list.
95
+ ```bash
96
+ wolfpack --machine <peer> session create --project-dir /absolute/remote/worktree \
97
+ --harness pi --task-worker --readiness-timeout-ms 30000 --json
98
+ ```
85
99
 
86
- ```json
87
- { "result": { "changedFiles": ["src/extension.ts"] }, "artifacts": [{ "path": "verification/task-2.md" }] }
100
+ paths are on the remote host. the CLI qualifies the remote endpoint through your local coordinator and returns a locally routable `taskEndpoint`. use that returned address in the same send flow. if it reports `taskEndpointError`, inspect the retained `sessionId` rather than blindly creating another session. use the same `--machine` selector for cleanup.
101
+
102
+ see the [delegation workflow](skills/wolfpack-pi-task-delegation/SKILL.md) for role reuse, worker restrictions, readiness errors, and complete lifecycle rules.
103
+
104
+ ## use without wolfpack
105
+
106
+ **the task core is transport-independent; the default pi extension is not.** you can import the core into your own application and supply a `TaskRelay`. there is no bundled alternative production network relay or configuration switch that makes the default extension wolfpack-free.
107
+
108
+ ### try the core in one process
109
+
110
+ with the package available to your bun project, save this as `task-demo.ts` and run `bun task-demo.ts`. it uses the exported in-memory relay fixture, so it needs neither wolfpack nor a model:
111
+
112
+ ```ts
113
+ import {
114
+ createInMemoryTaskRelay,
115
+ createTaskCore,
116
+ createTaskStore,
117
+ } from "@sgtbeatdown/pi-tasks";
118
+
119
+ const relay = createInMemoryTaskRelay("demo");
120
+ const parentStore = createTaskStore();
121
+ const workerStore = createTaskStore();
122
+ const parent = createTaskCore({
123
+ endpoint: { relay: relay.id, id: "parent" },
124
+ relay,
125
+ store: parentStore,
126
+ });
127
+ const worker = createTaskCore({
128
+ endpoint: { relay: relay.id, id: "worker" },
129
+ relay,
130
+ store: workerStore,
131
+ });
132
+
133
+ try {
134
+ await parent.connect();
135
+ await worker.connect();
136
+ const { taskId } = await parent.createTask({
137
+ target: worker.endpoint,
138
+ task: "summarize the change",
139
+ timeoutMs: 60_000,
140
+ });
141
+
142
+ // a real application would consume the assignment and do the work here.
143
+ for (const delivery of await worker.receive()) {
144
+ await worker.acknowledgeRelayDelivery(delivery.cursor);
145
+ }
146
+ await worker.submitIntent({
147
+ taskId,
148
+ type: "task.completed",
149
+ payload: { summary: "the change adds task coordination" },
150
+ });
151
+ for (const delivery of await parent.receive()) {
152
+ await parent.acknowledgeRelayDelivery(delivery.cursor);
153
+ }
154
+
155
+ console.log(parent.getTask(taskId)?.status); // completed
156
+ await parent.acknowledgeParent(taskId);
157
+ } finally {
158
+ parentStore.close();
159
+ workerStore.close();
160
+ }
88
161
  ```
89
162
 
90
- ## development and verification
163
+ this demonstrates task coordination, not agent execution. both endpoints share one in-process fixture; it does not connect separate processes or machines.
164
+
165
+ ### integrate your own transport
166
+
167
+ implement the exported [`TaskRelay` interface](src/task-protocol.ts) to connect endpoints, resolve targets, send envelopes, receive mail, and acknowledge individual deliveries. provide a separate `createTaskStore()` and `createTaskCore()` for each endpoint. `runTaskRelayConformance` is available to check a relay implementation.
168
+
169
+ your application owns polling, retries, timeout evaluation, delivery acknowledgments, and shutdown. it also owns how assignments reach an agent and how that agent runs them. the core alone does not install pi tools or wake a model.
170
+
171
+ ## important limits
172
+
173
+ - **active state lives in ram.** a pi restart loses that endpoint's tasks; a relay restart can lose even accepted mail. pi session history remains readable, but does not restore tasks or authorize workers after restart.
174
+ - **accepted is not executed.** relay acceptance, receiver receipt, pi follow-up insertion, model execution, and task completion are separate stages.
175
+ - **the parent owns task status.** a worker submits a result; the origin processes it before its task becomes terminal. status reads are local snapshots.
176
+ - **use a trusted network.** the wolfpack integration assumes trusted tailnet machines. do not expose owner APIs to the public internet or an untrusted proxy.
177
+
178
+ ## reference and development
179
+
180
+ - [protocol and operations reference](docs/protocol-reference.md): memory lifecycle, restart loss, authentication, worker gate, delivery evidence, and acknowledgment details.
181
+ - [wolfpack relay control API](https://github.com/almogdepaz/wolfpack/blob/main/docs/control-api-schema.md#pi-tasks-relay-v2-boundary): transport contract.
91
182
 
92
183
  ```bash
93
184
  bun install
94
185
  bun test
95
186
  bun run typecheck
96
- PI_TASKS_WOLFPACK_SOURCE=/absolute/clean/wolfpack-checkout \
97
- PI_TASKS_WOLFPACK_REVISION=<full-commit-id> \
98
- bun test tests/volatile-real-worker.test.ts tests/volatile-extension-worker.test.ts
99
187
  ```
100
188
 
101
- Wolfpack worker tests require its supported Bun runtime (currently stable>=1.4.2). Optional source-selected checks use private HTTP/workers, not installed services. They test same-lifetime retry/ACK, automatic default-extension reset recovery, explicit lower-level session replacement, fresh endpoint state and history-only evidence. `createInMemoryTaskRelay` is a deterministic conformance fixture, never the default runtime transport. Physical two-machine qualification, final-path performance, release packaging and live activation remain separate gates.
189
+ optional real-worker checks and their prerequisites are documented in the [reference](docs/protocol-reference.md#development-and-verification).
@@ -0,0 +1,107 @@
1
+ # protocol and operations reference
2
+
3
+ start with the [readme](../README.md) for an overview, quickstart, and the transport-independent core example. this reference covers the default wolfpack integration.
4
+
5
+ Endpoint-owned task coordination for Pi over Wolfpack's memory-only relay. **Both relay mail and endpoint task bookkeeping live in RAM.** Pi's existing session history is the historical record; there is no SQLite database, task-history directory, migration, disk outbox or startup replay.
6
+
7
+ The [relay control API](https://github.com/almogdepaz/wolfpack/blob/main/docs/control-api-schema.md#pi-tasks-relay-v2-boundary) defines the transport boundary.
8
+
9
+ ## endpoint-owned relay
10
+
11
+ The default extension requires a compatible Wolfpack memory-owned server. It negotiates `volatile-v1` at `POST /api/task-relay/volatile-v1`. No transport opt-in flag is needed. The old durable relay adapter is removed, not retained as a compatibility mode.
12
+
13
+ Set `WOLFPACK_SESSION_NAME` for each Pi process. `WOLFPACK_PORT` defaults to `18790`; `WOLFPACK_SESSION_NAME` resolves the active Pi process to its relay endpoint. Read `taskEndpoint` from structured session creation/status and pass its opaque `{ relay, id }` unchanged. Never derive it from a name, terminal output or broker ID.
14
+
15
+ ### minimal valid v2 send envelope
16
+
17
+ ```json
18
+ {
19
+ "to": {
20
+ "relay": "wolfpack-pi-tasks-v2",
21
+ "id": "target-opaque-endpoint-id"
22
+ },
23
+ "task": "implement the narrow change and run focused tests"
24
+ }
25
+ ```
26
+
27
+ The `agent_task_send` schema is exactly `to`, `task`, and optional `timeoutMs`. Unsupported fields are rejected before admission: a pre-admission validation rejection creates no task. If an outcome is uncertain, idempotency remains necessary within the current lifetime; inspect the returned task ID rather than blindly creating another assignment.
28
+
29
+ ## history versus live state
30
+
31
+ - Pi requests task-processing turns through its safe `deliverAs: "followUp"` queue. Pi session messages and tool calls/results preserve historical context. Non-waking receipts, parent ACKs and late-terminal facts are archived as `pi-tasks-event-record` custom entries before transport ACK, without waking the model. Received model-visible task events include the complete structured event in `pi-tasks-event` details, including results—not just a rendered summary.
32
+ - Active tasks, outbox intentions, individual ACK checkpoints, deduplication and delivery-blocked evidence are RAM-owned. Each endpoint has lower-only limits of 16,384 records/32MiB encoded state. Admission fails before exceeding those bounds; they are not a process-RSS guarantee.
33
+ - `agent_task_send` confirms relay acceptance, **not execution**. `receiver_recorded` confirms receiver RAM receipt, **not durable persistence**. Pi insertion and wake acceptance are separate evidence, never proof of model execution.
34
+ - Session history is not an operational journal. A new process/lifecycle starts empty with a fresh endpoint/generation; it does not reconstruct tasks, replay envelopes, reuse old ACKs or reopen a worker gate from old history.
35
+ - A relay restart can lose even accepted mail. A Pi restart loses that endpoint's active task state. Relay-loss evidence marks affected work failed only in the discarded local lifetime; its remote outcome remains explicitly unknown and is never presented as canonical delivery or completion evidence.
36
+
37
+ ## memory-owned lifecycle
38
+
39
+ `createConfiguredTaskCore()` owns its transport and `createTaskStore()` RAM state. Startup registers; polling runs every five seconds and at agent settlement. Shutdown fences new work, aborts requests, drains active calls and discards state. Late callbacks cannot mutate a successor lifetime.
40
+
41
+ Background refresh records protocol facts in RAM even while Pi is busy or has queued messages. A valid completion within the fetched receive page can become canonical before its notification is presented. Model-visible insertion and the separate follow-up wake still wait for idle Pi with no queued messages; visible relay mail remains replayable until the required structured insertion/wake evidence is present. Intake alone does not incorporate an assignment or authorize worker tools. Parent acknowledgment before terminal presentation retains the event but suppresses its redundant wake.
42
+
43
+ This separation does not change polling or the 100-envelope receive-page bound: a full prefix of held visible mail can still prevent intake of a newer intent beyond that page. General backlog scanning is not provided.
44
+
45
+ The default extension automatically replaces a lifetime only after a structured `RELAY_RESET` or `RELAY_REBIND_REQUIRED`. It fences and discards the unusable RAM state, records each active task in session history as locally failed with `RELAY_STATE_LOST` and an explicitly unknown remote outcome, then binds a fresh empty endpoint. The loss record does not wake the model by itself. Transient availability, authentication, capacity, abort, profile and malformed-response failures never trigger replacement. Historical tasks are not adopted into the successor lifetime.
46
+
47
+ `createVolatileTaskSession({ url, callerSession, store })` is the lower-level API for a caller-owned RAM store. Transport reconnect within that same lifetime retains pending immutable retries; `rebind()` clears the store, while `close()` stops transport and the caller then closes its state. Persistence/path options are rejected. Old database files are neither read nor deleted.
48
+
49
+ Full immutable content, including the original creation timestamp, is reused for retries. Destination-confirmed acceptance is required. Conflicting content, exhausted delivery and reset cannot become successful acceptance. Individual ACKs preserve sparse bigint cursors and cannot skip earlier pending mail. Bounds/deadlines apply through complete response bodies, including noncooperative injected transports.
50
+
51
+ ## trusted Tailnet and optional authentication
52
+
53
+ The intended deployment trusts its Tailnet machines; Tailscale access control is the network boundary. Do not expose owner APIs through the public internet/Funnel or an untrusted proxy. No additional JWT or peer-signature scheme is required by the task protocol.
54
+
55
+ Existing global Wolfpack JWT enforcement remains **optional**. If the owner configures `WOLFPACK_JWT_SECRET`, the configured Pi factory supports it with short-lived tokens only for HTTP loopback. It never discovers/distributes secrets or automatically forwards them to arbitrary HTTPS URLs. HTTP401 reports `RELAY_AUTH_REQUIRED`, not an epoch reset.
56
+
57
+ Remote task endpoints must be locally resolved aliases. The paired CLI qualifies a selected remote endpoint through the local coordinator; remote lists expose explicit remote metadata. Failed qualification preserves any created session/ID but removes unsafe `taskEndpoint` output and reports `taskEndpointError`.
58
+
59
+ ## delegation workflow
60
+
61
+ Configure `WOLFPACK_IMPLEMENTER_MODEL` and `WOLFPACK_REVIEWER_MODEL`; defaults are `openai-codex/gpt-5.6-terra` and `openai-codex/gpt-5.6-sol`. Explicit user or project choices override those defaults.
62
+
63
+ ```bash
64
+ IMPLEMENTER_MODEL="${WOLFPACK_IMPLEMENTER_MODEL:-openai-codex/gpt-5.6-terra}"
65
+ REVIEWER_MODEL="${WOLFPACK_REVIEWER_MODEL:-openai-codex/gpt-5.6-sol}"
66
+ ```
67
+
68
+ 1. Use one assignment mode: full-startup instructions, or an endpoint assignment—not both. For a task worker: `wolfpack agent spawn --project-dir /absolute/worktree --name <task-role> --model "$IMPLEMENTER_MODEL" --task-worker --readiness-timeout-ms 30000 --json` (or `"$REVIEWER_MODEL"`). This mode rejects prompts/plans and `--notify-parent`; put all instructions in `agent_task_send.task`.
69
+ 2. Verify exact session ID/root/Pi harness and registered `taskEndpoint`. `TASK_WORKER_PREFLIGHT_FAILED` precedes creation. `TASK_WORKER_NOT_READY` retains `createdSession`/`cleanup`; inspect `unconfirmed` cleanup by exact ID before retry. Registration is not model execution.
70
+ 3. Submit the assignment and continue useful work. When none remains, yield to structured follow-up. Do not poll `agent_task_status` or `agent_task_inbox` merely to wait; use `agent_task_wait` only when explicitly asked to block.
71
+ 4. Use `agent_task_message` for questions, answers and information. The receiver's last action is `agent_task_done`, not completion prose.
72
+ 5. Independently verify, then acknowledge the terminal task and deliberately retain or clean up the parent-owned role.
73
+
74
+ Endpoint assignments require terminal completion and one `agent_task_ack`; full-startup children have no task ID, so use explicit completion/block notification and parent verification instead. Close only sessions the parent spawned using `wolfpack kill <stable-session-id> --json`, then verify that ID absent from `wolfpack list --json`. Never use `wolfpack session send`, `/exit`, or `/quit` for cleanup.
75
+
76
+ ## worker-only execution gate
77
+
78
+ The exact value `PI_TASK_WORKER=1` enables a fail-closed model tool gate. Before assignment only inbox/status/wait are allowed; other tools receive `PI_TASK_WORKER_ASSIGNMENT_REQUIRED`. Ordinary interactive Pi and explicit user shell commands are outside this gate.
79
+
80
+ `PI_TASK_WORKER=1` sessions are leaf roles. Generic role-orchestration guidance applies only to non-worker coordinators. The spawning coordinator owns each child's complete lifecycle.
81
+
82
+ Workers cannot call `agent_task_send`, `agent_task_cancel`, or `agent_task_ack` (`PI_TASK_WORKER_COORDINATION_FORBIDDEN`). `agent_task_message` must name their eligible incorporated assignment. A structured session event alone is insufficient: its task must also be active in this endpoint's current RAM state. Old session history never authorizes work after restart.
83
+
84
+ 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.
85
+
86
+ ## terminal delivery and acknowledgment
87
+
88
+ During the active lifetime `terminalDelivery` reports `not_submitted`, `pending`, `accepted`, or `delivery_blocked`. The blocked variant includes stable intent/envelope identities, origin endpoint, timestamp, and structured non-retryable relay error. Canonical task status is never changed to `delivery_blocked`. Retryable failures reuse the original intent; permanent failures are not rearmed. This evidence is lost on endpoint restart.
89
+
90
+ `agent_task_ack` is terminal-only and origin-owned. Sequential/concurrent retries in one lifetime reuse the logical event and envelope IDs. There is no restart recovery. Report changed source paths in `result.changedFiles`; artifacts are receiver-project-relative regular files, not the changed-file list.
91
+
92
+ ```json
93
+ { "result": { "changedFiles": ["src/extension.ts"] }, "artifacts": [{ "path": "verification/task-2.md" }] }
94
+ ```
95
+
96
+ ## development and verification
97
+
98
+ ```bash
99
+ bun install
100
+ bun test
101
+ bun run typecheck
102
+ PI_TASKS_WOLFPACK_SOURCE=/absolute/clean/wolfpack-checkout \
103
+ PI_TASKS_WOLFPACK_REVISION=<full-commit-id> \
104
+ bun test tests/volatile-real-worker.test.ts tests/volatile-extension-worker.test.ts
105
+ ```
106
+
107
+ Wolfpack worker tests require its supported Bun runtime (currently stable>=1.4.2). Optional source-selected checks use private HTTP/workers, not installed services. They test same-lifetime retry/ACK, automatic default-extension reset recovery, explicit lower-level session replacement, fresh endpoint state and history-only evidence. `createInMemoryTaskRelay` is a deterministic conformance fixture, never the default runtime transport. Physical two-machine qualification, final-path performance, release packaging and live activation remain separate gates.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sgtbeatdown/pi-tasks",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Memory-only endpoint-owned Pi agent delegation through Wolfpack relay v2.",
5
5
  "module": "src/index.ts",
6
6
  "exports": {
@@ -18,6 +18,7 @@
18
18
  ],
19
19
  "files": [
20
20
  "README.md",
21
+ "docs",
21
22
  "src",
22
23
  "skills",
23
24
  "tsconfig.json"
@@ -74,8 +74,8 @@ Assignment completion ends the assigned task, not the reusable role session. Whe
74
74
  1. create a role session through the readiness path above, or select an existing role by stable ID and verify its structured liveness, canonical project, Pi harness, and registered `taskEndpoint`. Never use terminal output as readiness evidence.
75
75
  2. call `agent_task_send` with the opaque endpoint and concise instructions in `task`. The tool returns after relay acceptance only, not Pi insertion or model execution. If submission reports a retryable delivery error after local persistence, use the structured task ID from the error and inspect it rather than creating an unrelated replacement.
76
76
  3. after `agent_task_send`, continue useful independent coordinator work. When none remains, yield the current turn and let structured task follow-up wake the parent; do not poll `agent_task_status` or `agent_task_inbox` merely to wait. Reserve those tools for concrete progress evidence or recovery, and call `agent_task_wait` only when the user explicitly asks to block.
77
- 4. use `agent_task_message` for `question`, `answer`, or `information` flow within the active endpoint lifetime. Do not use rendered text, terminal output, or logs as lifecycle evidence.
78
- 5. assignees call `agent_task_done` as their final action with the assigned task ID, terminal status, concise summary, and optional structured result, error, and artifact declarations. Report source modifications in `result.changedFiles`; artifacts are receiver-project-relative regular files for a parent to inspect, not changed-file lists: `{ "result": { "changedFiles": ["src/extension.ts"] }, "artifacts": [{ "path": "verification/task-2.md" }] }`. No prose completion afterward.
77
+ 4. while continuing work or asking for input, use `agent_task_message` for intermediate `question`, `answer`, or `information` flow within the active endpoint lifetime. Do not use rendered text, terminal output, or logs as lifecycle evidence.
78
+ 5. assignees call exactly one final `agent_task_done` with the assigned task ID, terminal status, and concise completion or blocker summary, plus optional structured result, error, and artifact declarations. Intermediate messages remain allowed; do not send a separate terminal information message before `agent_task_done`. Report source modifications in `result.changedFiles`; artifacts are receiver-project-relative regular files for a parent to inspect, not changed-file lists: `{ "result": { "changedFiles": ["src/extension.ts"] }, "artifacts": [{ "path": "verification/task-2.md" }] }`. No prose completion afterward.
79
79
  6. the parent independently verifies files, diff, tests, and artifacts. Then call `agent_task_ack({ taskId })` for that one terminal task and explicitly retain or close the spawned role session.
80
80
 
81
81
  ## v2 delivery and recovery
@@ -1,4 +1,4 @@
1
- import type { TaskCore } from "./task-core";
1
+ import type { TaskCore, WakeRequest } from "./task-core";
2
2
  import { TaskProtocolError } from "./task-protocol";
3
3
  import { createTaskStore } from "./task-store";
4
4
  import { createVolatileTaskSession } from "./volatile-task-session";
@@ -36,6 +36,11 @@ export async function createConfiguredTaskCore(options: ConfiguredTaskCoreOption
36
36
  const active = new Set<Promise<unknown>>();
37
37
  let closed = false, closing: Promise<void> | undefined;
38
38
  const owned = { ...core } as OwnedTaskCore;
39
+ const track = <T>(result: Promise<T>): Promise<T> => {
40
+ active.add(result);
41
+ void result.then(() => active.delete(result), () => active.delete(result));
42
+ return result;
43
+ };
39
44
  for (const [key, value] of Object.entries(core)) {
40
45
  if (typeof value !== "function") continue;
41
46
  Object.defineProperty(owned, key, { enumerable: true, value: (...args: unknown[]) => {
@@ -43,10 +48,14 @@ export async function createConfiguredTaskCore(options: ConfiguredTaskCoreOption
43
48
  // Core methods may call sibling methods through `this` (notably intent ACK).
44
49
  // Keep their original receiver while the outer promise owns the full operation.
45
50
  const result: unknown = value.apply(core, args);
46
- if (!(result instanceof Promise)) return result;
47
- active.add(result);
48
- void result.then(() => active.delete(result), () => active.delete(result));
49
- return result;
51
+ if (key === "recordWakeRequest") {
52
+ const request = result as WakeRequest;
53
+ return { flush: (signal?: AbortSignal): Promise<void> => {
54
+ if (closed) throw new TaskProtocolError("RELAY_CLOSED", "task session is closed", { retryable: false });
55
+ return track(request.flush(signal));
56
+ } };
57
+ }
58
+ return result instanceof Promise ? track(result) : result;
50
59
  } });
51
60
  }
52
61
  owned.close = () => {
package/src/extension.ts CHANGED
@@ -133,6 +133,7 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
133
133
  const guardedCore: TaskCore = {
134
134
  ...activeCore,
135
135
  async receive(receiveSignal) {
136
+ if (!isCurrent()) return [];
136
137
  const deliveries = await activeCore.receive(receiveSignal);
137
138
  return isCurrent() ? deliveries : [];
138
139
  },
@@ -142,6 +143,14 @@ export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefi
142
143
  async recordInsertion(input, insertionSignal) {
143
144
  if (isCurrent()) await activeCore.recordInsertion(input, insertionSignal);
144
145
  },
146
+ recordWakeRequest(input) {
147
+ if (!isCurrent()) throw new TaskProtocolError("RELAY_CLOSED", "task lifecycle was replaced", { retryable: false });
148
+ const request = activeCore.recordWakeRequest(input);
149
+ return { flush: async (flushSignal?: AbortSignal): Promise<void> => {
150
+ if (!isCurrent()) throw new TaskProtocolError("RELAY_CLOSED", "task lifecycle was replaced", { retryable: false });
151
+ await request.flush(flushSignal);
152
+ } };
153
+ },
145
154
  };
146
155
  const inboxOutboxError = await deliverTaskInbox({
147
156
  sendMessage(message, options) { if (isCurrent()) pi.sendMessage(message, options); },
package/src/task-core.ts CHANGED
@@ -27,6 +27,7 @@ const RECEIVE_PAGE_SIZE = 100;
27
27
  const DELIVERY_EVIDENCE_OPERATION = "delivery_evidence";
28
28
  const TERMINAL_STATUSES = new Set<TaskRecord["status"]>(["completed", "failed", "cancelled", "timed_out"]);
29
29
  const TERMINAL_EVENTS = new Set(["task.completed", "task.failed", "task.cancelled", "task.timed_out"]);
30
+ const CONVERSATIONAL_EVENTS = new Set(["task.information", "task.question", "task.answer"]);
30
31
  const CANONICAL_EVENTS = new Set([
31
32
  "task.created", "task.completed", "task.failed", "task.cancelled", "task.timed_out",
32
33
  "task.information", "task.question", "task.answer", "task.delivery_receipt", "task.parent_acknowledged", "task.late_terminal",
@@ -60,6 +61,11 @@ export type SubmitIntentOutcome =
60
61
  | { readonly authority: "origin"; readonly canonicalEvent: { readonly type: string; readonly reused: boolean } }
61
62
  | { readonly authority: "receiver" };
62
63
 
64
+ /** Local wake-request evidence is persisted before Pi wake emission; relay delivery is flushed afterward. */
65
+ export interface WakeRequest {
66
+ flush(signal?: AbortSignal): Promise<void>;
67
+ }
68
+
63
69
  export interface TaskCore {
64
70
  readonly endpoint: TaskEndpoint;
65
71
  connect(signal?: AbortSignal): Promise<void>;
@@ -72,6 +78,7 @@ export interface TaskCore {
72
78
  submitIntent(input: SubmitIntentInput, signal?: AbortSignal): Promise<void>;
73
79
  submitIntentWithOutcome?(input: SubmitIntentInput, signal?: AbortSignal): Promise<SubmitIntentOutcome>;
74
80
  recordInsertion(input: { readonly taskId: string; readonly eventId: string }, signal?: AbortSignal): Promise<void>;
81
+ recordWakeRequest(input: { readonly taskId: string; readonly eventId: string }): WakeRequest;
75
82
  evaluateTimeouts(signal?: AbortSignal): Promise<void>;
76
83
  acknowledgeParent(taskId: string, signal?: AbortSignal): Promise<void>;
77
84
  }
@@ -213,6 +220,21 @@ export function createTaskCore(options: TaskCoreOptions): TaskCore {
213
220
  });
214
221
  await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence);
215
222
  },
223
+ recordWakeRequest(input) {
224
+ const task = requiredTask(options.store, input.taskId);
225
+ const evidence: DeliveryEvidence = { eventId: input.eventId, stage: TaskDeliveryStage.wakeRequested, state: TaskDeliveryEvidenceState.confirmed };
226
+ let persistedEnvelopeIds: readonly string[] = [];
227
+ options.store.transaction(() => {
228
+ persistedEnvelopeIds = persistIntent(options, clock.now, ids, task, {
229
+ taskId: input.taskId,
230
+ type: "task.delivery_receipt",
231
+ payload: { eventId: input.eventId, stage: evidence.stage, state: evidence.state },
232
+ }, deliveryEvidenceOperation(evidence)).envelopeIds;
233
+ });
234
+ return { flush: async (signal?: AbortSignal): Promise<void> => {
235
+ await flushDeliveryEvidence(options, signal, input.taskId, persistedEnvelopeIds, evidence);
236
+ } };
237
+ },
216
238
  async evaluateTimeouts(signal) {
217
239
  const persistedEnvelopeIds: string[] = [];
218
240
  for (const candidate of options.store.listTasks()) {
@@ -303,7 +325,9 @@ function persistIntent(options: TaskCoreOptions, now: () => number, ids: () => s
303
325
  if (!sameEndpoint(options.endpoint, task.origin) && !sameEndpoint(options.endpoint, task.target)) throw new TaskProtocolError("NOT_PARTICIPANT", "historical task belongs to a different endpoint", { retryable: false });
304
326
  if (sameEndpoint(options.endpoint, task.origin)) {
305
327
  const operation = input.type === "task.cancelled" ? ORIGIN_CANCELLATION_OPERATION : reservedOperation;
306
- const canonical = canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type, operation);
328
+ // The origin already recorded its own conversational event; echo only peer-authored conversations back for model incorporation.
329
+ const echoCanonicalEventToOrigin = !CONVERSATIONAL_EVENTS.has(input.type);
330
+ const canonical = canonicalize(options, now, ids, task, { intentId: ids(), taskId: input.taskId, type: input.type, payload: input.payload }, input.type, operation, echoCanonicalEventToOrigin);
307
331
  return { envelopeIds: canonical.envelopeIds, outcome: { authority: "origin", canonicalEvent: { type: canonical.eventType, reused: canonical.reused } } };
308
332
  }
309
333
  const envelopeId = ids();
@@ -363,21 +387,22 @@ interface Canonicalization {
363
387
  readonly reused: boolean;
364
388
  }
365
389
 
366
- function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, intent: TaskIntent, requestedType: string, operation?: string): Canonicalization {
390
+ function canonicalize(options: TaskCoreOptions, now: () => number, ids: () => string, task: TaskRecord, intent: TaskIntent, requestedType: string, operation?: string, echoCanonicalEventToOrigin = true): Canonicalization {
367
391
  const terminal = TERMINAL_EVENTS.has(requestedType);
368
392
  const type = terminal && TERMINAL_STATUSES.has(task.status) ? "task.late_terminal" : requestedType;
369
393
  const sequence = String(task.events.length + 1);
370
394
  const canonical = event(task, ids(), type, sequence, options.endpoint, task.target, now(), { intentId: intent.intentId, ...intent.payload });
371
- const targetEnvelope = envelope(ids(), options.endpoint, task.target, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
372
- const originEnvelope = sameEndpoint(task.origin, task.target) ? undefined : envelope(ids(), options.endpoint, task.origin, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
373
- const persistedEnvelopeIds = originEnvelope === undefined ? [targetEnvelope.envelopeId] : [targetEnvelope.envelopeId, originEnvelope.envelopeId];
395
+ const suppressSelfTargetConversationEcho = !echoCanonicalEventToOrigin && sameEndpoint(task.origin, task.target);
396
+ const targetEnvelope = suppressSelfTargetConversationEcho ? undefined : envelope(ids(), options.endpoint, task.target, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
397
+ const originEnvelope = !echoCanonicalEventToOrigin || sameEndpoint(task.origin, task.target) ? undefined : envelope(ids(), options.endpoint, task.origin, task.taskId, TaskEnvelopeKind.canonicalEvent, canonical, canonical.occurredAt);
398
+ const persistedEnvelopeIds = [targetEnvelope, originEnvelope].flatMap((item) => item === undefined ? [] : [item.envelopeId]);
374
399
  if (operation !== undefined) {
375
400
  const reservation = options.store.reserveTaskOperation({ taskId: task.taskId, operation, logicalId: canonical.eventId, logicalType: canonical.type, envelopeIds: persistedEnvelopeIds });
376
401
  if (!reservation.created) return { envelopeIds: reservation.record.envelopeIds, eventType: reservation.record.logicalType, reused: true };
377
402
  }
378
403
  options.store.appendEvent(canonical);
379
404
  if (TERMINAL_EVENTS.has(type)) options.store.setStatus(task.taskId, statusFor(type));
380
- options.store.putOutbox(targetEnvelope);
405
+ if (targetEnvelope !== undefined) options.store.putOutbox(targetEnvelope);
381
406
  if (originEnvelope !== undefined) options.store.putOutbox(originEnvelope);
382
407
  return { envelopeIds: persistedEnvelopeIds, eventType: type, reused: false };
383
408
  }
package/src/task-inbox.ts CHANGED
@@ -6,6 +6,7 @@ const TASK_EVENT_CUSTOM_TYPE = "pi-tasks-event";
6
6
  const TASK_WAKE_CUSTOM_TYPE = "pi-tasks-wake";
7
7
  const TASK_CURSOR_CUSTOM_TYPE = "pi-tasks-relay-cursor";
8
8
  const TASK_RECORD_CUSTOM_TYPE = "pi-tasks-event-record";
9
+ const TERMINAL_EVENT_TYPES = new Set(["task.completed", "task.failed", "task.cancelled", "task.timed_out"]);
9
10
 
10
11
  interface InboxContext {
11
12
  readonly isIdle: () => boolean;
@@ -34,7 +35,6 @@ type DeliveryEvidencePayload =
34
35
 
35
36
  /** Persists model-visible Pi evidence before advancing the relay cursor, then starts one separate turn. */
36
37
  export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: InboxContext, signal?: AbortSignal): Promise<TaskOutboxDeliveryError | undefined> {
37
- if (context.hasPendingMessages()) return undefined;
38
38
  const deliveries = await core.receive(signal);
39
39
  let outboxError: TaskOutboxDeliveryError | undefined;
40
40
  for (const delivery of deliveries) {
@@ -77,10 +77,11 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
77
77
  const insertionError = await settleDeliveryEvidence(() => core.recordInsertion(eventDetails, signal));
78
78
  outboxError ??= insertionError;
79
79
  let wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
80
- if (!wakeAccepted) {
80
+ const wakeSuppressed = !wakeAccepted && hasAcknowledgedLocalOriginTerminal(core, event);
81
+ if (!wakeAccepted && !wakeSuppressed) {
81
82
  if (!context.isIdle()) return outboxError;
82
- const wakeRequestError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeRequested, state: TaskDeliveryEvidenceState.confirmed }, signal);
83
- outboxError ??= wakeRequestError;
83
+ // This synchronous local write closes the ACK/wake race; do not await relay I/O before Pi emits the wake.
84
+ const wakeRequest = core.recordWakeRequest(eventDetails);
84
85
  pi.sendMessage({
85
86
  customType: TASK_WAKE_CUSTOM_TYPE,
86
87
  content: "Process the pending Pi task event.",
@@ -89,9 +90,13 @@ export async function deliverTaskInbox(pi: InboxPi, core: TaskCore, context: Inb
89
90
  }, { triggerTurn: true, deliverAs: "followUp" });
90
91
  wakeAccepted = taskMessageKeys(context.sessionManager.getEntries(), TASK_WAKE_CUSTOM_TYPE).has(eventKey);
91
92
  if (!wakeAccepted) return outboxError;
93
+ const wakeRequestError = await settleDeliveryEvidence(() => wakeRequest.flush(signal));
94
+ outboxError ??= wakeRequestError;
95
+ }
96
+ if (wakeAccepted) {
97
+ const wakeAcceptanceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeAccepted, state: TaskDeliveryEvidenceState.confirmed }, signal);
98
+ outboxError ??= wakeAcceptanceError;
92
99
  }
93
- const wakeAcceptanceError = await recordDeliveryEvidence(core, eventDetails, { stage: TaskDeliveryStage.wakeAccepted, state: TaskDeliveryEvidenceState.confirmed }, signal);
94
- outboxError ??= wakeAcceptanceError;
95
100
  }
96
101
  await core.acknowledgeRelayDelivery(delivery.cursor, signal);
97
102
  pi.appendEntry(TASK_CURSOR_CUSTOM_TYPE, { cursor: delivery.cursor });
@@ -131,6 +136,14 @@ function isModelVisible(type: string): boolean {
131
136
  return !["task.delivery_receipt", "task.parent_acknowledged", "task.late_terminal"].includes(type);
132
137
  }
133
138
 
139
+ function hasAcknowledgedLocalOriginTerminal(core: TaskCore, event: TaskEvent): boolean {
140
+ if (!TERMINAL_EVENT_TYPES.has(event.type)) return false;
141
+ const task = core.getTask(event.taskId);
142
+ if (task === undefined || task.origin.relay !== core.endpoint.relay || task.origin.id !== core.endpoint.id) return false;
143
+ const terminalIndex = task.events.findIndex((candidate) => candidate.eventId === event.eventId && candidate.type === event.type);
144
+ return terminalIndex >= 0 && task.events.slice(terminalIndex + 1).some((candidate) => candidate.type === "task.parent_acknowledged");
145
+ }
146
+
134
147
  function renderTaskEvent(event: TaskEvent): string {
135
148
  if (event.type === "task.created") return `## task assignment\ntask: \`${event.taskId}\` · event: \`${event.eventId}\`\n\n${String(event.payload.task ?? "")}`;
136
149
  const body = typeof event.payload.message === "string" ? `\n\n${event.payload.message}` : "";