@sgtbeatdown/pi-tasks 0.1.1 → 0.1.2
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 +51 -9
- package/package.json +6 -2
- package/skills/wolfpack-pi-task-delegation/SKILL.md +19 -10
- package/src/extension.ts +181 -137
- package/src/gateway-client.ts +32 -8
- package/src/in-memory-task-relay.ts +114 -0
- package/src/index.ts +35 -0
- package/src/legacy-extension.ts +248 -0
- package/src/legacy-task-inbox.ts +148 -0
- package/src/sqlite-database.ts +68 -0
- package/src/task-core.ts +383 -0
- package/src/task-inbox.ts +55 -87
- package/src/task-protocol.ts +129 -0
- package/src/task-relay-conformance.ts +23 -0
- package/src/task-store.ts +238 -0
- package/src/v1-compat-extension.ts +1 -0
- package/src/wolfpack-task-relay.ts +319 -0
package/README.md
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
# pi-tasks
|
|
2
2
|
|
|
3
|
-
`pi-tasks` is a Pi extension client for
|
|
3
|
+
`pi-tasks` is a Pi extension client for Wolfpack task transports. The retained v1 adapter uses Wolfpack's machine-global `~/.wolfpack/tasks` store; v2 keeps endpoint-owned Pi task state locally and uses Wolfpack as a content-blind relay. Neither adapter writes assignments to terminals. Read Wolfpack's [harness-neutral task adapter contract](https://github.com/almogdepaz/wolfpack/blob/main/docs/task-adapter-contract.md) for canonical adapter behavior and its [task gateway guide](https://github.com/almogdepaz/wolfpack/blob/main/docs/task-gateway.md) for routes and operations.
|
|
4
|
+
|
|
5
|
+
## v2 endpoint-owned relay
|
|
6
|
+
|
|
7
|
+
The default extension uses the configured local Wolfpack relay v2 adapter, never the in-memory conformance relay. It registers an opaque endpoint with `POST /api/task-relay/v2/connect`, stores endpoint-owned task state at the deterministic per-session path `~/.pi/tasks/v2/sessions/<sha256(WOLFPACK_SESSION_NAME)>/tasks.sqlite`, and exchanges opaque relay envelopes through Wolfpack. The adapter needs a Wolfpack release exposing the stable [relay v2 control-api contract](https://github.com/almogdepaz/wolfpack/blob/main/docs/control-api-schema.md#pi-tasks-relay-v2-boundary); a v1-only gateway is incompatible and does not silently fall back.
|
|
8
|
+
|
|
9
|
+
Set `WOLFPACK_SESSION_NAME` for every Pi process. The adapter uses `WOLFPACK_PORT` when the local control port differs from `18790`; `WOLFPACK_SESSION_NAME` resolves the active Pi process to its relay endpoint. On startup, obtain a target's opaque `{ relay, id }` endpoint from its Wolfpack session-status projection after that target has registered, then send it through the endpoint-owned tool shape:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"to": { "relay": "wolfpack-pi-tasks-v2", "id": "target-opaque-endpoint-id" },
|
|
14
|
+
"task": "implement the narrow change and run focused tests"
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Wolfpack's relay owns durable mailbox delivery and peer forwarding; Pi owns task lifecycle, logical event order, receipts, and its local SQLite state. Insertion receipts are idempotent by `{ taskId, eventId }`, so replay after Pi has structurally recorded an event cannot create an additional logical receipt. `createInMemoryTaskRelay` remains exported solely as a deterministic conformance fixture.
|
|
19
|
+
|
|
20
|
+
## v1 gateway adapter (retained)
|
|
21
|
+
|
|
22
|
+
The v1 gateway client is available only through the published `@sgtbeatdown/pi-tasks/v1-compat-extension` entry. This is an explicit opt-in: after resolving the installed package root, add `src/v1-compat-extension.ts` as an extension path in Pi settings instead of loading the package default. The package manifest loads only `src/extension.ts` (v2); v2 does not fall back to v1 when its relay is unavailable or incompatible. Its v1 behavior and operational requirements are documented below.
|
|
4
23
|
|
|
5
24
|
## requirements and trust boundary
|
|
6
25
|
|
|
@@ -29,6 +48,20 @@ A target is always `{ machine, sessionId }`, where `sessionId` is the stable opa
|
|
|
29
48
|
}
|
|
30
49
|
```
|
|
31
50
|
|
|
51
|
+
### minimal valid send envelope
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"to": {
|
|
56
|
+
"machine": "local",
|
|
57
|
+
"sessionId": "receiver-broker-id"
|
|
58
|
+
},
|
|
59
|
+
"task": "implement the narrow change and run focused tests"
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`INVALID_REQUEST.error.path` is an optional RFC 6901 JSON Pointer to the rejected send field, for example `/preflight/requiredProject`; use it rather than parsing error prose. A pre-persistence validation rejection creates no task, so correct the field and retry. If creation status is uncertain, idempotency remains necessary.
|
|
64
|
+
|
|
32
65
|
Remote addressing changes only `to.machine`:
|
|
33
66
|
|
|
34
67
|
```json
|
|
@@ -39,7 +72,7 @@ Context is optional, curated Markdown plus deliberately selected ref metadata. T
|
|
|
39
72
|
|
|
40
73
|
Initial limits are 16 KiB UTF-8 each for task instructions and context summary, 48 KiB for the assignment envelope, and 64 KiB for an HTTP request body. The initial size limits require representative payload benchmarking before adjustment.
|
|
41
74
|
|
|
42
|
-
`agent_task_send` waits for durable gateway
|
|
75
|
+
`agent_task_send` waits for durable gateway acceptance, not adapter insertion or task execution; delivery remains pending until `task.delivered`. A remote initial send has one initial attempt: the receiver persists a provisional receipt, then receives sender confirmation before Pi can see the assignment. Later peer confirmation, messages, terminal updates, cancellation, delivery notices, and parent acknowledgment have four total attempts: the initial attempt plus retries around 1, 2, and 4 seconds with jitter. Exhaustion is a visible local delivery failure, not a background queue or offline dispatch promise.
|
|
43
76
|
|
|
44
77
|
Normal delegation is fire-and-forget. Use `agent_task_wait` only when the user explicitly asks to block.
|
|
45
78
|
|
|
@@ -50,30 +83,39 @@ Normal delegation is fire-and-forget. Use `agent_task_wait` only when the user e
|
|
|
50
83
|
| `agent_task_send` | create a durable gateway assignment |
|
|
51
84
|
| `agent_task_status` | read task state, history, result, and warnings |
|
|
52
85
|
| `agent_task_wait` | explicitly poll a task until terminal or timeout |
|
|
53
|
-
| `agent_task_inbox` | read task events
|
|
86
|
+
| `agent_task_inbox` | read task events without acknowledgment |
|
|
87
|
+
| `agent_task_ack` | acknowledge one independently verified terminal task |
|
|
54
88
|
| `agent_task_message` | send a durable `question`, `answer`, or `information` event |
|
|
55
89
|
| `agent_task_cancel` | request cancellation |
|
|
56
90
|
| `agent_task_done` | receiver-only terminal completion; terminates the tool turn |
|
|
57
91
|
|
|
58
92
|
Use `agent_task_message` for clarification instead of terminal prose. Only one question can be unresolved per task; answers link to that question. Information is durable and does not change task state. An accepted receiver question terminates that receiver turn; a parent question does not.
|
|
59
93
|
|
|
60
|
-
|
|
94
|
+
Report source modifications in `result.changedFiles`. Artifacts are receiver-project-relative regular files for a parent to inspect, not a changed-file list:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{ "result": { "changedFiles": ["src/extension.ts"] }, "artifacts": [{ "path": "verification/task-2.md" }] }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
See the canonical Wolfpack artifact contract for provenance, containment, and warning behavior.
|
|
101
|
+
|
|
102
|
+
The sender gateway owns canonical event order, timeout, and terminal state. The first accepted terminal event wins. Sender timeout triggers best-effort remote cancellation; late terminals remain diagnostics. After independently verifying one terminal result, call `agent_task_ack({ taskId })`. Remote acknowledgment is two-phase: the receiver durably confirms pending acknowledgment before the sender records final parent acknowledgment. If that delivery fails, the task remains visible and a later explicit acknowledgment repairs it with the same event ID.
|
|
61
103
|
|
|
62
104
|
## structured delivery and replay
|
|
63
105
|
|
|
64
|
-
The extension polls the local gateway every five seconds
|
|
106
|
+
The extension polls the local gateway every five seconds. With no pending message, it submits each visible event as a structured custom message using Pi's safe `deliverAs: "followUp"` mode: idle Pi starts immediately, while an active turn finishes before the queued event starts the next turn. It never steers or interrupts active work. Delivery remains pending until `task.delivered`. The `{ taskId, eventId }` details let task context participate in the session without parsing prose.
|
|
65
107
|
|
|
66
|
-
On start or resume it rebuilds incorporated IDs from
|
|
108
|
+
On start or resume it rebuilds incorporated IDs from the full durable session entry set, not only active prompt context. A session gets only missing events; a new Pi session in the same Wolfpack PTY gets complete active history. The receiver records gateway delivery only after the structured insertion exists. A restart after insertion and before acknowledgment therefore acknowledges the existing entry without duplicate injection. Unknown model-visible events fail closed without advancing the cursor. Gateway delivery is at-least-once; this does not claim exactly-once model execution.
|
|
67
109
|
|
|
68
110
|
## parent workflow
|
|
69
111
|
|
|
70
|
-
1. create or select a Wolfpack Pi session with the canonical session-control workflow and retain its stable broker ID.
|
|
71
|
-
2. check the target's local
|
|
112
|
+
1. create or select a Wolfpack Pi session with the canonical session-control workflow and retain its stable broker ID. Spawn a disposable worker without an initial model prompt so it reaches idle before assignment; put worker instructions in `agent_task_send.task`.
|
|
113
|
+
2. before remote dispatch, complete Wolfpack's [Live-peer readiness checklist](https://github.com/almogdepaz/wolfpack/blob/main/docs/task-gateway.md#live-peer-readiness-checklist) and retain operator-recorded package/reload evidence; only then check the target's local requirements and send compact instructions with curated context and selected refs when they save receiver investigation.
|
|
72
114
|
3. keep working; use `agent_task_status` or `agent_task_inbox` for structured follow-up, and `agent_task_message` for questions, answers, and decisions.
|
|
73
115
|
4. verify cited files, diffs, tests, and paths-only artifact metadata independently before reporting success.
|
|
74
116
|
5. acknowledge the accepted terminal result, then clean up only sessions this parent spawned. Keep a reusable implementer alive while review or correction remains possible; never make workers close their own sessions.
|
|
75
117
|
|
|
76
|
-
|
|
118
|
+
Isolated coverage is the deterministic acceptance gate, but a specific live peer still requires current readiness at the time of use. If the checklist cannot pass, stop before task creation and report fixture-only verification. Do not use direct peer URLs from Pi.
|
|
77
119
|
|
|
78
120
|
See `skills/wolfpack-pi-task-delegation/SKILL.md` for the operational workflow and `skills/task-context-summary/SKILL.md` for recovery/reuse summaries.
|
|
79
121
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sgtbeatdown/pi-tasks",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Durable Pi agent delegation through the Wolfpack task gateway.",
|
|
5
|
-
"module": "src/
|
|
5
|
+
"module": "src/index.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./v1-compat-extension": "./src/v1-compat-extension.ts"
|
|
9
|
+
},
|
|
6
10
|
"repository": {
|
|
7
11
|
"type": "git",
|
|
8
12
|
"url": "git+https://github.com/almogdepaz/pi-tasks.git"
|
|
@@ -5,7 +5,7 @@ description: Use when opening, selecting, delegating to, checking, or cleaning u
|
|
|
5
5
|
|
|
6
6
|
# wolfpack pi task delegation
|
|
7
7
|
|
|
8
|
-
use `wolfpack-tailnet-control` for session control. this skill covers gateway task lifecycle, curated context, and parent verification. Wolfpack's [task
|
|
8
|
+
use `wolfpack-tailnet-control` for session control. this skill covers gateway task lifecycle, curated context, and parent verification. Pi is one conforming adapter. Wolfpack's [harness-neutral task adapter contract](https://github.com/almogdepaz/wolfpack/blob/main/docs/task-adapter-contract.md) is the canonical adapter reference; its [task gateway guide](https://github.com/almogdepaz/wolfpack/blob/main/docs/task-gateway.md) covers routes, trust, retention, and federation.
|
|
9
9
|
|
|
10
10
|
## gateway requirements and addressing
|
|
11
11
|
|
|
@@ -15,30 +15,39 @@ use `wolfpack-tailnet-control` for session control. this skill covers gateway ta
|
|
|
15
15
|
- use `machine: "local"` for same-machine work. For remote work, use only the receiver's canonical HTTPS Tailnet origin, such as `https://worker.example.ts.net`; never a terminal name, arbitrary URL, path, query, fragment, credentials, or unexpected port.
|
|
16
16
|
- v1 trusts local processes and trusted Tailnet machines. Pi calls only its local gateway; gateways use direct fetch federation. JWT federation is unsupported, so do not promise remote delivery where Wolfpack global JWT requires credentials.
|
|
17
17
|
|
|
18
|
+
## phase roles and handoffs
|
|
19
|
+
|
|
20
|
+
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 is an assignment result, not a reason to replace its session. Do not rotate a healthy role session for routine corrections. Rotate only for explicit phase completion, material context saturation, harness failure, or a required independent specialist. Keep at most one active assignment per role unless the user explicitly asks for parallel work.
|
|
21
|
+
|
|
22
|
+
Persistence is a parent decision, not a default. After every verified and acknowledged terminal task, the parent must explicitly choose whether to retain or close a session it spawned. Retain it only when the parent expects concrete follow-up work; close it when the parent does not expect to use it again. Do not leave the cleanup decision implicit.
|
|
23
|
+
|
|
24
|
+
Use gateway task tools for assignments, questions, decisions, completion, and acknowledgment. terminal `send` is only for explicit human steering; it is not task state, a completion channel, or a substitute for `agent_task_message`.
|
|
25
|
+
|
|
18
26
|
## workflow
|
|
19
27
|
|
|
20
|
-
1. create or select an existing Pi session with canonical Wolfpack session control. Read
|
|
21
|
-
2. send concise opaque task instructions with `agent_task_send`.
|
|
28
|
+
1. create or select an existing Pi session with canonical Wolfpack session control. Reuse the phase role session when it is healthy. For a disposable worker, omit `--prompt`: `wolfpack agent spawn <project> --name <task-role> --json`. Put all worker instructions in `agent_task_send.task` so the fresh Pi process can reach idle before assignment. Do not start a disposable worker with a blocking “wait for assignments” model prompt. Read the structured spawn response and retain the stable broker `sessionId`. Before remote dispatch, complete Wolfpack's [Live-peer readiness checklist](https://github.com/almogdepaz/wolfpack/blob/main/docs/task-gateway.md#live-peer-readiness-checklist), including operator-recorded package/reload evidence; if it cannot pass, stop before task creation and report fixture-only verification.
|
|
29
|
+
2. send concise opaque task instructions with `agent_task_send`. Use `context.summary` only for constraints, decisions, and recovery state, and deliberately select `context.refs` with `path`, optional `selector`, and `purpose`; refs are metadata, not copied files or transcript. Use `task-context-summary` only for recovery/reuse.
|
|
22
30
|
3. include `role`, `metadata`, enforceable `preflight.requiredProject`, bounded timeout, idempotency key, and `onCompletePrompt` only when they apply. Timeout is 1000ms through 24h; default is 30 minutes. Task/context limits are 16 KiB each, assignment envelope 48 KiB, and request body 64 KiB.
|
|
23
31
|
4. after durable receipt, keep working. Do not call `agent_task_wait` unless the user explicitly asks to block. Use `agent_task_status` or `agent_task_inbox` for structured follow-up.
|
|
24
32
|
5. use `agent_task_message` for durable `question`, `answer`, or `information` flow. One question may be unresolved globally; answers link to it. A receiver question accepted by the sender ends the receiver turn; a parent question does not end the parent turn.
|
|
25
|
-
6. assignees call `agent_task_done` exactly once as their final action with `completed`, `failed`, or `cancelled`, concise summary, and optional structured result/error/
|
|
26
|
-
7. parent independently verifies files, diff, tests, and artifacts before reporting success. Then use `
|
|
33
|
+
6. assignees call `agent_task_done` exactly once as their final action with `completed`, `failed`, or `cancelled`, concise summary, and optional structured result/error/artifact metadata. 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" }] }`. Follow the canonical Wolfpack artifact contract for provenance, containment, and warning behavior. No prose completion afterward.
|
|
34
|
+
7. parent independently verifies files, diff, tests, and artifacts before reporting success. Then use `agent_task_ack({ taskId })` for that one verified terminal task; remote acknowledgment is two-phase and failed propagation leaves the task visible for explicit repair. After acknowledgment, explicitly decide whether the spawned session has a likely next assignment. Retain it while review, correction, or another concrete assignment is expected; otherwise close it through canonical Wolfpack session control. Cleanup applies only to sessions this parent spawned and only after result verification and acknowledgment.
|
|
27
35
|
|
|
28
36
|
## delivery and recovery
|
|
29
37
|
|
|
30
|
-
`agent_task_send` returns after durable receiver-gateway
|
|
38
|
+
`agent_task_send` returns after durable receiver-gateway acceptance, not adapter insertion or model execution; delivery remains pending until `task.delivered`. A remote initial send gets one initial attempt. The receiver stores a provisional receipt, and Pi sees the assignment only after sender receipt confirmation. Later peer events get four total attempts: initial plus retries around 1, 2, and 4 seconds with jitter. Retry exhaustion is a surfaced local delivery failure; v1 has no gateway queue, scheduler, or offline dispatch.
|
|
31
39
|
|
|
32
|
-
The sender gateway owns canonical order, expiry, and terminal choice; the first accepted terminal event wins. Sender timeout causes best-effort remote cancellation. The local extension polls every five seconds
|
|
40
|
+
The sender gateway owns canonical order, expiry, and terminal choice; the first accepted terminal event wins. Sender timeout causes best-effort remote cancellation. The local extension polls every five seconds. With no pending message, it submits visible events through Pi's safe `deliverAs: "followUp"` queue: idle sessions start immediately, active turns finish first, and task delivery never steers or interrupts them. It stores `{ taskId, eventId }` in structured custom messages, replays missing events after restart, and records delivery only after structural insertion. Do not call that exactly-once execution.
|
|
33
41
|
|
|
34
|
-
|
|
42
|
+
Isolated coverage is the deterministic acceptance gate, but a specific live peer still requires current readiness at the time of use. Do not claim that a particular remote deployment is reachable without the checklist evidence.
|
|
35
43
|
|
|
36
44
|
## do not
|
|
37
45
|
|
|
38
46
|
- do not use terminal output as task state or completion evidence.
|
|
39
47
|
- do not ask workers to complete in prose.
|
|
40
|
-
- do not
|
|
41
|
-
-
|
|
48
|
+
- do not steer or interrupt an active Pi turn with task context; use the adapter's follow-up queue.
|
|
49
|
+
- Do not copy plans, source contents, or transcripts into task context.
|
|
42
50
|
- do not use filesystem task storage or retain old `mustReturn`, `rejected`, or semantic completion contracts.
|
|
43
51
|
- do not promise JWT federation, artifact byte transfer, or background retry/offline dispatch.
|
|
44
52
|
- do not make workers close their own sessions.
|
|
53
|
+
- do not leave a parent-spawned session open after acknowledgment when the parent does not expect further work from it.
|
package/src/extension.ts
CHANGED
|
@@ -3,205 +3,253 @@ 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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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 } from "./task-inbox";
|
|
7
|
+
import { TaskOutboxDeliveryError, TaskProtocolError } from "./task-protocol";
|
|
8
|
+
import { createWolfpackTaskCore } from "./wolfpack-task-relay";
|
|
9
|
+
import type { TaskCore } from "./task-core";
|
|
17
10
|
|
|
11
|
+
const DEFAULT_TASK_TIMEOUT_MS = 30 * 60 * 1_000;
|
|
12
|
+
const MIN_TASK_TIMEOUT_MS = 1_000;
|
|
13
|
+
const MAX_TASK_TIMEOUT_MS = 86_400_000;
|
|
18
14
|
const BACKGROUND_POLL_MS = 5_000;
|
|
15
|
+
const TARGET_NOT_REGISTERED_CODE = "TARGET_NOT_REGISTERED";
|
|
19
16
|
const WAIT_POLL_MS = 250;
|
|
20
17
|
const SUMMARY_MAX_CHARS = 1_200;
|
|
21
18
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
const EndpointParams = Type.Object({
|
|
20
|
+
relay: Type.String({ minLength: 1, description: "relay identifier" }),
|
|
21
|
+
id: Type.String({ minLength: 1, description: "opaque endpoint identifier" }),
|
|
25
22
|
});
|
|
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
23
|
const SendParams = Type.Object({
|
|
34
|
-
to:
|
|
24
|
+
to: EndpointParams,
|
|
35
25
|
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
26
|
timeoutMs: Type.Optional(Type.Integer({ minimum: MIN_TASK_TIMEOUT_MS, maximum: MAX_TASK_TIMEOUT_MS })),
|
|
50
|
-
idempotencyKey: Type.Optional(Type.String({ minLength: 1 })),
|
|
51
27
|
});
|
|
52
|
-
|
|
53
28
|
const TaskIdParams = Type.Object({ taskId: Type.String({ minLength: 1 }) });
|
|
54
29
|
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
|
|
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
|
-
});
|
|
30
|
+
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
31
|
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 }),
|
|
32
|
+
taskId: Type.String({ minLength: 1 }), status: StringEnum(["completed", "failed", "cancelled"] as const), summary: Type.String({ minLength: 1, maxLength: SUMMARY_MAX_CHARS }),
|
|
66
33
|
result: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
67
34
|
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()) }))),
|
|
35
|
+
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
36
|
});
|
|
70
37
|
|
|
71
|
-
export
|
|
72
|
-
|
|
38
|
+
export type ConfiguredCoreFactory = (signal?: AbortSignal) => Promise<TaskCore>;
|
|
39
|
+
|
|
40
|
+
/** Reuses a successful configured core while allowing the next lifecycle operation to retry a failed setup. */
|
|
41
|
+
export function createConfiguredCoreLoader(factory: ConfiguredCoreFactory): ConfiguredCoreFactory {
|
|
42
|
+
let corePromise: Promise<TaskCore> | undefined;
|
|
43
|
+
return async (signal?: AbortSignal): Promise<TaskCore> => {
|
|
44
|
+
if (corePromise) return corePromise;
|
|
45
|
+
const candidate = Promise.resolve().then(() => factory(signal));
|
|
46
|
+
corePromise = candidate;
|
|
47
|
+
try {
|
|
48
|
+
return await candidate;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (corePromise === candidate) corePromise = undefined;
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
73
55
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
56
|
+
/** Registers v2 endpoint-owned tools using a configured durable Wolfpack relay by default. */
|
|
57
|
+
export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefined = undefined, createCore: ConfiguredCoreFactory = (signal) => createWolfpackTaskCore({}, signal)): void {
|
|
58
|
+
let inboxContext: ExtensionContext | undefined;
|
|
59
|
+
let backgroundTimer: ReturnType<typeof setInterval> | undefined;
|
|
60
|
+
let lifecycleEpoch = 0;
|
|
61
|
+
const configuredCore: ConfiguredCoreFactory = core === undefined ? createConfiguredCoreLoader(createCore) : async (): Promise<TaskCore> => core;
|
|
62
|
+
const refreshInbox = createSingleFlightInboxRefresh(async (signal) => {
|
|
63
|
+
const context = inboxContext;
|
|
64
|
+
if (!context) return undefined;
|
|
65
|
+
const activeCore = await configuredCore(signal);
|
|
66
|
+
if (inboxContext !== context) return undefined;
|
|
67
|
+
let outboxError: unknown;
|
|
68
|
+
try {
|
|
69
|
+
await activeCore.flushOutbox(signal);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
outboxError = error;
|
|
72
|
+
}
|
|
73
|
+
if (inboxContext !== context) return undefined;
|
|
74
|
+
try {
|
|
75
|
+
await activeCore.evaluateTimeouts(signal);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
outboxError ??= error;
|
|
78
|
+
}
|
|
79
|
+
if (inboxContext !== context) return undefined;
|
|
80
|
+
const isCurrent = (): boolean => inboxContext === context;
|
|
81
|
+
const guardedCore: TaskCore = {
|
|
82
|
+
...activeCore,
|
|
83
|
+
async receive(receiveSignal) {
|
|
84
|
+
const deliveries = await activeCore.receive(receiveSignal);
|
|
85
|
+
return isCurrent() ? deliveries : [];
|
|
86
|
+
},
|
|
87
|
+
async acknowledgeRelayDelivery(cursor, acknowledgementSignal) {
|
|
88
|
+
if (isCurrent()) await activeCore.acknowledgeRelayDelivery(cursor, acknowledgementSignal);
|
|
89
|
+
},
|
|
90
|
+
async recordInsertion(input, insertionSignal) {
|
|
91
|
+
if (isCurrent()) await activeCore.recordInsertion(input, insertionSignal);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
await deliverTaskInbox({
|
|
95
|
+
sendMessage(message, options) { if (isCurrent()) pi.sendMessage(message, options); },
|
|
96
|
+
appendEntry(customType, data) { if (isCurrent()) pi.appendEntry(customType, data); },
|
|
97
|
+
}, guardedCore, {
|
|
98
|
+
hasPendingMessages: (): boolean => !isCurrent() || context.hasPendingMessages(),
|
|
99
|
+
sessionManager: context.sessionManager,
|
|
100
|
+
}, signal);
|
|
101
|
+
return outboxError;
|
|
102
|
+
});
|
|
103
|
+
const refreshLifecycle = async (context: ExtensionContext, epoch: number): Promise<void> => {
|
|
104
|
+
const isCurrent = (): boolean => lifecycleEpoch === epoch && inboxContext === context;
|
|
105
|
+
try {
|
|
106
|
+
const activeCore = await configuredCore();
|
|
107
|
+
let connectionError: unknown;
|
|
108
|
+
try {
|
|
109
|
+
await activeCore.connect();
|
|
110
|
+
} catch (error) {
|
|
111
|
+
connectionError = error;
|
|
112
|
+
}
|
|
113
|
+
if (!isCurrent()) return;
|
|
114
|
+
const outboxError = await refreshInbox();
|
|
115
|
+
if (!isCurrent()) return;
|
|
116
|
+
const lifecycleError = outboxError ?? connectionError;
|
|
117
|
+
const status = lifecycleError === undefined ? undefined : outboxFailureStatus(lifecycleError);
|
|
118
|
+
context.ui.setStatus("pi-tasks", status === undefined ? undefined : context.ui.theme.fg("warning", status));
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (isCurrent()) context.ui.setStatus("pi-tasks", context.ui.theme.fg("warning", outboxFailureStatus(error)));
|
|
121
|
+
}
|
|
77
122
|
};
|
|
78
123
|
|
|
79
|
-
pi.on("session_start", async (_event,
|
|
80
|
-
|
|
124
|
+
pi.on("session_start", async (_event, context) => {
|
|
125
|
+
const epoch = ++lifecycleEpoch;
|
|
126
|
+
inboxContext = context;
|
|
127
|
+
if (backgroundTimer) clearInterval(backgroundTimer);
|
|
81
128
|
backgroundTimer = setInterval(() => {
|
|
82
|
-
|
|
129
|
+
const currentContext = inboxContext;
|
|
130
|
+
if (currentContext) void refreshLifecycle(currentContext, epoch);
|
|
83
131
|
}, BACKGROUND_POLL_MS);
|
|
84
|
-
await
|
|
132
|
+
await refreshLifecycle(context, epoch);
|
|
85
133
|
});
|
|
86
|
-
pi.on("agent_end", async (_event,
|
|
87
|
-
|
|
134
|
+
pi.on("agent_end", async (_event, context) => {
|
|
135
|
+
const epoch = lifecycleEpoch;
|
|
136
|
+
inboxContext = context;
|
|
137
|
+
await refreshLifecycle(context, epoch);
|
|
88
138
|
});
|
|
89
139
|
pi.on("session_shutdown", () => {
|
|
140
|
+
lifecycleEpoch += 1;
|
|
90
141
|
if (backgroundTimer) clearInterval(backgroundTimer);
|
|
91
142
|
backgroundTimer = undefined;
|
|
143
|
+
inboxContext = undefined;
|
|
92
144
|
});
|
|
93
145
|
|
|
94
146
|
pi.registerTool({
|
|
95
|
-
name: "agent_task_send", label: "Send Agent Task", description: "
|
|
96
|
-
|
|
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) {
|
|
147
|
+
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,
|
|
148
|
+
async execute(_id, params, signal) {
|
|
100
149
|
try {
|
|
101
|
-
const
|
|
102
|
-
return toolResult(
|
|
103
|
-
} catch (error) { return
|
|
104
|
-
},
|
|
150
|
+
const sent = await (await configuredCore(signal)).createTask({ target: params.to, task: params.task, timeoutMs: params.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS }, signal);
|
|
151
|
+
return toolResult(sent, `## task accepted\n- task: \`${sent.taskId}\`\n- target: \`${params.to.relay}/${params.to.id}\`\n- delivery: relay acceptance only`);
|
|
152
|
+
} catch (error) { return taskError(error); }
|
|
153
|
+
},
|
|
154
|
+
renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
105
155
|
});
|
|
106
|
-
|
|
107
156
|
pi.registerTool({
|
|
108
|
-
name: "agent_task_status", label: "Task Status", description: "Read
|
|
109
|
-
async execute(
|
|
110
|
-
try {
|
|
157
|
+
name: "agent_task_status", label: "Task Status", description: "Read local endpoint-owned task state; status is unavailable while its origin is offline.", parameters: TaskIdParams,
|
|
158
|
+
async execute(_id, params, signal) {
|
|
159
|
+
try {
|
|
160
|
+
const task = (await configuredCore(signal)).getTask(params.taskId);
|
|
161
|
+
return task ? toolResult(task, `## task status\n- task: \`${task.taskId}\`\n- status: ${task.status}`) : taskError(new Error("unknown local task"));
|
|
162
|
+
} catch (error) { return taskError(error); }
|
|
111
163
|
}, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
112
164
|
});
|
|
113
|
-
|
|
114
165
|
pi.registerTool({
|
|
115
|
-
name: "agent_task_wait", label: "Wait Agent Task", description: "
|
|
116
|
-
async execute(
|
|
166
|
+
name: "agent_task_wait", label: "Wait Agent Task", description: "Wait for a locally-known endpoint-owned task to reach a terminal state.", parameters: WaitParams,
|
|
167
|
+
async execute(_id, params, signal, onUpdate) {
|
|
117
168
|
const deadline = Date.now() + (params.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS);
|
|
118
169
|
try {
|
|
119
170
|
for (;;) {
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
123
|
-
if (
|
|
171
|
+
await refreshInbox(signal);
|
|
172
|
+
const task = (await configuredCore(signal)).getTask(params.taskId);
|
|
173
|
+
if (!task) return taskError(new Error("unknown local task"));
|
|
174
|
+
if (terminal(task.status)) return toolResult(task, `## task status\n- task: \`${task.taskId}\`\n- status: ${task.status}`);
|
|
175
|
+
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
176
|
onUpdate?.({ content: [{ type: "text", text: `waiting for ${params.taskId}...` }], details: {} });
|
|
125
|
-
await sleep(Math.min(WAIT_POLL_MS,
|
|
177
|
+
await sleep(Math.min(WAIT_POLL_MS, deadline - Date.now()), signal);
|
|
126
178
|
}
|
|
127
|
-
} catch (error) { return
|
|
179
|
+
} catch (error) { return taskError(error); }
|
|
128
180
|
}, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
129
181
|
});
|
|
130
|
-
|
|
131
182
|
pi.registerTool({
|
|
132
|
-
name: "agent_task_inbox", label: "Task Inbox", description: "Read
|
|
133
|
-
async execute(
|
|
183
|
+
name: "agent_task_inbox", label: "Task Inbox", description: "Read local task records after processing relay deliveries without acknowledging task lifecycle.", parameters: Type.Object({}),
|
|
184
|
+
async execute(_id, _params, signal) {
|
|
134
185
|
try {
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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))); },
|
|
186
|
+
await refreshInbox(signal);
|
|
187
|
+
const tasks = (await configuredCore(signal)).listTasks();
|
|
188
|
+
return toolResult({ tasks }, `## task inbox\n${tasks.map((task) => `- \`${task.taskId}\`: ${task.status}`).join("\n") || "- empty"}`);
|
|
189
|
+
} catch (error) { return taskError(error); }
|
|
190
|
+
},
|
|
191
|
+
renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
148
192
|
});
|
|
149
|
-
|
|
150
193
|
pi.registerTool({
|
|
151
|
-
name: "agent_task_message", label: "Message Agent Task", description: "
|
|
152
|
-
async execute(
|
|
153
|
-
|
|
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))); },
|
|
194
|
+
name: "agent_task_message", label: "Message Agent Task", description: "Persist a message intent before relay submission.", parameters: MessageParams,
|
|
195
|
+
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); } },
|
|
196
|
+
renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
160
197
|
});
|
|
161
|
-
|
|
162
198
|
pi.registerTool({
|
|
163
|
-
name: "agent_task_cancel", label: "Cancel Agent Task", description: "
|
|
164
|
-
async execute(
|
|
165
|
-
|
|
166
|
-
}, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
199
|
+
name: "agent_task_cancel", label: "Cancel Agent Task", description: "Persist cancellation before relay submission.", parameters: TaskIdParams,
|
|
200
|
+
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); } },
|
|
201
|
+
renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
167
202
|
});
|
|
168
|
-
|
|
169
203
|
pi.registerTool({
|
|
170
|
-
name: "
|
|
171
|
-
|
|
172
|
-
|
|
204
|
+
name: "agent_task_ack", label: "Acknowledge Agent Task", description: "Record parent acknowledgement as an origin-owned logical event.", parameters: TaskIdParams,
|
|
205
|
+
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); } },
|
|
206
|
+
renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
207
|
+
});
|
|
208
|
+
pi.registerTool({
|
|
209
|
+
name: "agent_task_done", label: "Complete Agent Task", description: "Persist a terminal task intent before relay submission.", parameters: DoneParams,
|
|
210
|
+
async execute(_id, params, signal) {
|
|
173
211
|
try {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
} catch (error) { return clientError(error); }
|
|
212
|
+
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);
|
|
213
|
+
return { ...toolResult({ taskId: params.taskId }, `## task ${params.status}\n- task: \`${params.taskId}\`\n- ${params.summary}`), terminate: true };
|
|
214
|
+
} catch (error) { return taskError(error); }
|
|
178
215
|
}, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
179
216
|
});
|
|
180
217
|
}
|
|
181
218
|
|
|
182
|
-
export
|
|
183
|
-
|
|
219
|
+
export function createSingleFlightInboxRefresh<TValue>(refresh: (signal?: AbortSignal) => Promise<TValue>): (signal?: AbortSignal) => Promise<TValue> {
|
|
220
|
+
let inFlight: Promise<TValue> | undefined;
|
|
221
|
+
return (signal): Promise<TValue> => {
|
|
222
|
+
if (inFlight) return inFlight;
|
|
223
|
+
const current = refresh(signal).finally(() => {
|
|
224
|
+
if (inFlight === current) inFlight = undefined;
|
|
225
|
+
});
|
|
226
|
+
inFlight = current;
|
|
227
|
+
return current;
|
|
228
|
+
};
|
|
184
229
|
}
|
|
185
230
|
|
|
186
|
-
function
|
|
187
|
-
|
|
231
|
+
export default function piTasks(pi: ExtensionAPI): void {
|
|
232
|
+
registerAgentTaskTools(pi);
|
|
188
233
|
}
|
|
189
234
|
|
|
190
235
|
function toolResult(details: unknown, markdown: string): AgentToolResult<unknown> {
|
|
191
236
|
return { content: [{ type: "text", text: markdown }], details };
|
|
192
237
|
}
|
|
193
238
|
|
|
194
|
-
function
|
|
195
|
-
const
|
|
196
|
-
|
|
239
|
+
function taskError(error: unknown): AgentToolResult<unknown> {
|
|
240
|
+
const message = error instanceof Error ? error.message : "task operation failed";
|
|
241
|
+
if (error instanceof TaskProtocolError) {
|
|
242
|
+
return toolResult({ ...error.details, error: { code: error.code, message, retryable: error.retryable } }, `## task error\n- ${message}`);
|
|
243
|
+
}
|
|
244
|
+
return toolResult({ error: { code: "TASK_ERROR", message, retryable: true } }, `## task error\n- ${message}`);
|
|
197
245
|
}
|
|
198
246
|
|
|
199
|
-
function
|
|
200
|
-
return
|
|
247
|
+
function outboxFailureStatus(error: unknown): "tasks: relay unavailable" | "tasks: outbox degraded" {
|
|
248
|
+
return error instanceof TaskOutboxDeliveryError && error.code === TARGET_NOT_REGISTERED_CODE ? "tasks: outbox degraded" : "tasks: relay unavailable";
|
|
201
249
|
}
|
|
202
250
|
|
|
203
251
|
function terminal(status: string): boolean {
|
|
204
|
-
return
|
|
252
|
+
return ["completed", "failed", "cancelled", "timed_out"].includes(status);
|
|
205
253
|
}
|
|
206
254
|
|
|
207
255
|
function text(result: { readonly content?: readonly unknown[] }): string {
|
|
@@ -209,14 +257,10 @@ function text(result: { readonly content?: readonly unknown[] }): string {
|
|
|
209
257
|
return first && typeof first === "object" && "type" in first && first.type === "text" && "text" in first && typeof first.text === "string" ? first.text : "";
|
|
210
258
|
}
|
|
211
259
|
|
|
212
|
-
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
213
|
-
if (signal?.aborted) throw new GatewayClientError("ABORTED", "task wait was cancelled", true);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
260
|
async function sleep(milliseconds: number, signal: AbortSignal | undefined): Promise<void> {
|
|
261
|
+
if (signal?.aborted) throw new Error("task wait was cancelled");
|
|
217
262
|
await new Promise<void>((resolve, reject) => {
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
263
|
+
const timer = setTimeout(resolve, Math.max(1, milliseconds));
|
|
264
|
+
signal?.addEventListener("abort", () => { clearTimeout(timer); reject(new Error("task wait was cancelled")); }, { once: true });
|
|
221
265
|
});
|
|
222
266
|
}
|