@maxiaochao/pi-toolkit 0.13.4 → 0.13.6
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/CHANGELOG.md +14 -0
- package/README.md +4 -0
- package/package.json +1 -1
- package/packages/background-tasks/CHANGELOG.md +12 -0
- package/packages/background-tasks/README.md +37 -6
- package/packages/background-tasks/TESTING.md +9 -2
- package/packages/background-tasks/docs/demo.gif +0 -0
- package/packages/background-tasks/docs/screenshot.png +0 -0
- package/packages/background-tasks/extensions/background-tasks.ts +88 -8
- package/packages/background-tasks/package.json +2 -2
- package/packages/background-tasks/tests/compaction.mjs +167 -0
- package/packages/background-tasks/tests/extension.mjs +309 -9
- package/packages/background-tasks/tests/fixtures/notifications.ts +77 -0
- package/packages/background-tasks/tests/fixtures/provider.ts +14 -4
- package/packages/background-tasks/tests/interruptions.mjs +140 -0
- package/packages/background-tasks/tests/notifications.mjs +80 -0
- package/packages/background-tasks/tests/tui-smoke.py +8 -4
- package/packages/herdr-subagents/CHANGELOG.md +6 -0
- package/packages/herdr-subagents/README.md +4 -0
- package/packages/herdr-subagents/TESTING.md +4 -0
- package/packages/herdr-subagents/extensions/subagent-report.ts +2 -1
- package/packages/herdr-subagents/package.json +1 -1
- package/packages/herdr-subagents/src/live-agent.ts +5 -2
- package/packages/herdr-subagents/tests/config.mjs +8 -0
- package/packages/herdr-subagents/tests/core-state-machine.mjs +36 -0
- package/packages/herdr-subagents/tests/extension-sources-load.mjs +25 -0
- package/packages/herdr-subagents/tests/run-tests.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 2026-10-22 - v0.13.6
|
|
2
|
+
|
|
3
|
+
- background-tasks v0.1.1: batch unread completions at tool boundaries, preserve interrupted runs, and provide non-polling startup guidance
|
|
4
|
+
|
|
5
|
+
## 2026-10-22 - v0.13.5
|
|
6
|
+
|
|
7
|
+
- herdr-subagents v0.7.4: preserve Codex patch editing in child allowlists and hide the parent shortcut footer hint
|
|
8
|
+
- Herdr Subagents: hide the child parent-navigation footer hint while keeping `Alt+P`, `/parent`, and other extensions' editor status.
|
|
9
|
+
- Herdr Subagents: permit `apply_patch` in edit-capable child CLI allowlists so editor switching does not remove the only active editing tool; preserve read-only limits and native fallback.
|
|
10
|
+
|
|
1
11
|
## 2026-10-22 - v0.13.4
|
|
2
12
|
|
|
3
13
|
- herdr-subagents v0.7.3: keep Persona selection lists concise
|
|
@@ -13,6 +23,10 @@
|
|
|
13
23
|
|
|
14
24
|
## Unreleased
|
|
15
25
|
|
|
26
|
+
- Background Tasks: expose the approved non-polling startup guidance to direct and Codemode callers. Retain results after a user interrupt without restarting the agent, then hand them to the next user-message run; verify native tool batches, interrupts and compaction cancellation without changing Pi itself.
|
|
27
|
+
- Background Tasks: merge unread completions at the next safe tool boundary, consume pending reminders on terminal-output reads, and show actual completion times. Preserve automatic idle notification without adding a pause switch or changing Herdr execution/global queue settings.
|
|
28
|
+
- Background Tasks: reduce busy-session notification checks with exponential backoff capped at 30 seconds, without expiring unread results or delaying normal tool-boundary delivery.
|
|
29
|
+
|
|
16
30
|
- Herdr Subagents: show only Persona names and descriptions in model-facing selection lists, preserving diagnostics and detailed user-facing inspection.
|
|
17
31
|
- Make Agent Team user-invoked only through `/skill:agent-team`, hidden from automatic model selection.
|
|
18
32
|
- Add `npm run release:check -- vX.Y.Z` to wait for GitHub publication and verify exact npm versions, remote tags, integrity, and all tarball contents against the release commit, including coordinated child releases.
|
package/README.md
CHANGED
|
@@ -40,8 +40,12 @@ Restart Pi after updating. `pi update --extensions` has the same effect when upd
|
|
|
40
40
|
|
|
41
41
|
Each child README owns its feature description, motivation or upstream alignment, examples, screenshots/demos, configuration, and test documentation.
|
|
42
42
|
|
|
43
|
+
Background Tasks combines unread completions after any current tool batch, before the next model request, including when the main agent waits for a synchronous subagent. Idle completions wake the main agent automatically, except after a user interrupt: results remain available until the next user-message run. Reading a finished task's `output` through the tool consumes its pending reminder. Startup guidance tells direct and Codemode callers to continue independent work or end the turn, not query to wait. See [completion delivery](packages/background-tasks/README.md#user-controls-and-status).
|
|
44
|
+
|
|
43
45
|
Codex Edit personal routing rules belong in `~/.pi/agent/codex-edit.json`, not inside `node_modules`. User fields override bundled defaults; `models` replaces the complete default list. The file survives package updates and is shared by parent sessions and explicitly configured subagents using the same agent directory. Use `/reload` after editing it, and start new subagents. See [Codex Edit configuration](packages/codex-edit/README.md#configuration).
|
|
44
46
|
|
|
47
|
+
Child panes keep `Alt+P` and `/parent` navigation without a persistent shortcut footer hint. Editor status remains owned by the loaded editor extension.
|
|
48
|
+
|
|
45
49
|
Herdr Subagents tasks require an explicit `agent` Persona; no default `worker` is selected. Choose `reviewer` for independent reviews of proposals, designs, documents, implementations, or analysis; `explorer` for codebase investigation; `worker` for file-changing work; and `analyst` for charter-defined agent-team roles. Control `list` returns names and descriptions for selection, plus configuration diagnostics when present; use `/herdr-subagents agents` and `settings` for detailed configuration. See [built-in Personas](packages/herdr-subagents/README.md#built-in-personas) for selection guidance and the distinction between parent-facing descriptions and child instructions. Personas can opt into local extension files or npm/git Pi packages through `extensions`. Set top-level `defaultExtensions` in `~/.pi/agent/herdr-subagents.json` (or project `.pi/herdr-subagents.json`) to share a fallback list; Persona-specific lists take precedence, and `[]` clears inherited sources. This uses native Pi package semantics: package-provided skills, prompt templates, and themes may also load, and `--no-skills` does not exclude explicitly selected package skills. See the [extension-source contract](packages/herdr-subagents/README.md#explicit-extension-sources-and-pi-packages) for precedence, isolation, and examples. The [real Herdr smoke test](packages/herdr-subagents/TESTING.md) preserves the configured analyst model and explicit authentication extensions while isolating unrelated settings.
|
|
46
50
|
|
|
47
51
|
Multiple standalone children may be installed together. `pi-openai-account-pool` may also be installed alongside the complete toolkit because the toolkit does not embed it. Every other child in the table above, including `pi-background-tasks`, is embedded, so a second install exposes the same resource twice.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-toolkit",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.6",
|
|
4
4
|
"description": "Personal pi coding agent toolkit: background tasks, scheduler, /btw, /cache_export, apply_patch, browser automation, and bundled theme/skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2026-10-22 - v0.1.1
|
|
4
|
+
|
|
5
|
+
- batch unread completions at tool boundaries, preserve interrupted runs, and provide non-polling startup guidance
|
|
6
|
+
- Include the approved non-polling startup guidance in the tool description, model-visible start result and Codemode's structured result. Keep ordinary output reads separate from waiting.
|
|
7
|
+
- Follow OMP's user-interrupt semantics: retain pending and later results without restarting the stopped agent, then hand unread results to the next user-message run. Do not confuse manual-compaction cancellation with an agent interrupt; add no switch or waiting tool.
|
|
8
|
+
- Cover direct native tool batches as well as Codemode, and real user interrupts before/after shell completion. Determine native compaction cancellation from lifecycle state instead of error wording.
|
|
9
|
+
- Deliver pending completions together after the current tool batch, before the next model request, instead of queuing one follow-up turn per task after the final report. Idle completions still wake the main agent automatically.
|
|
10
|
+
- Consume pending reminders when tool callers read terminal output, show actual completion times, and clear pending notification timers on session changes. Keep process/subagent execution and global queue settings unchanged; add no notification pause switch.
|
|
11
|
+
- Add a real-Pi four-completion queue regression with direct nested/Codemode reads, alongside deterministic busy/idle, consumption and abort/error checks.
|
|
12
|
+
- Back off busy-session notification checks through 1/2/4/8/16/30-second intervals, capped at 30 seconds without a retry limit or unread-result expiry. Tool boundaries still deliver immediately; fresh batches reset the delay. Keep the generic idle check instead of subscribing to individual busy-operation events.
|
|
13
|
+
- Verify native Pi manual compaction success, summary failure and cancellation with real shell jobs finishing during an offline summary request; pending results wake the main agent once after compaction, without another user prompt.
|
|
14
|
+
|
|
3
15
|
## 2026-10-20 - v0.1.0
|
|
4
16
|
|
|
5
17
|
- bundle background shell tasks with runtime limits, bounded logs, completion notifications and /bg controls
|
|
@@ -12,6 +12,8 @@ A command such as `sleep 60; python3 ...` can spend most of its time waiting to
|
|
|
12
12
|
|
|
13
13
|
It uses Pi's public `createLocalBashOperations()` backend for shell execution and process-group cancellation. It adds session ownership, bounded logs, status, and completion messages. It neither replaces Bash nor starts another model or Herdr pane.
|
|
14
14
|
|
|
15
|
+
Completion delivery follows [oh-my-pi's yield-queue approach](https://github.com/can1357/oh-my-pi/blob/3a2758b68f7c8e6bdf22c3a76b86c8675e5e0c9e/packages/coding-agent/src/session/yield-queue.ts): retain results, combine them at a safe boundary, and filter consumed results before delivery. This package uses Pi's public extension API, not patched host code; a local idle retry covers busy operations that expose no final agent boundary.
|
|
16
|
+
|
|
15
17
|
## Install and update
|
|
16
18
|
|
|
17
19
|
Requires Pi **1.3.0 or later**.
|
|
@@ -27,8 +29,22 @@ The complete `@maxiaochao/pi-toolkit` package **bundles and enables Background T
|
|
|
27
29
|
|
|
28
30
|
```text
|
|
29
31
|
background_task({ action: "start", command: "sleep 10; printf 'checks complete\\n'", description: "Delayed check", timeout: 120 })
|
|
30
|
-
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Each accepted start includes this exact guidance in the tool's text and `structuredContent.guidance`. The tool description carries it too, including for Codemode discovery:
|
|
35
|
+
|
|
36
|
+
> 完成会自动通知。现在不要查询等待;继续其他独立工作,没有其他工作就结束当前轮,收到通知后再取结果。
|
|
37
|
+
|
|
38
|
+
After the completion notification, read the result:
|
|
39
|
+
|
|
40
|
+
```text
|
|
31
41
|
background_task({ action: "output", id: "<returned-task-id>" })
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The tool also supports listing and cancellation:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
background_task({ action: "list" })
|
|
32
48
|
background_task({ action: "cancel", id: "<returned-task-id>" })
|
|
33
49
|
```
|
|
34
50
|
|
|
@@ -57,7 +73,22 @@ The commands inspect the same jobs without asking a model to perform the operati
|
|
|
57
73
|
|
|
58
74
|
A fixed widget **above the editor** shows each task's ID, status, elapsed time, runtime limit and description. It coexists with Scheduler, Todo, and Herdr widgets. Finished rows remain for five seconds; hiding a row does not delete its record. RPC gets a compact active count; non-interactive tools work without the widget.
|
|
59
75
|
|
|
60
|
-
Completion, failure, cancellation, timeout and output-limit termination
|
|
76
|
+
Completion, failure, cancellation, timeout and output-limit termination update the widget immediately. The extension holds pending completion metadata until it can deliver it safely:
|
|
77
|
+
|
|
78
|
+
- While the main agent is busy, completions are combined into one message after the current tool batch returns, before the next model request. This includes waiting for a synchronous Herdr subagent; it does not cancel or interrupt that subagent.
|
|
79
|
+
- While idle, pending completions are combined on the next event-loop tick and wake the main agent once. Jobs that finish after that batch can produce another notification. Pi's global steering/follow-up queue settings are unchanged.
|
|
80
|
+
- If another operation such as manual compaction keeps the session busy without an agent boundary, one local timer retries after 1, 2, 4, 8, 16, then 30 seconds, staying at 30 seconds thereafter. There is no retry-count limit or expiry that discards unread results. This does not call a model or poll shell output.
|
|
81
|
+
- Normal tool/settlement boundaries still deliver immediately, without waiting for the retry timer. Without a boundary, an idle session can wait up to the current retry interval (at most 30 seconds, subject to native timer delays). Once a batch is delivered or fully consumed, the next batch starts with an immediate check and fresh backoff.
|
|
82
|
+
- A successful tool `output` read of a finished task consumes its pending reminder, including failed tasks. This applies to direct and Codemode callers; a script that reads and filters a result has consumed it even if it does not print it. A running snapshot, `list`, a failed log read, or a user `/bg output` inspection does not consume the main agent's reminder. Consumption does not mean the task passed validation.
|
|
83
|
+
- Aborted/error boundaries do not request continuation or replay a previously scheduled wake. An error does not suppress later completions.
|
|
84
|
+
- A user interrupt keeps the agent stopped: accepted jobs still run, but even later completions cannot restart it. Unread results remain queryable and are handed to the next user-message run before its first model request. Another extension's custom wake does not release this interrupt state; a new user-message run or session replacement does. This adds no notification pause switch.
|
|
85
|
+
- Cancelling manual compaction is separate from interrupting the agent. Pending completions still wake an otherwise-idle session after compaction succeeds, fails or is cancelled.
|
|
86
|
+
|
|
87
|
+
Pi's public interrupted-run event does not include the abort reason. In particular, starting `/compact` during an active run first interrupts that run. This package preserves that interruption until the next user-message run; it does not infer that compaction grants permission to restart the agent. Compaction started while already idle does not create this interrupted-run state.
|
|
88
|
+
|
|
89
|
+
Each message includes actual completion times, statuses and log paths, **not the output body**. The agent can call output when it needs details. Completions that arrive near final settlement are checked again before the run settles. No per-task `followUp` messages are left behind the main agent's final report.
|
|
90
|
+
|
|
91
|
+
Pi reports asynchronous message/run-start failures through its extension error channel. This extension does not automatically replay a message after such a failure; task metadata and captured logs remain queryable for the lifetime of the session.
|
|
61
92
|
|
|
62
93
|
## Output and safety limits
|
|
63
94
|
|
|
@@ -85,21 +116,21 @@ const started = await tools.background_task({
|
|
|
85
116
|
command: "sleep 10; printf 'done\\n'",
|
|
86
117
|
timeout: 120,
|
|
87
118
|
});
|
|
88
|
-
text(started
|
|
119
|
+
text(started);
|
|
89
120
|
```
|
|
90
121
|
|
|
91
|
-
|
|
122
|
+
This prints the startup guidance as well as the task. Continue independent work or end the turn; do not keep a Codemode worker open to sleep or poll for completion. After notification, another script can read the result. Merely starting an unawaited Bash call is different: Codemode cancels unfinished nested calls when its script ends.
|
|
92
123
|
|
|
93
124
|
The tool description is compact. This package adds no `promptGuidelines` or extra global system-prompt rules.
|
|
94
125
|
|
|
95
126
|
## Lifetime and scope
|
|
96
127
|
|
|
97
128
|
- Exit, new/resumed/forked sessions and reload terminate active tasks, remove the widget and timers, and delete this package's captured logs. Commands' own files are left alone. History is not restored.
|
|
98
|
-
- Aborting the main agent's current turn does not cancel an accepted job. Use cancel or `/bg cancel
|
|
129
|
+
- Aborting the main agent's current turn does not cancel an accepted job, but its completion cannot automatically restart the interrupted agent. Use cancel or `/bg cancel` to stop the job itself.
|
|
99
130
|
- Cancellation and runtime limits stop the native shell process group. Scripts must wait for their own work. Daemonized/detached commands (`nohup`, `setsid`, self-backgrounding processes), existing PID adoption, PTYs and interactive stdin are not supported.
|
|
100
131
|
- This is a distinct **local execution** tool. A third-party sandbox or remote backend installed only for Bash does not automatically cover it. Enable it only where direct local shell execution is permitted.
|
|
101
132
|
- It is not an OS sandbox: CPU/memory quotas, cleanup after SIGKILL/power loss, automatic retries, deadline renewal and persistence are out of scope. Native signal delivery and timers are best-effort.
|
|
102
|
-
-
|
|
133
|
+
- Pending reminders and notification timers are cleared on session shutdown. Once a completion is delivered into conversation history, reading output does not erase that history. Messages queued by an older installed version cannot be selectively withdrawn by this version.
|
|
103
134
|
|
|
104
135
|
## Remove
|
|
105
136
|
|
|
@@ -13,7 +13,14 @@ Tests use Node's built-in test runner. They resolve host peers from the installe
|
|
|
13
13
|
- `tasks.mjs`: fake execution and clock at the task-manager interface; immediate return, two slots, validation, timeout/cancel races, final failure, disk budgets, write errors, bounded history and shutdown.
|
|
14
14
|
- `output.mjs`: compares both output views with the real host Bash tool. Covers line/byte limits, long lines, empty output, missing final newline, UTF-8 split across chunks, invalid UTF-8 and logs larger than 1 MiB.
|
|
15
15
|
- `processes.mjs`: the native backend, a multi-line Python log check, non-blocking concurrency, PID-observed process-tree cleanup, silent busy-loop timeout and the default 32 MiB disk limit.
|
|
16
|
-
- `extension.mjs`: real extension registration with fake execution/clock; schema errors, command parity, parent-signal independence,
|
|
16
|
+
- `extension.mjs`: real extension registration with fake execution/clock; schema errors, command parity, parent-signal independence, merged busy/idle notifications, terminal-output consumption and failed-read retention, abort/error boundaries, manual-compaction-style busy intervals without agent boundaries, other extensions' draft preservation, no output in notifications, above-editor placement, five-second retention, narrow/CJK rendering, coexistence and non-TUI modes. Checks the exact approved startup guidance in both return paths and the tool description; verifies 24-hour retention after an interrupt, output consumption while stopped, custom-wake isolation and restoration of automatic delivery by a new user-message run.
|
|
17
|
+
- `notifications.mjs`: isolated real Pi with direct tool batches or its native Codemode tool, default one-at-a-time queues and an offline provider. Four actual failing shell jobs finish while a synchronous tool stands in for a subagent. The next model request gets one merged notification, excluding zero, one or four consumed terminal outputs; no extra requests follow the final report. Also verifies that native Codemode and the next model request receive the exact startup guidance. The blocking tool is a fixture, not a real Herdr child. `BG_TEST_EXTENSION=/absolute/path/to/background-tasks.ts` can run the queue regressions against an older implementation.
|
|
18
|
+
- `compaction.mjs`: isolated native Pi SDK sessions call `session.compact()`, the same manual-compaction entry point used by `/compact` and RPC. Real shell jobs exit during a held offline summary request. Covers successful native compaction, a provider summary error and `abortCompaction()` cancellation; each must retain the pending completion while busy and automatically deliver it once afterward, without another user prompt. Checks actual compaction lifecycle/events, saved compaction entries, completion timestamps/logs and exactly one automatic conversation request. Uses real timers and native `isIdle`/`isCompacting`, not injected busy state or replacement compaction results; no paid model call or TUI keyboard invocation of `/compact`. `BG_TEST_EXTENSION` also supports a negative-control extension.
|
|
19
|
+
- `interruptions.mjs`: isolated native Pi SDK sessions call the real `session.abort()` while a foreground tool is held. A real shell finishes before or after the interrupt; terminal timestamps verify the ordering. Native `agent_settled.aborted` must confirm the stop; no completion may start another model request. A new user prompt sees the retained completion in its first request, reads the real failed task's output, and finishes without duplicate delivery or extension errors. A third case starts actual manual compaction while the foreground tool is held: Pi itself interrupts the active agent, which must stay stopped even after compaction and background completion. The public abort event has no reason field, so the extension preserves this native interruption rather than guessing its source. `BG_TEST_EXTENSION` supports a negative control without interrupt suppression.
|
|
20
|
+
|
|
21
|
+
Compaction cancellation assertions use native lifecycle `aborted` flags and the absence of a saved compaction entry, not version-dependent error strings. Agent interruption is a different state and is tested separately.
|
|
22
|
+
|
|
23
|
+
Notification retry tests use the injected clock and session-idle interface. They check the 1/2/4/8/16/30-second delays, the repeated 30-second cap, retention across 24 hours of busy time, one timer shared by new completions, immediate boundary delivery, fresh-batch reset, and cleanup on terminal reads, abort/error boundaries and session replacement. No compression-event subscription or notification pause action is introduced. Offline fixtures prove guidance delivery, not that a live model always obeys it; no paid/live-model anti-polling evaluation is part of this suite.
|
|
17
24
|
|
|
18
25
|
Load-test uses an isolated temporary agent directory, the production extension and an offline fixture provider. It does not access real provider credentials or alter the user's installed packages. From the workspace, `npm --prefix packages/background-tasks run load-test -- --toolkit` loads the complete toolkit directory through its manifest and verifies exactly one background-task tool; the root load-test runs this check too.
|
|
19
26
|
|
|
@@ -25,7 +32,7 @@ Requires Python 3 with the existing `pexpect` module; process fixtures use Bash,
|
|
|
25
32
|
python3 packages/background-tasks/tests/tui-smoke.py .cache/verification/background-tui
|
|
26
33
|
```
|
|
27
34
|
|
|
28
|
-
This starts a real Pi in a PTY with the production extension, Scheduler, the built-in Codemode tool and a deterministic fixture provider. It checks two jobs, rejection of a third, Codemode completion while jobs remain alive, explicit output, cancellation, automatic main-agent
|
|
35
|
+
This starts a real Pi in a PTY with the production extension, Scheduler, the built-in Codemode tool and a deterministic fixture provider. It checks two jobs, rejection of a third, Codemode completion while jobs remain alive, explicit output, cancellation, automatic main-agent notification, reload/new/quit cleanup and log removal. It observes both persisted boundary entries and idle custom messages by unique session-entry ID, and rejects duplicate notifications. No network model calls occur. Mocked UI tests separately verify the exact five-second boundary.
|
|
29
36
|
|
|
30
37
|
Results and the actual ANSI stream are stored under the chosen evidence directory. Real screenshots require an isolated X11 terminal window, `gnome-terminal.real`, `xwininfo` and ImageMagick `import`:
|
|
31
38
|
|
|
Binary file
|
|
Binary file
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
-
import { createLocalBashOperations, type BashOperations, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { createLocalBashOperations, type AgentBeforeSettleEvent, type BashOperations, type ExtensionAPI, type ExtensionContext, type TurnEndEvent } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import { stripVTControlCharacters } from "node:util";
|
|
5
5
|
import { Type } from "typebox";
|
|
@@ -7,6 +7,7 @@ import { BackgroundTasks, errorText, summary, systemClock, type Clock, type Task
|
|
|
7
7
|
import { BackgroundWidget, FINISHED_VISIBLE_MS, WIDGET_KEY, duration, visibleTasks } from "../src/widget.ts";
|
|
8
8
|
|
|
9
9
|
const actions = ["start", "list", "output", "cancel"] as const;
|
|
10
|
+
const startGuidance = "完成会自动通知。现在不要查询等待;继续其他独立工作,没有其他工作就结束当前轮,收到通知后再取结果。";
|
|
10
11
|
type Action = typeof actions[number];
|
|
11
12
|
interface Params {
|
|
12
13
|
action: Action;
|
|
@@ -34,6 +35,7 @@ const OutputSchema = Type.Object({
|
|
|
34
35
|
action: Type.Optional(StringEnum(actions)), task: Type.Optional(TaskSchema), tasks: Type.Optional(Type.Array(TaskSchema)),
|
|
35
36
|
running: Type.Optional(Type.Number()), total: Type.Optional(Type.Number()), omitted: Type.Optional(Type.Number()),
|
|
36
37
|
output: Type.Optional(Type.String()), truncated: Type.Optional(Type.Boolean()), full_output_path: Type.Optional(Type.String()),
|
|
38
|
+
guidance: Type.Optional(Type.String()),
|
|
37
39
|
error: Type.Optional(Type.String()),
|
|
38
40
|
});
|
|
39
41
|
|
|
@@ -62,8 +64,78 @@ export default function (pi: ExtensionAPI, dependencies: { operations?: BashOper
|
|
|
62
64
|
let context: ExtensionContext | undefined;
|
|
63
65
|
let generation = 0;
|
|
64
66
|
let timer: unknown;
|
|
67
|
+
let notificationTimer: unknown;
|
|
68
|
+
let completedNormally = true;
|
|
69
|
+
let interrupted = false;
|
|
70
|
+
const pending = new Map<string, TaskSnapshot>();
|
|
65
71
|
let mounted: { widget: BackgroundWidget; tui: { requestRender(): void } } | undefined;
|
|
66
72
|
|
|
73
|
+
const clearNotificationTimer = () => {
|
|
74
|
+
if (notificationTimer !== undefined) clock.clearTimeout(notificationTimer);
|
|
75
|
+
notificationTimer = undefined;
|
|
76
|
+
};
|
|
77
|
+
const takeNotification = () => {
|
|
78
|
+
clearNotificationTimer();
|
|
79
|
+
if (pending.size === 0) return;
|
|
80
|
+
const tasks = [...pending.values()].sort((a, b) => a.ended_at! - b.ended_at!);
|
|
81
|
+
pending.clear();
|
|
82
|
+
return {
|
|
83
|
+
customType: "background-task-finished", display: true,
|
|
84
|
+
details: { tasks, ...(tasks.length === 1 ? { task: tasks[0] } : {}) },
|
|
85
|
+
content: `[Background task finished]\n${tasks.map((task) =>
|
|
86
|
+
`${formatTask(task)}\nEnded at: ${new Date(task.ended_at!).toISOString()}${task.reason ? `\nReason: ${task.reason}` : ""}\nCaptured log: ${task.log_path}${task.capture_complete ? "" : "\nOutput capture is incomplete."}`
|
|
87
|
+
).join("\n\n")}`,
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
const scheduleNotification = (delay = 0) => {
|
|
91
|
+
if (interrupted || notificationTimer !== undefined || pending.size === 0) return;
|
|
92
|
+
const epoch = generation;
|
|
93
|
+
notificationTimer = clock.setTimeout(() => {
|
|
94
|
+
notificationTimer = undefined;
|
|
95
|
+
if (generation !== epoch || !context) return;
|
|
96
|
+
if (!context.isIdle()) {
|
|
97
|
+
// Manual compaction has no agent boundary when it finishes.
|
|
98
|
+
// Keep one wake pending, without expiring unread results or polling every second.
|
|
99
|
+
scheduleNotification(Math.min(delay === 0 ? 1000 : delay * 2, 30_000));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const message = takeNotification();
|
|
103
|
+
if (!message) return;
|
|
104
|
+
try { pi.sendMessage(message, { triggerTurn: true, deliverAs: "steer" }); }
|
|
105
|
+
catch (error) {
|
|
106
|
+
// Native Pi reports async send/run failures itself; this covers synchronous adapters.
|
|
107
|
+
for (const task of message.details.tasks) pending.set(task.id, task);
|
|
108
|
+
console.error(`Background completion notification failed: ${errorText(error)}`);
|
|
109
|
+
}
|
|
110
|
+
}, delay);
|
|
111
|
+
};
|
|
112
|
+
const deliverAtBoundary = (event: TurnEndEvent | AgentBeforeSettleEvent) => {
|
|
113
|
+
completedNormally = event.outcome === "completed";
|
|
114
|
+
if (event.outcome === "aborted") interrupted = true;
|
|
115
|
+
if (!completedNormally || interrupted) {
|
|
116
|
+
clearNotificationTimer();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const message = takeNotification();
|
|
120
|
+
if (message) return { entries: [...event.entries, { type: "custom_message" as const, ...message }], continue: true };
|
|
121
|
+
};
|
|
122
|
+
pi.on("turn_end", deliverAtBoundary);
|
|
123
|
+
pi.on("agent_before_settle", deliverAtBoundary);
|
|
124
|
+
pi.on("before_agent_start", (event) => {
|
|
125
|
+
if (event.messageType !== "user" || !interrupted) return;
|
|
126
|
+
// Only a new user prompt releases an interrupt, not another extension's custom wake.
|
|
127
|
+
interrupted = false;
|
|
128
|
+
const message = takeNotification();
|
|
129
|
+
if (message) return { message };
|
|
130
|
+
});
|
|
131
|
+
pi.on("agent_settled", (event) => {
|
|
132
|
+
if (event.aborted) {
|
|
133
|
+
interrupted = true;
|
|
134
|
+
clearNotificationTimer();
|
|
135
|
+
}
|
|
136
|
+
if (!event.aborted && completedNormally) scheduleNotification();
|
|
137
|
+
});
|
|
138
|
+
|
|
67
139
|
const clearTimer = () => {
|
|
68
140
|
if (timer !== undefined) clock.clearTimeout(timer);
|
|
69
141
|
timer = undefined;
|
|
@@ -107,6 +179,10 @@ export default function (pi: ExtensionAPI, dependencies: { operations?: BashOper
|
|
|
107
179
|
manager = undefined;
|
|
108
180
|
context = undefined;
|
|
109
181
|
clearTimer();
|
|
182
|
+
clearNotificationTimer();
|
|
183
|
+
pending.clear();
|
|
184
|
+
completedNormally = true;
|
|
185
|
+
interrupted = false;
|
|
110
186
|
try {
|
|
111
187
|
if (previousContext?.hasUI) {
|
|
112
188
|
previousContext.ui.setStatus(WIDGET_KEY, undefined);
|
|
@@ -124,10 +200,8 @@ export default function (pi: ExtensionAPI, dependencies: { operations?: BashOper
|
|
|
124
200
|
onChange() { if (generation === epoch) refresh(); },
|
|
125
201
|
onComplete(task) {
|
|
126
202
|
if (generation !== epoch || !context) return;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
content: `[Background task finished]\n${formatTask(task)}${task.reason ? `\nReason: ${task.reason}` : ""}\nCaptured log: ${task.log_path}${task.capture_complete ? "" : "\nOutput capture is incomplete."}`,
|
|
130
|
-
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
203
|
+
pending.set(task.id, task);
|
|
204
|
+
scheduleNotification();
|
|
131
205
|
},
|
|
132
206
|
});
|
|
133
207
|
});
|
|
@@ -139,8 +213,8 @@ export default function (pi: ExtensionAPI, dependencies: { operations?: BashOper
|
|
|
139
213
|
if (params.action === "start") {
|
|
140
214
|
const task = manager.start({ command: params.command!, description: params.description, cwd: params.cwd, timeout: params.timeout }, cwd);
|
|
141
215
|
return {
|
|
142
|
-
text: `Started ${formatTask(task)}\nLog: ${task.log_path}\nAt most 2 tasks run at once. Tasks stop on session changes, reload, or exit; captured logs are then removed.`,
|
|
143
|
-
data: { action: params.action, task }, details: { action: params.action, task }, isError: false,
|
|
216
|
+
text: `Started ${formatTask(task)}\n${startGuidance}\nLog: ${task.log_path}\nAt most 2 tasks run at once. Tasks stop on session changes, reload, or exit; captured logs are then removed.`,
|
|
217
|
+
data: { action: params.action, task, guidance: startGuidance }, details: { action: params.action, task }, isError: false,
|
|
144
218
|
};
|
|
145
219
|
}
|
|
146
220
|
if (params.action === "list") {
|
|
@@ -166,13 +240,19 @@ export default function (pi: ExtensionAPI, dependencies: { operations?: BashOper
|
|
|
166
240
|
|
|
167
241
|
pi.registerTool({
|
|
168
242
|
name: "background_task", label: "Background task",
|
|
169
|
-
description:
|
|
243
|
+
description: `Start shell commands as background tasks and return task IDs immediately. Supports status, output, and cancellation. After start: ${startGuidance}`,
|
|
170
244
|
parameters: Parameters, outputSchema: OutputSchema,
|
|
171
245
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
172
246
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
173
247
|
try {
|
|
174
248
|
if (signal?.aborted) throw new Error("Tool call aborted before task acceptance");
|
|
175
249
|
const result = await perform(params, ctx?.cwd ?? context?.cwd ?? process.cwd());
|
|
250
|
+
// A terminal result was consumed by this tool's caller (including Codemode).
|
|
251
|
+
// User /bg inspections and running snapshots do not acknowledge completion.
|
|
252
|
+
if (params.action === "output" && "task" in result.data && result.data.task.status !== "running") {
|
|
253
|
+
pending.delete(result.data.task.id);
|
|
254
|
+
if (pending.size === 0) clearNotificationTimer();
|
|
255
|
+
}
|
|
176
256
|
return { content: [{ type: "text" as const, text: result.text }], structuredContent: result.data, details: result.details, isError: result.isError };
|
|
177
257
|
} catch (error) {
|
|
178
258
|
const message = errorText(error);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-background-tasks",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Session-scoped background shell tasks with completion notifications for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"typebox": "*"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
|
-
"test": "node --experimental-strip-types --import ./tests/runtime.mjs --test tests/tasks.mjs tests/output.mjs tests/processes.mjs tests/extension.mjs",
|
|
37
|
+
"test": "node --experimental-strip-types --import ./tests/runtime.mjs --test tests/tasks.mjs tests/output.mjs tests/processes.mjs tests/extension.mjs tests/notifications.mjs tests/compaction.mjs tests/interruptions.mjs",
|
|
38
38
|
"load-test": "node --experimental-strip-types tests/load.mjs"
|
|
39
39
|
},
|
|
40
40
|
"publishConfig": {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import "./runtime.mjs";
|
|
9
|
+
|
|
10
|
+
const { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager } = await import("@earendil-works/pi-coding-agent");
|
|
11
|
+
const { createAssistantMessageEventStream } = await import("@earendil-works/pi-ai");
|
|
12
|
+
const { default: register } = await import(process.env.BG_TEST_EXTENSION
|
|
13
|
+
? pathToFileURL(process.env.BG_TEST_EXTENSION).href : new URL("../extensions/background-tasks.ts", import.meta.url).href);
|
|
14
|
+
|
|
15
|
+
async function waitFor(check, label) {
|
|
16
|
+
for (let i = 0; i < 1000; i++) {
|
|
17
|
+
if (check()) return;
|
|
18
|
+
await delay(10);
|
|
19
|
+
}
|
|
20
|
+
throw new Error(`Timed out waiting for ${label}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const outcome of ["success", "failure", "cancel"]) {
|
|
24
|
+
test(`real Pi manual compaction ${outcome} delivers a job completed during summarization once, without another user prompt`, { timeout: 20000 }, async (t) => {
|
|
25
|
+
const directory = mkdtempSync(join(tmpdir(), "pi-bg-compact-"));
|
|
26
|
+
const release = join(directory, "release");
|
|
27
|
+
const exited = join(directory, "exited");
|
|
28
|
+
const finishSummary = Promise.withResolvers();
|
|
29
|
+
const requests = [], lifecycle = [], events = [], errors = [];
|
|
30
|
+
let session, startedTask;
|
|
31
|
+
try {
|
|
32
|
+
const modelRuntime = await ModelRuntime.create({ authPath: join(directory, "auth.json"), modelsPath: join(directory, "models.json"), refreshOnCreate: false });
|
|
33
|
+
modelRuntime.registerProvider("bg-compaction", {
|
|
34
|
+
name: "Offline real-compaction fixture", baseUrl: "http://127.0.0.1:1", api: "bg-compaction", apiKey: "fixture-not-a-secret",
|
|
35
|
+
models: [{ id: "deterministic", name: "Offline fixture", reasoning: false, input: ["text"], contextWindow: 128000, maxTokens: 4096, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }],
|
|
36
|
+
streamSimple(model, context, options) {
|
|
37
|
+
const stream = createAssistantMessageEventStream();
|
|
38
|
+
const compacting = session.isCompacting;
|
|
39
|
+
requests.push({ compacting, messages: context.messages });
|
|
40
|
+
void (async () => {
|
|
41
|
+
const message = { role: "assistant", api: model.api, provider: model.provider, model: model.id, timestamp: Date.now(), content: [], stopReason: "stop",
|
|
42
|
+
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
|
|
43
|
+
try {
|
|
44
|
+
if (compacting) {
|
|
45
|
+
assert.ok(options.signal, "native summarization supplies its cancellation signal");
|
|
46
|
+
writeFileSync(release, "release background shell\n");
|
|
47
|
+
const aborted = () => finishSummary.resolve();
|
|
48
|
+
options.signal.addEventListener("abort", aborted, { once: true });
|
|
49
|
+
try { await finishSummary.promise; }
|
|
50
|
+
finally { options.signal.removeEventListener("abort", aborted); }
|
|
51
|
+
if (options.signal.aborted) {
|
|
52
|
+
message.stopReason = "aborted";
|
|
53
|
+
message.errorMessage = "Fixture summary aborted";
|
|
54
|
+
} else if (outcome === "failure") {
|
|
55
|
+
message.stopReason = "error";
|
|
56
|
+
message.errorMessage = "Fixture summary failure";
|
|
57
|
+
} else message.content.push({ type: "text", text: "## Goal\nVerify compaction delivery.\n## Progress\nA background shell remains owned by this session." });
|
|
58
|
+
} else {
|
|
59
|
+
const last = context.messages.at(-1);
|
|
60
|
+
const text = typeof last.content === "string" ? last.content : last.content.filter((block) => block.type === "text").map((block) => block.text).join("\n");
|
|
61
|
+
if (last.role === "user" && text.includes("START_COMPACTION_JOB")) {
|
|
62
|
+
message.stopReason = "toolUse";
|
|
63
|
+
message.content.push({ type: "toolCall", id: "compaction-start", name: "background_task", arguments: {
|
|
64
|
+
action: "start", command: "while [ ! -f release ]; do sleep 0.01; done; printf 'finished during compaction\\n'; printf done > exited; exit 7", description: "Finishes during native compaction", timeout: 10,
|
|
65
|
+
} });
|
|
66
|
+
} else message.content.push({ type: "text", text: text.includes("[Background task finished]") ? "COMPLETION_ACK" : "JOB_ACCEPTED" });
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
message.stopReason = "error";
|
|
70
|
+
message.errorMessage = String(error);
|
|
71
|
+
}
|
|
72
|
+
stream.push({ type: "start", partial: message });
|
|
73
|
+
if (["error", "aborted"].includes(message.stopReason)) stream.push({ type: "error", reason: message.stopReason, error: message });
|
|
74
|
+
else stream.push({ type: "done", reason: message.stopReason, message });
|
|
75
|
+
})();
|
|
76
|
+
return stream;
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false, keepRecentTokens: 128 }, retry: { enabled: false }, cacheWarming: "off" });
|
|
80
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
81
|
+
cwd: directory, agentDir: directory, settingsManager,
|
|
82
|
+
noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true,
|
|
83
|
+
extensionFactories: [register, (pi) => {
|
|
84
|
+
for (const type of ["session_before_compact", "session_compact", "session_compact_failed"]) pi.on(type, (event, ctx) => {
|
|
85
|
+
lifecycle.push({ type, reason: event.reason, aborted: event.aborted, fromExtension: event.fromExtension, idle: ctx.isIdle() });
|
|
86
|
+
});
|
|
87
|
+
}],
|
|
88
|
+
});
|
|
89
|
+
await resourceLoader.reload();
|
|
90
|
+
const created = await createAgentSession({ cwd: directory, agentDir: directory, modelRuntime, model: modelRuntime.getModel("bg-compaction", "deterministic"), thinkingLevel: "off", resourceLoader, settingsManager, sessionManager: SessionManager.inMemory(directory), tools: ["background_task"] });
|
|
91
|
+
session = created.session;
|
|
92
|
+
assert.deepEqual(created.extensionsResult.errors, []);
|
|
93
|
+
session.extensionRunner.onError((error) => errors.push(error));
|
|
94
|
+
session.subscribe((event) => {
|
|
95
|
+
events.push({ ...event, observedAt: Date.now() });
|
|
96
|
+
if (event.type === "tool_execution_end" && event.toolName === "background_task") startedTask = event.result.structuredContent.task;
|
|
97
|
+
});
|
|
98
|
+
await session.bindExtensions({});
|
|
99
|
+
await session.prompt("Earlier context to summarize: " + "background verification history ".repeat(200));
|
|
100
|
+
await session.prompt("START_COMPACTION_JOB");
|
|
101
|
+
assert.equal(startedTask?.status, "running");
|
|
102
|
+
assert.equal(session.isIdle, true);
|
|
103
|
+
const notifications = () => session.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === "background-task-finished");
|
|
104
|
+
const conversationRequests = () => requests.filter((request) => !request.compacting);
|
|
105
|
+
const before = conversationRequests().length;
|
|
106
|
+
// This is the same public manual-compaction entry point used by /compact and RPC.
|
|
107
|
+
const compacting = session.compact("COMPACTION_DELIVERY_FIXTURE").then((value) => ({ value }), (error) => ({ error }));
|
|
108
|
+
await waitFor(() => lifecycle.some((event) => event.type === "session_before_compact"), "native compaction start");
|
|
109
|
+
await waitFor(() => requests.some((request) => request.compacting), "native summarizer request");
|
|
110
|
+
await waitFor(() => existsSync(exited), "real background shell exit marker");
|
|
111
|
+
await delay(1250); // Let the real first retry run while native compaction is still busy.
|
|
112
|
+
assert.equal(session.isCompacting, true);
|
|
113
|
+
assert.equal(session.isIdle, false);
|
|
114
|
+
assert.equal(notifications().length, 0, "completion cannot interrupt native compaction");
|
|
115
|
+
assert.equal(conversationRequests().length, before, "busy retries must not start another conversation request");
|
|
116
|
+
if (outcome === "cancel") session.abortCompaction();
|
|
117
|
+
else finishSummary.resolve();
|
|
118
|
+
const result = await compacting;
|
|
119
|
+
if (outcome === "success") {
|
|
120
|
+
assert.equal(result.error, undefined);
|
|
121
|
+
assert.match(result.value.summary, /Verify compaction delivery/);
|
|
122
|
+
} else {
|
|
123
|
+
assert.ok(result.error, "native compaction must reject on failure or cancellation");
|
|
124
|
+
if (outcome === "failure") assert.match(result.error.message, /Fixture summary failure/);
|
|
125
|
+
}
|
|
126
|
+
assert.equal(session.isCompacting, false);
|
|
127
|
+
assert.deepEqual(lifecycle.map((event) => event.type), ["session_before_compact", outcome === "success" ? "session_compact" : "session_compact_failed"]);
|
|
128
|
+
assert.ok(lifecycle.every((event) => event.reason === "manual"));
|
|
129
|
+
assert.equal(lifecycle[1].fromExtension, false, "the fixture must not supply a replacement compaction result");
|
|
130
|
+
if (outcome !== "success") assert.equal(lifecycle[1].aborted, outcome === "cancel");
|
|
131
|
+
await waitFor(() => notifications().length > 0, "automatic completion delivery after compaction");
|
|
132
|
+
await session.waitForIdle();
|
|
133
|
+
await delay(50);
|
|
134
|
+
assert.equal(notifications().length, 1);
|
|
135
|
+
assert.equal(conversationRequests().length, before + 1, "no user prompt is submitted after compact()");
|
|
136
|
+
const notification = notifications()[0];
|
|
137
|
+
const task = notification.details.tasks[0];
|
|
138
|
+
assert.equal(task.id, startedTask.id);
|
|
139
|
+
assert.equal(task.status, "failed");
|
|
140
|
+
assert.equal(task.exit_code, 7);
|
|
141
|
+
assert.match(readFileSync(task.log_path, "utf8"), /finished during compaction/);
|
|
142
|
+
const start = events.find((event) => event.type === "compaction_start");
|
|
143
|
+
const end = events.find((event) => event.type === "compaction_end");
|
|
144
|
+
assert.ok(start && end, "native compaction lifecycle must actually run");
|
|
145
|
+
assert.ok(task.ended_at >= start.observedAt && task.ended_at <= end.observedAt, "the actual task completed during compaction, not beforehand");
|
|
146
|
+
assert.equal(end.aborted, outcome === "cancel");
|
|
147
|
+
assert.equal(session.sessionManager.getBranch().filter((entry) => entry.type === "compaction").length, outcome === "success" ? 1 : 0);
|
|
148
|
+
assert.equal(session.getLastAssistantText(), "COMPLETION_ACK");
|
|
149
|
+
assert.deepEqual(errors, []);
|
|
150
|
+
t.diagnostic(JSON.stringify({
|
|
151
|
+
outcome, lifecycle, taskCompletedDuringCompaction: true,
|
|
152
|
+
summaryRequests: requests.filter((request) => request.compacting).length,
|
|
153
|
+
automaticConversationRequests: conversationRequests().length - before,
|
|
154
|
+
completionEntries: notifications().length,
|
|
155
|
+
}));
|
|
156
|
+
} finally {
|
|
157
|
+
finishSummary.resolve();
|
|
158
|
+
if (session) {
|
|
159
|
+
session.abortCompaction();
|
|
160
|
+
await session.abort();
|
|
161
|
+
await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
|
|
162
|
+
session.dispose();
|
|
163
|
+
}
|
|
164
|
+
rmSync(directory, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|