impel-cli 0.20.45 → 0.20.46-beta.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/README.md CHANGED
@@ -525,15 +525,148 @@ Main paths:
525
525
  ~/.config/impel/config.json
526
526
  ~/.config/impel/cli/tenants/<tenant>/
527
527
  ~/.config/impel/apps/tenants/<tenant>/
528
+ ~/.config/impel/update-check.json
529
+ ~/.config/impel/flags.json
530
+ ~/.config/impel/telemetry-notice.json
531
+ ~/.config/impel/telemetry/
532
+ ~/.config/impel/reports/
528
533
  ```
529
534
 
530
535
  The config directory is owner-only and the config file is written with mode
531
536
  `0600` where supported. Generated client profiles use tenant-bound helper
532
537
  invocations; PAT material is not embedded into shell entries.
533
538
 
539
+ `flags.json` caches server-evaluated feature flags for the current principal.
540
+ `telemetry/` holds queued analytics events waiting to be sent, plus their
541
+ send state; it is empty unless analytics are on. `reports/` holds bug reports
542
+ that could not be delivered, kept so they are never lost, bounded to the last
543
+ 50 and nothing older than 30 days; its hidden `.last-auto-report.json` records
544
+ what this machine last reported automatically, so the same failure is not
545
+ reported twice in a day. Everything above lives under one directory, so
546
+ `impel nuke` erases all of it.
547
+
534
548
  Losing membership does not delete local tenant data. A future cleanup feature
535
549
  must remain explicit and destructive-state aware.
536
550
 
551
+ ## Privacy
552
+
553
+ Analytics are off unless you turn them on: nothing in the first table below is
554
+ sent until `telemetry.enabled` is `true` in `config.json`, which only
555
+ `impel setup --analytics on` writes.
556
+
557
+ Failure reports are the one exception and are described in their own section
558
+ further down. When `impel setup` or `impel update` runs out of ways to fix a
559
+ failure, the CLI files a bug report about that failure whether or not analytics
560
+ are on. That is deliberate: a broken install cannot be fixed by people who never
561
+ hear about it, and the machines least likely to opt in are the ones failing.
562
+
563
+ **Usage analytics** (`cli_command_run`, one per command, sent when enabled):
564
+
565
+ | Field | Example | Notes |
566
+ |---|---|---|
567
+ | `command` | `claude` | The command name only — never its arguments |
568
+ | `outcome` | `success` | `success`, `failure`, or `refused` |
569
+ | `durationBucket` | `lt_5s` | A coarse bucket, not a duration |
570
+ | `cliVersion` | `0.20.41` | |
571
+ | `channel` | `latest` | npm release channel |
572
+ | `platform` | `darwin` | `darwin`, `linux`, `win32`, or `unknown` |
573
+
574
+ **Bug reports** (`cli_bug_report`, one per delivered report): the same
575
+ `cliVersion`, `channel`, and `platform`, plus whether a message was attached,
576
+ whether a task was filed, and if not, why.
577
+
578
+ Events are identified by the account your PAT resolves to, stamped by the
579
+ server. Nothing else is collected: no command arguments, file contents,
580
+ prompts, session content, environment variables, paths, or credentials. Events
581
+ are validated against a closed allowlist before they reach disk, and anything
582
+ that still looks like a secret after redaction is dropped locally and reported
583
+ by `impel status`.
584
+
585
+ Events are queued on disk and sent by a detached background process, so no
586
+ command ever waits on the network. Analytics never initialize inside a managed
587
+ desktop app profile or a non-interactive surface.
588
+
589
+ **Turning analytics off.** Any one of these is enough, and each overrides a
590
+ stored `yes`:
591
+
592
+ ```sh
593
+ IMPEL_DISABLE_TELEMETRY=1 impel claude # also accepts true/yes
594
+ DO_NOT_TRACK=1 impel claude
595
+ ```
596
+
597
+ Any non-empty `CI` value suppresses analytics as well. To change the stored
598
+ answer, run `impel setup --analytics off`. To erase everything already queued,
599
+ run `impel nuke`.
600
+
601
+ ## Automatic failure reports
602
+
603
+ When `impel setup` or `impel update` fails in a way it cannot recover from, the
604
+ CLI files a bug report about that failure and prints one line saying so, with
605
+ the report's id and how to turn the behavior off. It does not ask first.
606
+
607
+ **What triggers one.** Only a terminal failure of `impel setup` or `impel
608
+ update` — a failure that recovery already tried and could not repair. A failure
609
+ that recovery fixes is not a failure, and reports nothing. No other command
610
+ reports itself, and a successful run never reports.
611
+
612
+ **What is in it.** The same fields `impel report` sends, assembled from what the
613
+ command already had in hand:
614
+
615
+ | Field | Example | Notes |
616
+ |---|---|---|
617
+ | `message` | `The npm-verified global impel-cli update failed.` | Written by the CLI, not by you |
618
+ | `cliVersion` | `0.20.41` | |
619
+ | `nodeVersion` | `v22.11.0` | |
620
+ | `platform` | `darwin` | |
621
+ | `architecture` | `arm64` | |
622
+ | `channel` | `latest` | npm release channel |
623
+ | `command` | `npm install --global impel-cli` | The command that failed, when there was one |
624
+ | `exitCode` | `1` | |
625
+ | `errorCode` | `install_failed` | |
626
+ | `stderr` | | That command's output, redacted |
627
+ | `diagnostics` | `{ "step": "install.impel_cli", "reportId": "…" }` | The failing step, the report id, and a handful of scalars naming what the run was doing |
628
+
629
+ Credentials, home directory paths, and email addresses are removed before
630
+ anything leaves the machine, by the same redaction the manual command uses.
631
+ Nothing else is collected: no arguments beyond the failing command line, no file
632
+ contents, no prompts, no session content, no environment variables.
633
+
634
+ **Turning it off.** Either of these, and only these:
635
+
636
+ ```sh
637
+ IMPEL_DO_NOT_TRACK=1 impel setup
638
+ IMPEL_CI=1 impel setup
639
+ ```
640
+
641
+ Bare `DO_NOT_TRACK` and bare `CI` **do not** suppress failure reports, though
642
+ they still suppress analytics. The two surfaces have different contracts on
643
+ purpose: `CI` is set by every CI runner on earth and says nothing about whether
644
+ its operators want to hear that installs are broken there, and deciding that for
645
+ them is how a broken install stays broken. Suppressing a failure report takes
646
+ the branded name, typed on purpose.
647
+
648
+ The consequence is worth stating plainly: a GitHub Actions job sets `CI=true`,
649
+ not `IMPEL_CI`, so a repeatedly failing `impel setup` in an unmodified CI
650
+ pipeline files a report every run until someone sets `IMPEL_CI=1`.
651
+
652
+ Reports are also suppressed when the CLI is a rebranded distribution, when it
653
+ runs inside a managed desktop app profile, and when there is no PAT to attribute
654
+ the report to.
655
+
656
+ **The same failure is not reported twice in a day.** The report id is a
657
+ fingerprint of the report's own contents, so an identical failure produces an
658
+ identical id; `~/.config/impel/reports/.last-auto-report.json` records what was
659
+ reported and when, and a repeat inside 24 hours is dropped silently.
660
+
661
+ **Delivery is best effort and never blocks.** Two retries against an unreachable
662
+ control plane, then the report is written to `~/.config/impel/reports/` like any
663
+ other undelivered report. A reporting failure never changes the exit code of the
664
+ command that failed, and never adds a second error message to the one you
665
+ already got.
666
+
667
+ `impel report` remains a message you choose to send: neither switch above
668
+ disables it, and it works whether or not analytics are on.
669
+
537
670
  ## Public command surface
538
671
 
539
672
  ```text
@@ -548,6 +681,7 @@ impel tasks ...
548
681
  impel pat ...
549
682
  impel status
550
683
  impel doctor
684
+ impel report
551
685
  ```
552
686
 
553
687
  Profile helpers, content synchronization, desktop maintenance, and transport
@@ -1,6 +1,6 @@
1
1
  # Native-agent host capability matrix
2
2
 
3
- Validated on 2026-08-09 with isolated temporary homes and tenant profiles. The
3
+ Validated on 2026-08-11 with isolated temporary homes and tenant profiles. The
4
4
  matrix is intentionally pinned: a client upgrade must be re-qualified before it
5
5
  is treated as an eager host.
6
6
 
@@ -9,7 +9,7 @@ is treated as an eager host.
9
9
  | Claude Code | 2.1.220 | Yes, agent frontmatter `tools` | Eager; optional single-thread selection | The bound run/answer, resume, and recovery tools are direct child tools. `impel claude --agent` verifies the exact managed adapter before enabling 2.1.220's bypass-permission opt-in and mode. |
10
10
  | Claude Desktop | 1.26832.0 (embedded Code 2.1.222) | Yes, agent frontmatter `tools` | Eager | Same direct-tool contract as Claude Code. |
11
11
  | Codex CLI through `impel codex` | 0.147.0 | Yes, with `features.code_mode.direct_only_tool_namespaces = ["mcp__impel_agent"]` | Eager; optional single-thread profile | Existing `@Agent` children call the fixed MCP namespace directly. `impel codex --agent` also removes the parent spawn/wait/relay thread. |
12
- | ChatGPT desktop Codex | 26.803.41515 (embedded Codex 0.147.0-alpha.6.5) | Yes, with the same exact native namespace | Eager child, compatible parent | Keeps the app's supported `@Agent` parent/child route, but the child no longer needs code cells or code-mode waits. |
12
+ | ChatGPT desktop Codex | 26.803.81509 (embedded Codex 0.147.0-alpha.6.6) | Yes, with the same exact native namespace | Eager child, compatible parent | Keeps the app's supported `@Agent` parent/child route, but the child no longer needs code cells or code-mode waits. |
13
13
 
14
14
  ## Qualified contract
15
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.45",
3
+ "version": "0.20.46-beta.0",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -107,8 +107,8 @@ export const PINNED_VENDOR_APPS = Object.freeze({
107
107
  }),
108
108
  }),
109
109
  chatgpt: Object.freeze({
110
- version: "26.803.41515",
111
- codexVersion: "0.147.0-alpha.6.5",
110
+ version: "26.803.81509",
111
+ codexVersion: "0.147.0-alpha.6.6",
112
112
  bundleName: "ChatGPT.app",
113
113
  windows: Object.freeze({
114
114
  storeProductId: "9PLM9XGG6VKS",
@@ -116,20 +116,20 @@ export const PINNED_VENDOR_APPS = Object.freeze({
116
116
  // The Microsoft Store manifest identifies this exact reviewed build.
117
117
  // Keep one Windows package version so the manifest contract and local
118
118
  // AppX validation cannot silently drift apart again.
119
- packageVersion: "26.803.5235.0",
120
- codexVersion: "0.147.0-alpha.6.5",
119
+ packageVersion: "26.803.10989.0",
120
+ codexVersion: "0.147.0-alpha.6.6",
121
121
  publisherId: "2p2nqsd0c76g0",
122
122
  executable: "app\\ChatGPT.exe",
123
123
  updateManifestUrl: "https://persistent.oaistatic.com/codex-app-prod/windows-store-update.json",
124
124
  }),
125
125
  downloads: Object.freeze({
126
126
  arm64: Object.freeze({
127
- url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-arm64-26.803.41515.zip",
128
- sha256: "8abd46bf063bc27cbadcbc2863007ac44365b13a23ede17ceaee81fb9eeaeb9a",
127
+ url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-arm64-26.803.81509.zip",
128
+ sha256: "34c7e629678ad5d63a639ec694a5b83b85f4e1bd7c21ee2abcfefbb041095b9c",
129
129
  }),
130
130
  x64: Object.freeze({
131
- url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-x64-26.803.41515.zip",
132
- sha256: "87239a3dd12e2761de515ee78a0b73c02ac907c34c1f01a68dd6f093cf433fb1",
131
+ url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-x64-26.803.81509.zip",
132
+ sha256: "c9d5a14803c3138a77be5e891a4944c04ff421645150043dd3eddd0bf34eda97",
133
133
  }),
134
134
  }),
135
135
  }),
@@ -0,0 +1,289 @@
1
+ // The automatic bug-report path: a failed `impel setup` or `impel update`
2
+ // reporting itself.
3
+ //
4
+ // The manual command in `src/commands/report.js` has a person attached — they
5
+ // typed it, they are watching, and an error they can read is a useful outcome.
6
+ // This module has none of that. It runs inside a command that has already
7
+ // failed, on a machine whose owner may have walked away, and the only two
8
+ // things it owes anyone are the disclosure line and a promise never to make the
9
+ // failure worse. Everything below follows from that asymmetry:
10
+ //
11
+ // - Every gate returns silently. There is no "reporting was skipped because…"
12
+ // sentence, because nobody asked for a report and a refusal is not news.
13
+ // - The whole body is wrapped in a `try`/`catch` that swallows (KTD10). A
14
+ // throw from here would surface as a crash inside a command that had
15
+ // already decided how it wanted to fail.
16
+ // - `process.exitCode` is never written. The failing command owns its exit
17
+ // code; the report is a side effect, not an outcome.
18
+ //
19
+ // What is *sent* is identical to the manual path — same `buildEnvelope`, same
20
+ // origin check, same spool — because "the automatic sender cannot send
21
+ // something the manual sender would not" has to be a property of the code
22
+ // rather than a habit. This file only decides *whether* to send.
23
+ //
24
+ // The suppression marker lives under `CONFIG_DIR`, so `impel nuke` erases it.
25
+
26
+ import fs from "node:fs";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+
30
+ import {
31
+ buildEnvelope,
32
+ deliverWithRetry,
33
+ MAX_DIAGNOSTIC_KEYS,
34
+ REPORT_SPOOL_DIR,
35
+ resolveReportOrigin,
36
+ sleep,
37
+ spoolReport,
38
+ writeJsonAtomically,
39
+ } from "./bugReport.js";
40
+ import { resolveDefaultAppUrl } from "./config.js";
41
+ import { fetchHttp1 } from "./http1.js";
42
+ import { sanitizeInstallFailureEnvelope } from "./installRecovery/redact.js";
43
+ import {
44
+ DO_NOT_TRACK_ENV,
45
+ firstPartyCli,
46
+ managedMarkerPresent,
47
+ reportEnvOptOut,
48
+ } from "./telemetryConsent.js";
49
+
50
+ /**
51
+ * Shorter than `impel report`'s 10 s: this timeout is spent by a command that
52
+ * has already failed and wants to exit. Three attempts at 5 s plus 500/1,500 ms
53
+ * of backoff is ~17 s worst case against a black hole (KTD5).
54
+ */
55
+ export const AUTO_REPORT_TIMEOUT_MS = 5_000;
56
+
57
+ /** R4's record of what this machine has already reported. */
58
+ export const AUTO_REPORT_MARKER_NAME = ".last-auto-report.json";
59
+
60
+ /**
61
+ * The marker sits beside the spooled reports rather than in its own directory,
62
+ * so one `impel nuke` erases both and a test that redirects `spoolDir`
63
+ * redirects the marker with it.
64
+ */
65
+ export function autoReportMarkerPath(spoolDir = REPORT_SPOOL_DIR) {
66
+ return path.join(spoolDir, AUTO_REPORT_MARKER_NAME);
67
+ }
68
+
69
+ /** R4's window. A failure that recurs the next day is worth hearing about again. */
70
+ export const AUTO_REPORT_SUPPRESSION_MS = 24 * 60 * 60 * 1_000;
71
+
72
+ /**
73
+ * A list, not a single slot (KTD4). One machine reaches more than one failure
74
+ * mode, and two alternating failures sharing a slot would evict each other's
75
+ * record so neither is ever suppressed — the exact case automatic dispatch
76
+ * makes common.
77
+ */
78
+ export const AUTO_REPORT_MARKER_ENTRIES = 32;
79
+
80
+ /**
81
+ * `buildEnvelope` adds `tenant` and `reportId` of its own, and this mapping adds
82
+ * four more, so the failure's own diagnostics are trimmed to leave room.
83
+ * Exceeding `MAX_DIAGNOSTIC_KEYS` throws, and a report lost to a crowded
84
+ * diagnostics bag is a worse outcome than a report missing its 19th diagnostic.
85
+ */
86
+ const RESERVED_DIAGNOSTIC_KEYS = 6;
87
+
88
+ /* -------------------------------------------------------------------------- */
89
+ /* Envelope */
90
+ /* -------------------------------------------------------------------------- */
91
+
92
+ /**
93
+ * Fold the sanitized install-failure envelope into the bug-report field bag.
94
+ *
95
+ * The two shapes are close enough to look interchangeable and are not.
96
+ * `bugReportEnvelopeSchema` is `.strict()`, so `scope`, `tenantId`, `step`, and
97
+ * `signal` have no top-level home and must travel inside `diagnostics`. Each
98
+ * mismatch left uncorrected is a 400, not a dropped field.
99
+ *
100
+ * The wire's own bounds are not re-applied here: `buildEnvelope` already
101
+ * truncates each field to its schema length and drops a falsy one rather than
102
+ * sending it, which is the same answer the sanitizer's `null`s want. A second
103
+ * copy of `64` in this file would be a bound that can drift from the one the
104
+ * server actually enforces.
105
+ *
106
+ * `step` doubles as `command` when the failure has none, so the server's task
107
+ * title has something to name.
108
+ */
109
+ function reportFields(sanitized) {
110
+ const own = Object.entries(sanitized.diagnostics || {})
111
+ .slice(0, MAX_DIAGNOSTIC_KEYS - RESERVED_DIAGNOSTIC_KEYS);
112
+ return {
113
+ message: sanitized.message,
114
+ command: sanitized.command || sanitized.step,
115
+ errorCode: sanitized.errorCode,
116
+ exitCode: sanitized.exitCode,
117
+ log: sanitized.stderr || sanitized.stdout || "",
118
+ diagnostics: {
119
+ ...Object.fromEntries(own),
120
+ scope: sanitized.scope,
121
+ step: sanitized.step,
122
+ ...(sanitized.tenantId ? { tenantId: sanitized.tenantId } : {}),
123
+ ...(sanitized.signal ? { signal: sanitized.signal } : {}),
124
+ },
125
+ };
126
+ }
127
+
128
+ /* -------------------------------------------------------------------------- */
129
+ /* Suppression marker */
130
+ /* -------------------------------------------------------------------------- */
131
+
132
+ /** An unreadable or malformed marker reads as absent: it must not block a report. */
133
+ function readMarker(markerPath) {
134
+ try {
135
+ const parsed = JSON.parse(fs.readFileSync(markerPath, "utf8"));
136
+ if (!Array.isArray(parsed?.entries)) return [];
137
+ return parsed.entries.filter((entry) => (
138
+ entry && typeof entry.reportId === "string" && typeof entry.sentAt === "string"
139
+ ));
140
+ } catch {
141
+ return [];
142
+ }
143
+ }
144
+
145
+ function alreadyReported(entries, reportId, now) {
146
+ return entries.some((entry) => {
147
+ if (entry.reportId !== reportId) return false;
148
+ const sentAt = Date.parse(entry.sentAt);
149
+ return Number.isFinite(sentAt) && now - sentAt < AUTO_REPORT_SUPPRESSION_MS;
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Record this send.
155
+ *
156
+ * Losing the marker costs one duplicate report; refusing to report because the
157
+ * marker could not be written costs the report. So every failure here is
158
+ * swallowed by the caller.
159
+ */
160
+ function writeMarker(markerPath, entries, reportId, now) {
161
+ const kept = entries
162
+ .filter((entry) => entry.reportId !== reportId)
163
+ .filter((entry) => {
164
+ const sentAt = Date.parse(entry.sentAt);
165
+ return Number.isFinite(sentAt) && now - sentAt < AUTO_REPORT_SUPPRESSION_MS;
166
+ });
167
+ // Newest first, so the cap drops the oldest.
168
+ const next = [{ reportId, sentAt: new Date(now).toISOString() }, ...kept]
169
+ .slice(0, AUTO_REPORT_MARKER_ENTRIES);
170
+ writeJsonAtomically(markerPath, { entries: next });
171
+ }
172
+
173
+ /* -------------------------------------------------------------------------- */
174
+ /* Dispatch */
175
+ /* -------------------------------------------------------------------------- */
176
+
177
+ /**
178
+ * File a bug report for a failed install, or do nothing.
179
+ *
180
+ * `failure` is the same object the command already hands `io.recoverInstall`,
181
+ * so a call site adds no new data collection — it hands over what it had
182
+ * assembled anyway.
183
+ *
184
+ * `io` is the *calling command's* bag, which carries none of the delivery
185
+ * seams: `impel setup` and `impel update` have no `fetchImpl`, no `spoolDir`,
186
+ * no `sleep`. Those defaults are supplied here and caller keys win, so a test
187
+ * can inject a fake fetch and a command does not have to grow twelve fields it
188
+ * has no other use for.
189
+ */
190
+ export async function reportInstallFailure({ failure, config, io = {} }) {
191
+ try {
192
+ const environment = io.environment || process.env;
193
+ const homeDir = io.homeDir || os.homedir();
194
+
195
+ // KTD2's order. `firstPartyCli` leads: a rebranded fork must not inherit
196
+ // Impel's phone-home, and that answer does not depend on this machine's
197
+ // config, environment, or PAT — so it is also the only gate that can be
198
+ // decided without reading any of them.
199
+ if (!firstPartyCli()) return;
200
+ if (reportEnvOptOut(environment)) return;
201
+ // Before the marker check only because it is a property read and that one
202
+ // walks `realpath` up the ancestor chain. Nothing to authenticate with is
203
+ // just as final an answer, and this is a command that has already failed
204
+ // and wants to exit.
205
+ if (!config?.pat) return;
206
+ // Not a consent question (R7): a managed vendor app profile may reach the
207
+ // gateway and nothing else, so a report from inside one presents as an
208
+ // unexpected egress call from a profile holding app credentials.
209
+ if (managedMarkerPresent(environment, homeDir)) return;
210
+
211
+ const delivery = {
212
+ environment,
213
+ homeDir,
214
+ fetchImpl: fetchHttp1,
215
+ spoolDir: REPORT_SPOOL_DIR,
216
+ timeoutMs: AUTO_REPORT_TIMEOUT_MS,
217
+ now: Date.now(),
218
+ platform: process.platform,
219
+ sleep,
220
+ log: console.log,
221
+ warn: console.warn,
222
+ ...io,
223
+ };
224
+
225
+ // The same destination the manual command resolves, through the same
226
+ // check, for the same reason and one more: this sender has no user to
227
+ // notice that `appUrl` was hand-edited. A rejection is a gate like any
228
+ // other and returns silently — there is no `--app` flag to correct.
229
+ let appUrl;
230
+ try {
231
+ appUrl = resolveReportOrigin(config.appUrl || resolveDefaultAppUrl());
232
+ } catch {
233
+ return;
234
+ }
235
+
236
+ // Sanitize first, then build — not instead of building. The sanitizer
237
+ // removes credentials, home paths, and emails from the recovery envelope;
238
+ // `buildEnvelope` is what makes it a wire-legal bug report.
239
+ const sanitized = sanitizeInstallFailureEnvelope(failure, { homeDir });
240
+ const { envelope, reportId } = buildEnvelope({
241
+ fields: reportFields(sanitized),
242
+ config,
243
+ io: delivery,
244
+ });
245
+
246
+ // R4. Read before printing: a suppressed report prints nothing at all,
247
+ // because the disclosure line exists to explain a send that is happening.
248
+ const markerPath = autoReportMarkerPath(delivery.spoolDir);
249
+ const entries = readMarker(markerPath);
250
+ if (alreadyReported(entries, reportId, delivery.now)) return;
251
+
252
+ // R9/KTD6: before the request, and worded as an attempt. The id is a
253
+ // fingerprint of the envelope so it is already known, and up to ~17 s of
254
+ // retries against an unreachable control plane should not be a silent
255
+ // pause. A past-tense claim would be false in exactly the case that
256
+ // matters — a spooled report announcing a send that never happened.
257
+ delivery.log(`Reporting this failure automatically (id ${reportId}). Set ${DO_NOT_TRACK_ENV}=1 to disable.`);
258
+
259
+ const { outcome } = await deliverWithRetry({
260
+ envelope,
261
+ appUrl,
262
+ currentPat: config.pat,
263
+ io: delivery,
264
+ });
265
+
266
+ if (!outcome) {
267
+ // The outcome line that corrects the attempt line. Nothing drains this
268
+ // directory — the spool is a local paper trail, not a queue — so the next
269
+ // occurrence of this failure dispatches again rather than being
270
+ // suppressed, which is also why the marker below is not written here.
271
+ const filePath = spoolReport({ envelope, reportId, appUrl, io: delivery });
272
+ delivery.log(`The report could not be sent. It is saved at ${filePath}`);
273
+ return;
274
+ }
275
+
276
+ // A successful send prints nothing further, and is the only thing that
277
+ // suppresses a repeat. Marker failures are swallowed on purpose.
278
+ try {
279
+ writeMarker(markerPath, entries, reportId, delivery.now);
280
+ } catch {
281
+ // Costs one duplicate report. Refusing to report would cost the report.
282
+ }
283
+ } catch {
284
+ // KTD10. The command that called this is already failing; the reporting
285
+ // path must not add a second failure mode, and there is nobody here to
286
+ // tell. Deliberately empty: a warning printed from this catch would be the
287
+ // second error message R10 promises the user will not see.
288
+ }
289
+ }