pi-dcg 0.1.0 → 0.2.0
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 +25 -0
- package/CONTRIBUTING.md +21 -0
- package/README.md +31 -6
- package/SECURITY.md +14 -3
- package/extensions/index.ts +28 -12
- package/package.json +9 -6
- package/src/confirmation-queue.ts +41 -0
- package/src/install-telemetry.ts +19 -46
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,31 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.0] - 2026-10-09
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Keep footer labels palette-neutral and terminal-only, preserving RPC confirmations and notifications.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Update the shared Pi development and contract-test baseline to 1.1.0; require Node.js >=22.19.0 to match the host runtime. Pi remains a host-supplied peer dependency.
|
|
18
|
+
- Share install telemetry mechanics through `@mocito/install-telemetry` while preserving Pi-specific settings and state paths.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Let `enableInstallTelemetry: false` override an enabled `PI_TELEMETRY` environment flag.
|
|
23
|
+
- Cancel active and queued confirmations on turn abort or runtime shutdown, reject late approvals, and preserve cancellation blocking in both bridge-error modes.
|
|
24
|
+
- Serialize DCG confirmation dialogs so parallel bash calls cannot displace each other's prompt.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Real Pi 1.1.0 session contracts for direct, codemode, and custom nested bash calls, with mocked provider/process/shell boundaries, native TUI selector checks, approval composition, reload, and user `!`/`!!` coverage.
|
|
29
|
+
|
|
30
|
+
### Security
|
|
31
|
+
|
|
32
|
+
- Document the remaining Pi 1.1.0 input-object replacement and cross-extension dialog scheduling limitations; DCG does not replace the host executor or claim those upstream gaps are fixed.
|
|
33
|
+
|
|
9
34
|
## [0.1.0] - 2026-07-17
|
|
10
35
|
|
|
11
36
|
### Added
|
package/CONTRIBUTING.md
CHANGED
|
@@ -25,6 +25,27 @@ pi
|
|
|
25
25
|
|
|
26
26
|
Run `/dcg` to verify binary discovery. Exercise safe and destructive fixtures only through `dcg test` or a disposable sandbox; do not run genuinely destructive commands to test the bridge.
|
|
27
27
|
|
|
28
|
+
### Automated session and TUI smoke tests
|
|
29
|
+
|
|
30
|
+
From the repository root:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm run -w packages/pi-dcg test
|
|
34
|
+
node --import tsx --test packages/pi-dcg/tests/ui-contract.test.mjs
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The second command is the focused TUI smoke test. It drives Pi's actual selector rendering and keyboard handlers, plus the `!`/`!!` entry point, with a mock terminal. Every profile is temporary; provider requests, DCG process responses, and shell execution are mocked. No real credentials, user policy files, live model requests, or destructive commands are needed.
|
|
38
|
+
|
|
39
|
+
The session tests use the real extension factory, `DcgClient` parser, loader, event runner, tool registry, and codemode. A negative control without DCG proves that the shell-execution spy can be reached. Blocked calls must never reach it. Check nested execution-end events and parent IDs rather than assuming nested calls are transcript entries or that pre-execution blocks emit `tool_result`.
|
|
40
|
+
|
|
41
|
+
Private TUI method access is confined to `tests/ui-contract.test.mjs`; production code uses public extension APIs. These tests do not simulate every terminal emulator. For terminal-specific failures, use a disposable profile and harmless commands, confirm that parallel DCG prompts appear one at a time, then interrupt a pending prompt and check that it disappears without execution. Never change your normal Pi settings to perform the smoke test.
|
|
42
|
+
|
|
43
|
+
### Upstream follow-ups from #166
|
|
44
|
+
|
|
45
|
+
Pi 1.1.0 can detach `tool_call` event input from the actual execution arguments when an earlier handler replaces the object. It also lacks a shared queue for concurrent dialogs from different extensions. The tests that name these limitations deliberately characterize the current behavior; they do not certify it as safe.
|
|
46
|
+
|
|
47
|
+
An upstream fix should preserve input identity from the start of dispatch and coordinate terminal dialogs across extensions. Do not patch installed Pi files, replace bash, or weaken DCG policy to hide these gaps. When the host fixes them, update the shared baseline and replace the characterization assertions with the desired guarantees. Do not treat the local #166 work as closing those upstream requirements.
|
|
48
|
+
|
|
28
49
|
## Pull request checklist
|
|
29
50
|
|
|
30
51
|
- Run `npm run -w packages/pi-dcg check`.
|
package/README.md
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
# pi-dcg
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Stop destructive shell commands before they damage your system.
|
|
4
|
+
|
|
5
|
+
`pi-dcg` brings [Destructive Command Guard (dcg)](https://github.com/Dicklesworthstone/destructive_command_guard) policy checks into Pi, screening both agent-generated shell calls and your own `!` commands before execution.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Pre-execution protection** — block dangerous commands before Pi runs them.
|
|
10
|
+
- **Guard agent and user commands** — cover built-in `bash` tool calls plus `!command` and `!!command` invocations.
|
|
11
|
+
- **Actionable decisions** — surface matched rules and remediation while keeping hard denials non-overridable by the agent.
|
|
12
|
+
- **Your policy stays yours** — honor dcg's Pi-specific profiles, packs, allowlists, exceptions, and audit history.
|
|
13
|
+
- **Configurable failure posture** — choose fail-open convenience or fail-closed protection when dcg is unavailable.
|
|
4
14
|
|
|
5
15
|
`pi-dcg` is a Pi extension bridge. It does not bundle dcg, replace dcg policy, or provide a sandbox.
|
|
6
16
|
|
|
17
|
+
The TUI footer uses palette-neutral health labels, including the configured
|
|
18
|
+
blocking/fail-open state when unavailable. It does not retain colors from an old
|
|
19
|
+
theme. RPC confirmations and notifications remain separate from this terminal-only status.
|
|
20
|
+
|
|
7
21
|
## Requirements
|
|
8
22
|
|
|
9
|
-
- Node.js
|
|
10
|
-
- Pi
|
|
23
|
+
- Node.js 22.19.0 or newer
|
|
24
|
+
- Pi, supplied by the host; contract-tested against Pi 1.1.0
|
|
11
25
|
- A separately installed `dcg` executable; dcg 0.6.8 or newer is recommended
|
|
12
26
|
|
|
13
27
|
Install dcg using its [upstream installation instructions](https://github.com/Dicklesworthstone/destructive_command_guard#installation), review its release-verification guidance, and confirm that the binary is visible in the same environment as Pi:
|
|
@@ -40,24 +54,30 @@ pi -e /path/to/pi-mono/packages/pi-dcg
|
|
|
40
54
|
|
|
41
55
|
By default, the extension checks both Pi shell events available to extensions:
|
|
42
56
|
|
|
43
|
-
- agent calls to Pi's built-in `bash` tool
|
|
57
|
+
- agent calls to Pi's built-in `bash` tool, including nested calls through codemode (`on` and `only`) or `ctx.executeTool()`;
|
|
44
58
|
- user `!command` and `!!command` invocations.
|
|
45
59
|
|
|
46
60
|
Pi's separate RPC control-channel `{"type":"bash"}` command does not emit either event in current Pi releases and cannot be intercepted by `pi-dcg`; see [Limitations](#limitations).
|
|
47
61
|
|
|
48
62
|
For every non-empty command, the extension starts dcg directly without a shell, sends a Claude-compatible `PreToolUse` payload on stdin, and waits for dcg's decision before Pi executes the command.
|
|
49
63
|
|
|
50
|
-
Pi allows `tool_call` handlers to rewrite tool arguments in sequence. `pi-dcg` checks mutations made by earlier handlers, then seals both the approved `command` value and its input reference. If a later handler attempts to replace either one, Pi blocks the tool call
|
|
64
|
+
Pi allows `tool_call` handlers to rewrite tool arguments in sequence. `pi-dcg` checks **in-place** mutations made by earlier handlers, then seals both the approved `command` value and its input reference. If a later handler attempts to replace either one, Pi blocks the tool call. Pi 1.1.0 has a separate argument-identity limitation when an earlier handler replaces the entire input object; see [Limitations](#limitations).
|
|
65
|
+
|
|
66
|
+
DCG does not replace the bash executor, activate excluded tools, or bypass another extension's approval hooks.
|
|
51
67
|
|
|
52
68
|
| dcg response | Pi behavior |
|
|
53
69
|
| --- | --- |
|
|
54
70
|
| Empty stdout / explicit `allow` | Execute the command |
|
|
55
71
|
| `permissionDecision: "deny"` | Block and show bounded rule/remediation details |
|
|
56
72
|
| `permissionDecision: "ask"` | Ask for confirmation when UI is available; otherwise block |
|
|
57
|
-
| Bridge failure | Allow by default,
|
|
73
|
+
| Bridge failure | Allow by default, warning when UI is available; configurable to block |
|
|
58
74
|
|
|
59
75
|
Hard denials are never converted into one-click approvals. When dcg provides an allow-once code, `pi-dcg` shows the exact `dcg allow-once ...` command only in a user-facing UI notification. It is deliberately excluded from the model-visible blocked tool result so an agent cannot redeem the exception itself.
|
|
60
76
|
|
|
77
|
+
DCG confirmation dialogs run one at a time per extension instance, so parallel bash calls cannot replace each other's DCG prompt. Policy checks and allowed commands remain concurrent. Turn cancellation dismisses the active confirmation and blocks queued confirmations; runtime shutdown, including reload or session replacement, also cancels pending checks and confirmations. Cancellation blocks even with `PI_DCG_ON_ERROR=allow`, and a late dialog response cannot approve a cancelled call.
|
|
78
|
+
|
|
79
|
+
RPC extension UI can confirm `ask` decisions. Print/JSON sessions without UI block them. RPC **agent tool calls** are distinct from the excluded RPC control-channel `bash` command.
|
|
80
|
+
|
|
61
81
|
Run `/dcg` to probe the binary and show the active bridge configuration.
|
|
62
82
|
|
|
63
83
|
## Why this uses hook mode
|
|
@@ -130,6 +150,11 @@ This extension intercepts Pi events, not operating-system process execution. It
|
|
|
130
150
|
|
|
131
151
|
`user_bash` handlers are first-result-wins in Pi. An earlier extension that fully handles `!` commands can prevent later handlers, including `pi-dcg`, from seeing them.
|
|
132
152
|
|
|
153
|
+
Two Pi 1.1.0 composition limits remain:
|
|
154
|
+
|
|
155
|
+
- If an earlier `tool_call` handler assigns a new object to `event.input`, Pi can execute the original arguments while DCG checks the replacement. Integrations must mutate argument fields in place, not replace the input object. DCG cannot repair this host contract after the replacement has occurred.
|
|
156
|
+
- The confirmation queue covers this DCG instance, not other extensions' dialogs. Concurrent dialogs from another extension can still displace a prompt in Pi's shared editor slot. Cross-extension dialog scheduling requires host-level coordination.
|
|
157
|
+
|
|
133
158
|
Use a container, VM, sandbox, restricted credentials, backups, and review controls when a hard security boundary is required.
|
|
134
159
|
|
|
135
160
|
## Development
|
package/SECURITY.md
CHANGED
|
@@ -14,7 +14,7 @@ Do not open a public issue for a suspected vulnerability. Report privately throu
|
|
|
14
14
|
|
|
15
15
|
The bridge:
|
|
16
16
|
|
|
17
|
-
- intercepts Pi's built-in agent `bash` calls and, by default, user `!`/`!!` commands;
|
|
17
|
+
- intercepts Pi's built-in agent `bash` calls, including codemode and other `ctx.executeTool()` calls, and, by default, user `!`/`!!` commands;
|
|
18
18
|
- starts the configured dcg executable directly without a shell;
|
|
19
19
|
- sends command text to that local child process on stdin;
|
|
20
20
|
- sets the child cwd to Pi's current working directory;
|
|
@@ -23,7 +23,8 @@ The bridge:
|
|
|
23
23
|
- keeps allow-once commands out of model-visible denial results and shows them only through user-facing UI notifications;
|
|
24
24
|
- captures but does not log or forward dcg stderr;
|
|
25
25
|
- bounds child output and denial text;
|
|
26
|
-
-
|
|
26
|
+
- forwards turn/runtime cancellation to policy checks and confirmation dialogs, blocking even in fail-open mode;
|
|
27
|
+
- serializes this extension instance's confirmation dialogs without serializing ordinary policy checks or allowing approvals to carry over to another call;
|
|
27
28
|
- preserves hard dcg denials without a one-click bypass.
|
|
28
29
|
|
|
29
30
|
The child receives Pi's environment because dcg policy is intentionally configured through `DCG_*` variables. `pi-dcg` additionally sets `PI_CODING_AGENT=true`, `DCG_NO_SELF_HEAL=1`, and no-color flags for that child. Environment values are never logged or sent over the network by this package.
|
|
@@ -34,6 +35,16 @@ Bridge failures default to visible fail-open behavior to match dcg's integration
|
|
|
34
35
|
|
|
35
36
|
This setting cannot detect dcg's internal intentional fail-open paths, which may return a valid allow after size, parse, AST, or deadline fallback. Configure dcg itself for stricter analysis where supported.
|
|
36
37
|
|
|
38
|
+
Turn abort and runtime shutdown cancel active and queued DCG confirmations. Late UI responses cannot reverse cancellation. Print/JSON sessions without UI block `ask`; RPC clients can answer through the extension UI protocol. This does not extend coverage to RPC control-channel shell commands.
|
|
39
|
+
|
|
40
|
+
### Pi 1.1.0 composition limits
|
|
41
|
+
|
|
42
|
+
Command sealing protects against later `tool_call` mutations, not a replacement of the entire input object by an earlier handler. Pi retains the original execution arguments in that case, but DCG sees the replacement. An upstream argument-identity fix is needed before claiming that every possible hook composition executes exactly the checked command. Earlier handlers must mutate argument fields in place.
|
|
43
|
+
|
|
44
|
+
The confirmation queue is local to one DCG extension instance. Pi's terminal dialog slot is shared across extensions, and another extension's simultaneous dialog can still displace a pending prompt. This does not grant approval to the displaced call, but it can leave that call waiting until cancellation. General dialog scheduling must be fixed in Pi rather than by replacing the bash executor.
|
|
45
|
+
|
|
46
|
+
The real-session tests record these host limitations explicitly. They are not passing safety guarantees. No global fail-open switch, model-callable bypass, executor replacement, new credential flow, or additional command logging is introduced.
|
|
47
|
+
|
|
37
48
|
### Known bypasses
|
|
38
49
|
|
|
39
50
|
The extension cannot intercept arbitrary process creation. Important bypasses include:
|
|
@@ -54,4 +65,4 @@ Use least-privilege credentials, version control, backups, containers/VMs, and O
|
|
|
54
65
|
|
|
55
66
|
## Telemetry
|
|
56
67
|
|
|
57
|
-
On startup,
|
|
68
|
+
On startup, `@mocito/install-telemetry` sends a best-effort install/update telemetry ping to the configured telemetry endpoint once per package version unless disabled by CI, `PI_OFFLINE`, `PI_TELEMETRY`, or Pi's `enableInstallTelemetry` setting. The ping includes only package name/version and platform/runtime/architecture. It never includes commands, paths, dcg decisions, stderr, configuration, environment variables, prompts, credentials, or policy.
|
package/extensions/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type ExtensionContext,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { loadDcgBridgeConfig, type DcgBridgeConfig } from "../src/config.js";
|
|
7
|
+
import { ConfirmationQueue } from "../src/confirmation-queue.js";
|
|
7
8
|
import {
|
|
8
9
|
DcgClient,
|
|
9
10
|
DcgProcessError,
|
|
@@ -100,26 +101,26 @@ function setStatus(
|
|
|
100
101
|
config: DcgBridgeConfig,
|
|
101
102
|
version?: string,
|
|
102
103
|
): void {
|
|
103
|
-
if (
|
|
104
|
+
if (ctx.mode !== "tui") return;
|
|
104
105
|
try {
|
|
105
106
|
if (health === "active") {
|
|
106
107
|
const label = version ? `dcg ${version}` : "dcg active";
|
|
107
|
-
ctx.ui.setStatus(STATUS_KEY,
|
|
108
|
+
ctx.ui.setStatus(STATUS_KEY, `shield ${label}`);
|
|
108
109
|
return;
|
|
109
110
|
}
|
|
110
111
|
if (health === "degraded") {
|
|
111
112
|
const behavior = config.onError === "block" ? "blocking" : "fail-open";
|
|
112
|
-
ctx.ui.setStatus(STATUS_KEY,
|
|
113
|
+
ctx.ui.setStatus(STATUS_KEY, `shield dcg unavailable (${behavior})`);
|
|
113
114
|
return;
|
|
114
115
|
}
|
|
115
|
-
ctx.ui.setStatus(STATUS_KEY,
|
|
116
|
+
ctx.ui.setStatus(STATUS_KEY, "shield dcg checking");
|
|
116
117
|
} catch {
|
|
117
118
|
// Status rendering is advisory and must never alter a dcg decision.
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
function clearStatus(ctx: ExtensionContext): void {
|
|
122
|
-
if (
|
|
123
|
+
if (ctx.mode !== "tui") return;
|
|
123
124
|
try {
|
|
124
125
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
125
126
|
} catch {
|
|
@@ -135,6 +136,8 @@ export default function piDcg(
|
|
|
135
136
|
|
|
136
137
|
const config = dependencies.config ?? loadDcgBridgeConfig();
|
|
137
138
|
const client = dependencies.client ?? new DcgClient(config);
|
|
139
|
+
const confirmations = new ConfirmationQueue();
|
|
140
|
+
const lifetime = new AbortController();
|
|
138
141
|
let version: string | undefined;
|
|
139
142
|
let lastNotifiedError: string | undefined;
|
|
140
143
|
let warnedAboutVersion = false;
|
|
@@ -160,15 +163,24 @@ export default function piDcg(
|
|
|
160
163
|
cwd: string,
|
|
161
164
|
ctx: ExtensionContext,
|
|
162
165
|
): Promise<GuardOutcome> => {
|
|
166
|
+
// Capture once: ctx.signal is a live getter and may change when a run ends.
|
|
167
|
+
const turnSignal = ctx.signal;
|
|
168
|
+
const signal = turnSignal ? AbortSignal.any([turnSignal, lifetime.signal]) : lifetime.signal;
|
|
169
|
+
const cancelled = (): GuardOutcome => ({
|
|
170
|
+
block: true,
|
|
171
|
+
reason: "dcg check or confirmation was cancelled; the command was not run.",
|
|
172
|
+
});
|
|
173
|
+
if (signal.aborted) return cancelled();
|
|
163
174
|
if (!command.trim()) return { block: false };
|
|
164
175
|
|
|
165
176
|
let result;
|
|
166
177
|
try {
|
|
167
|
-
result = await client.check(command, cwd,
|
|
178
|
+
result = await client.check(command, cwd, signal);
|
|
179
|
+
if (signal.aborted) return cancelled();
|
|
168
180
|
markHealthy(ctx);
|
|
169
181
|
} catch (error) {
|
|
170
|
-
if (error instanceof DcgProcessError && error.code === "aborted") {
|
|
171
|
-
return
|
|
182
|
+
if (signal.aborted || (error instanceof DcgProcessError && error.code === "aborted")) {
|
|
183
|
+
return cancelled();
|
|
172
184
|
}
|
|
173
185
|
markDegraded(ctx, error);
|
|
174
186
|
if (config.onError === "block") {
|
|
@@ -196,13 +208,16 @@ export default function piDcg(
|
|
|
196
208
|
|
|
197
209
|
let approved = false;
|
|
198
210
|
try {
|
|
199
|
-
approved = await ctx.ui.confirm(
|
|
211
|
+
approved = await confirmations.confirm(signal, () => ctx.ui.confirm(
|
|
200
212
|
"dcg requires confirmation",
|
|
201
213
|
`Command:\n${truncate(command, MAX_COMMAND_PREVIEW_CHARS)}\n\n${reason}`,
|
|
202
|
-
|
|
214
|
+
{ signal },
|
|
215
|
+
));
|
|
203
216
|
} catch {
|
|
217
|
+
if (signal.aborted) return cancelled();
|
|
204
218
|
return { block: true, reason: `${reason}\n\nThe confirmation dialog failed, so the command was blocked.` };
|
|
205
219
|
}
|
|
220
|
+
if (signal.aborted) return cancelled();
|
|
206
221
|
return approved ? { block: false } : { block: true, reason: `${reason}\n\nThe command was not approved.` };
|
|
207
222
|
};
|
|
208
223
|
|
|
@@ -231,8 +246,8 @@ export default function piDcg(
|
|
|
231
246
|
const outcome = await guard(command, ctx.cwd, ctx);
|
|
232
247
|
if (outcome.block) return { block: true, reason: outcome.reason };
|
|
233
248
|
|
|
234
|
-
//
|
|
235
|
-
//
|
|
249
|
+
// Seal against later mutations. Pi 1.1.0 cannot reconcile an input reference
|
|
250
|
+
// replaced by an earlier handler; see the documented upstream limitation.
|
|
236
251
|
sealCheckedBashCommand(event, command);
|
|
237
252
|
return undefined;
|
|
238
253
|
});
|
|
@@ -277,6 +292,7 @@ export default function piDcg(
|
|
|
277
292
|
});
|
|
278
293
|
|
|
279
294
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
295
|
+
lifetime.abort();
|
|
280
296
|
clearStatus(ctx);
|
|
281
297
|
});
|
|
282
298
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-dcg",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Guard Pi shell commands with Destructive Command Guard.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -53,15 +53,18 @@
|
|
|
53
53
|
"@earendil-works/pi-coding-agent": "*"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@earendil-works/pi-coding-agent": "
|
|
57
|
-
"@types/node": "^26.
|
|
58
|
-
"tsx": "^4.23.
|
|
59
|
-
"typescript": "^
|
|
56
|
+
"@earendil-works/pi-coding-agent": "1.1.0",
|
|
57
|
+
"@types/node": "^26.6.3",
|
|
58
|
+
"tsx": "^4.23.15",
|
|
59
|
+
"typescript": "^7.0.2"
|
|
60
60
|
},
|
|
61
61
|
"publishConfig": {
|
|
62
62
|
"access": "public"
|
|
63
63
|
},
|
|
64
64
|
"engines": {
|
|
65
|
-
"node": ">=
|
|
65
|
+
"node": ">=22.19.0"
|
|
66
|
+
},
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"@mocito/install-telemetry": "0.1.1"
|
|
66
69
|
}
|
|
67
70
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Wait without retaining an abort listener or accepting a late UI response. */
|
|
2
|
+
function abortable<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
3
|
+
return new Promise<T>((resolve, reject) => {
|
|
4
|
+
const onAbort = (): void => reject(signal.reason);
|
|
5
|
+
const cleanup = (): void => signal.removeEventListener("abort", onAbort);
|
|
6
|
+
// Observe both outcomes even when cancellation wins the race.
|
|
7
|
+
pending.then(
|
|
8
|
+
(value) => { cleanup(); resolve(value); },
|
|
9
|
+
(error: unknown) => { cleanup(); reject(error); },
|
|
10
|
+
);
|
|
11
|
+
if (signal.aborted) {
|
|
12
|
+
onAbort();
|
|
13
|
+
} else {
|
|
14
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Pi's terminal dialogs share one editor slot. Queue only this bridge's prompts. */
|
|
20
|
+
export class ConfirmationQueue {
|
|
21
|
+
private tail: Promise<void> = Promise.resolve();
|
|
22
|
+
|
|
23
|
+
async confirm(signal: AbortSignal, show: () => Promise<boolean>): Promise<boolean> {
|
|
24
|
+
const previous = this.tail;
|
|
25
|
+
let release!: () => void;
|
|
26
|
+
const current = new Promise<void>((resolve) => { release = resolve; });
|
|
27
|
+
// A cancelled waiter must not let its successors overtake the current dialog.
|
|
28
|
+
this.tail = previous.then(() => current);
|
|
29
|
+
try {
|
|
30
|
+
await abortable(previous, signal);
|
|
31
|
+
signal.throwIfAborted();
|
|
32
|
+
const approved = await abortable(Promise.resolve().then(() => {
|
|
33
|
+
signal.throwIfAborted();
|
|
34
|
+
return show();
|
|
35
|
+
}), signal);
|
|
36
|
+
return !signal.aborted && approved === true;
|
|
37
|
+
} finally {
|
|
38
|
+
release();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/install-telemetry.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { reportInstallTelemetry as report } from "@mocito/install-telemetry";
|
|
5
5
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
7
|
const PACKAGE_NAME = "pi-dcg";
|
|
8
|
-
const
|
|
9
|
-
const INSTALL_TELEMETRY_TIMEOUT_MS = 5000;
|
|
8
|
+
const INSTALL_TELEMETRY_ENDPOINT = "https://mocito.dev/api/report-install";
|
|
10
9
|
const CI_ENVIRONMENT_VARIABLES = [
|
|
11
10
|
"APPVEYOR",
|
|
12
11
|
"BITBUCKET_BUILD_NUMBER",
|
|
@@ -24,10 +23,6 @@ const CI_ENVIRONMENT_VARIABLES = [
|
|
|
24
23
|
"VERCEL",
|
|
25
24
|
];
|
|
26
25
|
|
|
27
|
-
interface InstallTelemetryState {
|
|
28
|
-
lastReportedVersion?: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
26
|
interface PiSettingsDocument {
|
|
32
27
|
enableInstallTelemetry?: unknown;
|
|
33
28
|
}
|
|
@@ -51,18 +46,15 @@ function isPresentEnvFlag(value: string | undefined): boolean {
|
|
|
51
46
|
return normalized !== "0" && normalized !== "false" && normalized !== "no";
|
|
52
47
|
}
|
|
53
48
|
|
|
54
|
-
function
|
|
55
|
-
if (isTruthyEnvFlag(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
function isInstallTelemetryEnabled(): boolean {
|
|
60
|
-
if (isCiEnvironment()) return false;
|
|
61
|
-
if (isTruthyEnvFlag(process.env.PI_OFFLINE)) return false;
|
|
62
|
-
if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
|
|
49
|
+
export function isInstallTelemetryEnabled(env: NodeJS.ProcessEnv = process.env, settingsPath = join(getAgentDir(), "settings.json")): boolean {
|
|
50
|
+
if (isTruthyEnvFlag(env.CI)) return false;
|
|
51
|
+
if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(env[name]))) return false;
|
|
52
|
+
if (isTruthyEnvFlag(env.PI_OFFLINE)) return false;
|
|
63
53
|
|
|
64
|
-
const settings = readJsonFile(
|
|
65
|
-
|
|
54
|
+
const settings = readJsonFile(settingsPath) as PiSettingsDocument;
|
|
55
|
+
if (settings.enableInstallTelemetry === false) return false;
|
|
56
|
+
if (env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(env.PI_TELEMETRY);
|
|
57
|
+
return true;
|
|
66
58
|
}
|
|
67
59
|
|
|
68
60
|
function getPackageVersion(): string {
|
|
@@ -70,35 +62,16 @@ function getPackageVersion(): string {
|
|
|
70
62
|
return typeof packageJson.version === "string" && packageJson.version.length > 0 ? packageJson.version : "0.0.0";
|
|
71
63
|
}
|
|
72
64
|
|
|
73
|
-
function
|
|
74
|
-
const runtimeVersions = process.versions as NodeJS.ProcessVersions & { bun?: string };
|
|
75
|
-
const runtime = runtimeVersions.bun ? `bun/${runtimeVersions.bun}` : `node/${process.version}`;
|
|
76
|
-
return `${PACKAGE_NAME}/${version} (${process.platform}; ${runtime}; ${process.arch})`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function reportInstallTelemetryAsync(): Promise<void> {
|
|
65
|
+
export function reportInstallTelemetry(): void {
|
|
80
66
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
await mkdir(extensionsDir, { recursive: true });
|
|
90
|
-
await writeFile(statePath, `${JSON.stringify({ lastReportedVersion: version }, null, 2)}\n`, "utf8");
|
|
91
|
-
|
|
92
|
-
const params = new URLSearchParams({ tool: PACKAGE_NAME, version });
|
|
93
|
-
await fetch(`${INSTALL_TELEMETRY_URL}?${params.toString()}`, {
|
|
94
|
-
headers: { "User-Agent": getInstallTelemetryUserAgent(version) },
|
|
95
|
-
signal: AbortSignal.timeout(INSTALL_TELEMETRY_TIMEOUT_MS),
|
|
96
|
-
});
|
|
67
|
+
void report({
|
|
68
|
+
endpoint: INSTALL_TELEMETRY_ENDPOINT,
|
|
69
|
+
tool: PACKAGE_NAME,
|
|
70
|
+
version: getPackageVersion(),
|
|
71
|
+
statePath: join(getAgentDir(), "extensions", "pi-dcg-install.json"),
|
|
72
|
+
enabled: isInstallTelemetryEnabled(),
|
|
73
|
+
}).catch(() => undefined);
|
|
97
74
|
} catch {
|
|
98
|
-
// Best-effort telemetry: ignore
|
|
75
|
+
// Best-effort telemetry: ignore local policy and filesystem failures.
|
|
99
76
|
}
|
|
100
77
|
}
|
|
101
|
-
|
|
102
|
-
export function reportInstallTelemetry(): void {
|
|
103
|
-
void reportInstallTelemetryAsync();
|
|
104
|
-
}
|