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 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
- Guard Pi shell commands with [Destructive Command Guard (dcg)](https://github.com/Dicklesworthstone/destructive_command_guard) before they execute.
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 20.6 or newer
10
- - Pi 0.80 or newer
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 rather than executing a command dcg did not check.
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, visibly marking dcg unavailable; configurable to block |
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
- - blocks a command when its check is cancelled;
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, the package sends a best-effort install/update telemetry ping to `mocito.dev` 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.
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.
@@ -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 (!ctx.hasUI) return;
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, ctx.ui.theme.fg("success", `shield ${label}`));
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, ctx.ui.theme.fg("warning", `shield dcg unavailable (${behavior})`));
113
+ ctx.ui.setStatus(STATUS_KEY, `shield dcg unavailable (${behavior})`);
113
114
  return;
114
115
  }
115
- ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("muted", "shield dcg checking"));
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 (!ctx.hasUI) return;
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, ctx.signal);
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 { block: true, reason: "dcg check was cancelled; the command was not run." };
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
- // Pi executes this same input object after all tool_call handlers finish.
235
- // Seal the checked value so a later extension cannot replace it unchecked.
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.1.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": "^0.80.0",
57
- "@types/node": "^26.1.0",
58
- "tsx": "^4.23.0",
59
- "typescript": "^6.0.3"
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": ">=20.6.0"
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
+ }
@@ -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 INSTALL_TELEMETRY_URL = "https://mocito.dev/api/report-install";
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 isCiEnvironment(): boolean {
55
- if (isTruthyEnvFlag(process.env.CI)) return true;
56
- return CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(process.env[name]));
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(join(getAgentDir(), "settings.json")) as PiSettingsDocument;
65
- return settings.enableInstallTelemetry !== false;
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 getInstallTelemetryUserAgent(version: string): string {
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
- if (!isInstallTelemetryEnabled()) return;
82
-
83
- const version = getPackageVersion();
84
- const extensionsDir = join(getAgentDir(), "extensions");
85
- const statePath = join(extensionsDir, "pi-dcg-install.json");
86
- const state = readJsonFile(statePath) as InstallTelemetryState;
87
- if (state.lastReportedVersion === version) return;
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 settings, filesystem, and network failures.
75
+ // Best-effort telemetry: ignore local policy and filesystem failures.
99
76
  }
100
77
  }
101
-
102
- export function reportInstallTelemetry(): void {
103
- void reportInstallTelemetryAsync();
104
- }