spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

Merge remote-tracking branch 'origin/main' into poteat/diff-mod-telemetry-gapsBrowse files
poteat authored on Sep 20, 2026 parents f88faf6 + 7974a70 · commit fe17a972f768
245 changed files+3127 -208compared with f88faf6
MCHANGELOG.md
Mfeed.xml
Mmods/README.md
Mmods/diff/README.md
Mmods/diff/hooks/host/host.ts
Mmods/diff/hooks/index.ts
Amods/diff/hooks/is-checkpointing/index.ts
Amods/diff/hooks/is-checkpointing/is-checkpointing.ts
Mmods/diff/hooks/register.ts
Amods/diff/tests/fixtures/beneath/beneath.ts
Amods/diff/tests/fixtures/beneath/index.ts
Amods/diff/tests/fixtures/beneath/left-waiting.ts
Amods/diff/tests/fixtures/beneath/slow-diff-ms.ts
Amods/diff/tests/fixtures/edited-transcript.ts
Mmods/diff/tests/fixtures/in-repository.ts
Mmods/diff/tests/fixtures/index.ts
Amods/diff/tests/fixtures/resume.ts
Amods/diff/tests/fixtures/usage-at.ts
Mmods/diff/tests/git.test.ts
Mmods/diff/tests/register.test.ts
Mmods/telemetry/.claude-plugin/plugin.json
Mmods/telemetry/README.md
Amods/telemetry/hooks/batching/batch-rows/batch-rows.ts
Amods/telemetry/hooks/batching/batch-rows/index.ts
Amods/telemetry/hooks/batching/batch-window-ms/batch-window-ms.ts
Amods/telemetry/hooks/batching/batch-window-ms/index.ts
Amods/telemetry/hooks/batching/index.ts
Amods/telemetry/hooks/batching/is-retriable/index.ts
Amods/telemetry/hooks/batching/is-retriable/is-retriable.ts
Amods/telemetry/hooks/batching/is-retriable/status-request-timeout/index.ts
Amods/telemetry/hooks/batching/is-retriable/status-request-timeout/status-request-timeout.ts
Amods/telemetry/hooks/batching/is-retriable/status-server-error/index.ts
Amods/telemetry/hooks/batching/is-retriable/status-server-error/status-server-error.ts
Amods/telemetry/hooks/batching/is-retriable/status-too-many-requests/index.ts
Amods/telemetry/hooks/batching/is-retriable/status-too-many-requests/status-too-many-requests.ts
Amods/telemetry/hooks/batching/message-of/index.ts
Amods/telemetry/hooks/batching/message-of/message-of.ts
Amods/telemetry/hooks/batching/pending-row/index.ts
Amods/telemetry/hooks/batching/pending-row/pending-row.ts
Amods/telemetry/hooks/batching/retry-delay-ms/index.ts
Amods/telemetry/hooks/batching/retry-delay-ms/retry-delay-ms.ts
Amods/telemetry/hooks/config-location/config-location.ts
Amods/telemetry/hooks/config-location/index.ts
Amods/telemetry/hooks/context/client-type-of/client-type-by-entrypoint/client-type-by-entrypoint.ts
Amods/telemetry/hooks/context/client-type-of/client-type-by-entrypoint/index.ts
Amods/telemetry/hooks/context/client-type-of/client-type-of.ts
Amods/telemetry/hooks/context/client-type-of/index.ts
Amods/telemetry/hooks/context/context/context.ts
Amods/telemetry/hooks/context/context/index.ts
Amods/telemetry/hooks/context/context-of.ts
Amods/telemetry/hooks/context/entrypoint-of/entrypoint-of.ts
Amods/telemetry/hooks/context/entrypoint-of/index.ts
Amods/telemetry/hooks/context/environment-fields/environment-fields.ts
Amods/telemetry/hooks/context/environment-fields/github-actions-fields/github-actions-fields.ts
Amods/telemetry/hooks/context/environment-fields/github-actions-fields/index.ts
Amods/telemetry/hooks/context/environment-fields/index.ts
Amods/telemetry/hooks/context/environment-fields-of/action-ref-of/action-ref-of.ts
Amods/telemetry/hooks/context/environment-fields-of/action-ref-of/index.ts
Amods/telemetry/hooks/context/environment-fields-of/environment-fields-of.ts
Amods/telemetry/hooks/context/environment-fields-of/github-actions-fields-of/github-actions-fields-of.ts
Amods/telemetry/hooks/context/environment-fields-of/github-actions-fields-of/index.ts
Amods/telemetry/hooks/context/environment-fields-of/index.ts
Amods/telemetry/hooks/context/environment-fields-of/tags-of/index.ts
Amods/telemetry/hooks/context/environment-fields-of/tags-of/tags-of.ts
Amods/telemetry/hooks/context/identity/global-config-path/global-config-path.ts
Amods/telemetry/hooks/context/identity/global-config-path/index.ts
Amods/telemetry/hooks/context/identity/identity/identity.ts
Amods/telemetry/hooks/context/identity/identity/index.ts
Amods/telemetry/hooks/context/identity/identity-of.ts
Amods/telemetry/hooks/context/identity/index.ts
Amods/telemetry/hooks/context/identity/plausible-uuid/index.ts
Amods/telemetry/hooks/context/identity/plausible-uuid/plausible-uuid.ts
Amods/telemetry/hooks/context/identity/plausible-uuid/uuid-length-floor/index.ts
Amods/telemetry/hooks/context/identity/plausible-uuid/uuid-length-floor/uuid-length-floor.ts
Amods/telemetry/hooks/context/identity/plausible-uuid/uuid-length-limit/index.ts
Amods/telemetry/hooks/context/identity/plausible-uuid/uuid-length-limit/uuid-length-limit.ts
Amods/telemetry/hooks/context/index.ts
Amods/telemetry/hooks/context/linux-distro-of/index.ts
Amods/telemetry/hooks/context/linux-distro-of/linux-distro/index.ts
Amods/telemetry/hooks/context/linux-distro-of/linux-distro/linux-distro.ts
Amods/telemetry/hooks/context/linux-distro-of/linux-distro-of.ts
Amods/telemetry/hooks/context/linux-distro-of/os-release-value/index.ts
Amods/telemetry/hooks/context/linux-distro-of/os-release-value/os-release-value.ts
Amods/telemetry/hooks/context/machine/arch-of/arch-of.ts
Amods/telemetry/hooks/context/machine/arch-of/index.ts
Amods/telemetry/hooks/context/machine/index.ts
Amods/telemetry/hooks/context/machine/machine/index.ts
Amods/telemetry/hooks/context/machine/machine/machine.ts
Amods/telemetry/hooks/context/machine/machine-of.ts
Amods/telemetry/hooks/context/machine/platform-of/index.ts
Amods/telemetry/hooks/context/machine/platform-of/platform-of.ts
Amods/telemetry/hooks/context/machine/probe-script/index.ts
Amods/telemetry/hooks/context/machine/probe-script/probe-script.ts
Amods/telemetry/hooks/context/machine/probe-timeout-ms/index.ts
Amods/telemetry/hooks/context/machine/probe-timeout-ms/probe-timeout-ms.ts
Amods/telemetry/hooks/context/probe/index.ts
Amods/telemetry/hooks/context/probe/probe.ts
Amods/telemetry/hooks/context/probe-of/index.ts
Amods/telemetry/hooks/context/probe-of/linux-files/index.ts
Amods/telemetry/hooks/context/probe-of/linux-files/linux-files.ts
Amods/telemetry/hooks/context/probe-of/linux-files-of/index.ts
Amods/telemetry/hooks/context/probe-of/linux-files-of/linux-files-of.ts
Amods/telemetry/hooks/context/probe-of/probe-of.ts
Amods/telemetry/hooks/context/remote-hash-of/hex-of/hex-of.ts
Amods/telemetry/hooks/context/remote-hash-of/hex-of/hex-radix/hex-radix.ts
Amods/telemetry/hooks/context/remote-hash-of/hex-of/hex-radix/index.ts
Amods/telemetry/hooks/context/remote-hash-of/hex-of/index.ts
Amods/telemetry/hooks/context/remote-hash-of/index.ts
Amods/telemetry/hooks/context/remote-hash-of/normalized-remote-of/index.ts
Amods/telemetry/hooks/context/remote-hash-of/normalized-remote-of/normalized-remote-of.ts
Amods/telemetry/hooks/context/remote-hash-of/remote-hash-length/index.ts
Amods/telemetry/hooks/context/remote-hash-of/remote-hash-length/remote-hash-length.ts
Amods/telemetry/hooks/context/remote-hash-of/remote-hash-of.ts
Amods/telemetry/hooks/context/session-fields/index.ts
Amods/telemetry/hooks/context/session-fields/session-fields.ts
Amods/telemetry/hooks/context/session-fields-of/index.ts
Amods/telemetry/hooks/context/session-fields-of/session-fields-of.ts
Amods/telemetry/hooks/context/shell-of/index.ts
Amods/telemetry/hooks/context/shell-of/known-shells/index.ts
Amods/telemetry/hooks/context/shell-of/known-shells/known-shells.ts
Amods/telemetry/hooks/context/shell-of/shell-of.ts
Amods/telemetry/hooks/context/terminal-of/index.ts
Amods/telemetry/hooks/context/terminal-of/jetbrains-ides/index.ts
Amods/telemetry/hooks/context/terminal-of/jetbrains-ides/jetbrains-ides.ts
Amods/telemetry/hooks/context/terminal-of/terminal-of.ts
Amods/telemetry/hooks/context/vcs-of/distinct/distinct.ts
Amods/telemetry/hooks/context/vcs-of/distinct/index.ts
Amods/telemetry/hooks/context/vcs-of/index.ts
Amods/telemetry/hooks/context/vcs-of/vcs-markers/index.ts
Amods/telemetry/hooks/context/vcs-of/vcs-markers/vcs-markers.ts
Amods/telemetry/hooks/context/vcs-of/vcs-of.ts
Amods/telemetry/hooks/context/version-of/index.ts
Amods/telemetry/hooks/context/version-of/version-of.ts
Amods/telemetry/hooks/context/wsl-version-of/index.ts
Amods/telemetry/hooks/context/wsl-version-of/wsl-version-of.ts
Amods/telemetry/hooks/deployment/deployment-name/deployment-name.ts
Amods/telemetry/hooks/deployment/deployment-name/index.ts
Amods/telemetry/hooks/deployment/deployment-of.ts
Amods/telemetry/hooks/deployment/deployment-signal/deployment-signal.ts
Amods/telemetry/hooks/deployment/deployment-signal/index.ts
Amods/telemetry/hooks/deployment/deployments/deployments.ts
Amods/telemetry/hooks/deployment/deployments/index.ts
Amods/telemetry/hooks/deployment/index.ts
Mmods/telemetry/hooks/entries/batch-of.ts
Amods/telemetry/hooks/entries/batch-stamp/batch-stamp.ts
Amods/telemetry/hooks/entries/batch-stamp/index.ts
Amods/telemetry/hooks/entries/event-data-of/auth-of/auth-of.ts
Amods/telemetry/hooks/entries/event-data-of/auth-of/index.ts
Amods/telemetry/hooks/entries/event-data-of/event-data-of.ts
Amods/telemetry/hooks/entries/event-data-of/index.ts
Amods/telemetry/hooks/entries/event-data-of/metadata-of/index.ts
Amods/telemetry/hooks/entries/event-data-of/metadata-of/metadata-of.ts
Mmods/telemetry/hooks/entries/index.ts
Dmods/telemetry/hooks/entries/row-session/index.ts
Dmods/telemetry/hooks/entries/row-session/row-session.ts
Amods/telemetry/hooks/entries/wire-of/index.ts
Amods/telemetry/hooks/entries/wire-of/wire-of.ts
Mmods/telemetry/hooks/environment/environment.ts
Amods/telemetry/hooks/facts/facts.ts
Amods/telemetry/hooks/facts/index.ts
Amods/telemetry/hooks/gate/caught/caught.ts
Amods/telemetry/hooks/gate/caught/index.ts
Amods/telemetry/hooks/gate/gate-next/gate-next.ts
Amods/telemetry/hooks/gate/gate-next/index.ts
Amods/telemetry/hooks/gate/index.ts
Amods/telemetry/hooks/gate/refused/index.ts
Amods/telemetry/hooks/gate/refused/refused.ts
Amods/telemetry/hooks/gate/served/index.ts
Amods/telemetry/hooks/gate/served/served.ts
Amods/telemetry/hooks/gate/served-tiers/index.ts
Amods/telemetry/hooks/gate/served-tiers/served-tiers.ts
Mmods/telemetry/hooks/hooks.json
Mmods/telemetry/hooks/index.ts
Mmods/telemetry/hooks/is-analytics-off/is-analytics-off.ts
Amods/telemetry/hooks/policy-pins/index.ts
Amods/telemetry/hooks/policy-pins/policy-pins.ts
Mmods/telemetry/hooks/register.ts
Amods/telemetry/hooks/sender/index.ts
Amods/telemetry/hooks/sender/sender.ts
Mmods/telemetry/hooks/telemetry-deps/telemetry-deps.ts
Mmods/telemetry/hooks/telemetry-of/telemetry-of.ts
Dmods/telemetry/tests/fixtures/analytics-off-environments.ts
Dmods/telemetry/tests/fixtures/base64-alphabet.ts
Dmods/telemetry/tests/fixtures/base64-decoded.ts
Dmods/telemetry/tests/fixtures/base64-digit-bits.ts
Dmods/telemetry/tests/fixtures/batch-of.ts
Amods/telemetry/tests/fixtures/config-path.ts
Amods/telemetry/tests/fixtures/deaf-telemetry.ts
Amods/telemetry/tests/fixtures/device-id.ts
Amods/telemetry/tests/fixtures/ended.ts
Amods/telemetry/tests/fixtures/environments/analytics-off-environments.ts
Amods/telemetry/tests/fixtures/environments/github-action-env.ts
Amods/telemetry/tests/fixtures/environments/host-managed-bedrock-env.ts
Amods/telemetry/tests/fixtures/environments/index.ts
Amods/telemetry/tests/fixtures/environments/linux-files.ts
Amods/telemetry/tests/fixtures/environments/linux-probed.ts
Amods/telemetry/tests/fixtures/environments/sending-env.ts
Amods/telemetry/tests/fixtures/environments/windows-env.ts
Amods/telemetry/tests/fixtures/expected-row.ts
Mmods/telemetry/tests/fixtures/first-party-session.ts
Amods/telemetry/tests/fixtures/gate/failing-next.ts
Amods/telemetry/tests/fixtures/gate/index.ts
Amods/telemetry/tests/fixtures/gate/next-of.ts
Amods/telemetry/tests/fixtures/global-config.ts
Amods/telemetry/tests/fixtures/held.ts
Mmods/telemetry/tests/fixtures/index.ts
Dmods/telemetry/tests/fixtures/ingest-batch.ts
Dmods/telemetry/tests/fixtures/ingest-event.ts
Amods/telemetry/tests/fixtures/listing.ts
Dmods/telemetry/tests/fixtures/marking.ts
Amods/telemetry/tests/fixtures/plugins/holding.ts
Amods/telemetry/tests/fixtures/plugins/index.ts
Amods/telemetry/tests/fixtures/plugins/managing.ts
Amods/telemetry/tests/fixtures/plugins/marking.ts
Amods/telemetry/tests/fixtures/plugins/meddling.ts
Amods/telemetry/tests/fixtures/plugins/reaching.ts
Amods/telemetry/tests/fixtures/plugins/recording.ts
Amods/telemetry/tests/fixtures/plugins/replacing.ts
Amods/telemetry/tests/fixtures/plugins/swallowing.ts
Amods/telemetry/tests/fixtures/plugins/visiting.ts
Amods/telemetry/tests/fixtures/policy-option.ts
Amods/telemetry/tests/fixtures/probed.ts
Amods/telemetry/tests/fixtures/read-tool.ts
Dmods/telemetry/tests/fixtures/recording.ts
Mmods/telemetry/tests/fixtures/refused.ts
Amods/telemetry/tests/fixtures/rejected.ts
Amods/telemetry/tests/fixtures/remote-hash.ts
Amods/telemetry/tests/fixtures/repo.ts
Dmods/telemetry/tests/fixtures/row-of.ts
Amods/telemetry/tests/fixtures/sent-session.ts
Amods/telemetry/tests/fixtures/session-options.ts
Amods/telemetry/tests/fixtures/started.ts
Amods/telemetry/tests/fixtures/typed.ts
Amods/telemetry/tests/fixtures/wire/base64-alphabet.ts
Amods/telemetry/tests/fixtures/wire/base64-decoded.ts
Amods/telemetry/tests/fixtures/wire/base64-digit-bits.ts
Amods/telemetry/tests/fixtures/wire/batch-of.ts
Amods/telemetry/tests/fixtures/wire/event-name-of.ts
Amods/telemetry/tests/fixtures/wire/index.ts
Amods/telemetry/tests/fixtures/wire/ingest-batch.ts
Amods/telemetry/tests/fixtures/wire/ingest-event.ts
Amods/telemetry/tests/fixtures/wire/rows-of.ts
Amods/telemetry/tests/gate.test.ts
Mmods/telemetry/tests/register.test.ts
Mmods/telemetry/types/index.d.ts
Large file, diff not shown.
Mfeed.xml+9 -11
@@ -6,8 +6,16 @@
66 <author><name>Anthropic</name></author>
77 <link rel="alternate" type="text/html" href="https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md"/>
88 <link rel="self" type="application/atom+xml" href="https://raw.githubusercontent.com/anthropics/claude-code/main/feed.xml"/>
9- <updated>2026-09-18T18:06:17Z</updated>
9+ <updated>2026-09-19T03:10:24Z</updated>
1010 <entry>
11+ <id>https://github.com/anthropics/claude-code/releases/tag/v2.1.278</id>
12+ <title>Claude Code v2.1.278</title>
13+ <link rel="alternate" type="text/html" href="https://github.com/anthropics/claude-code/releases/tag/v2.1.278"/>
14+ <updated>2026-09-19T03:10:24Z</updated>
15+ <content type="html">&lt;p&gt;• Changed auto mode for Claude API and Enterprise users, and on Bedrock, Vertex, Foundry and gateways, to default to the server-side classifier, which does not charge for classifier overhead (CLAUDE_CODE_AUTO_MODE_SERVER=0 opts out on Bedrock, Vertex, Foundry and gateways); warns on billed fallback. See https://code.claude.com/docs/en/auto-mode-classifier-billing&lt;/p&gt;
16+&lt;p&gt;• Added an Auto mode server row to /status showing whether this session's auto mode classifier runs on the server&lt;/p&gt;</content>
17+ </entry>
18+ <entry>
1119 <id>https://github.com/anthropics/claude-code/releases/tag/v2.1.277</id>
1220 <title>Claude Code v2.1.277</title>
1321 <link rel="alternate" type="text/html" href="https://github.com/anthropics/claude-code/releases/tag/v2.1.277"/>
@@ -1150,14 +1158,4 @@
11501158 &lt;p&gt;• [VSCode] Changed the action menu to list slash commands in a filterable "Slash commands" dialog instead of inline; picking one runs it; the MCP servers dialog gained the same filter box&lt;/p&gt;
11511159 &lt;p&gt;• [VSCode] Changed "Delete session" to "Archive session": archived sessions move to a collapsible "Archived sessions" group at the bottom of the list with an Unarchive action&lt;/p&gt;</content>
11521160 </entry>
1153- <entry>
1154- <id>https://github.com/anthropics/claude-code/releases/tag/v2.1.252</id>
1155- <title>Claude Code v2.1.252</title>
1156- <link rel="alternate" type="text/html" href="https://github.com/anthropics/claude-code/releases/tag/v2.1.252"/>
1157- <updated>2026-08-31T19:46:48Z</updated>
1158- <content type="html">&lt;p&gt;• Fixed Bash commands failing with "task output swap refused (tasks dir moved or linked)" on some Macs&lt;/p&gt;
1159-&lt;p&gt;• Fixed "always allow" not saving in a project that has no .claude/settings.local.json yet&lt;/p&gt;
1160-&lt;p&gt;• Fixed Remote Control sessions hosted by Claude Desktop or VS Code stalling for minutes after a tool finished when the connection to claude.ai was degraded&lt;/p&gt;
1161-&lt;p&gt;• Fixed background task notifications with very large failure output (for example git errors on a full disk) making the conversation exceed the API request size limit&lt;/p&gt;</content>
1162- </entry>
11631161 </feed>
@@ -9,7 +9,7 @@
99 | --- | --- | --- |
1010 | [`sec-default`](sec-default) | Keeps an organization's classic hooks, prompt content, managed settings and tool policy out of reach of the plugins a person installs; adds no policy of its own. | Outermost, on a machine with managed settings or for a Team or Enterprise organization, unless managed `prependPlugins` says otherwise |
1111 | [`diff`](diff) | `/diff`: the session's uncommitted changes in a pane beside the transcript, file by file with their hunks, refreshed as Claude edits files and runs commands. | Built in |
12-| [`telemetry`](telemetry) | Adds `$.telemetry` (`log`, `mark`) in the `engine.create` fold so a plugin can record an event as a first-party analytics row; sends nothing wherever Claude Code's analytics are off. | Built in |
12+| [`telemetry`](telemetry) | Adds `$.telemetry` (`log`, `mark`) in the `engine.create` fold so a built-in plugin can record an event as a first-party analytics row, sent in batches; refuses installed plugins; sends nothing wherever Claude Code's analytics are off. | Built in |
1313 | [`agents-md`](agents-md) | `AGENTS.md` as project instructions, by one option: loaded where the project has no `CLAUDE.md` of its own (`claude-md-or-agents-md`, the default) or beside it (`claude-md-and-agents-md`), placed and framed exactly as the engine places `CLAUDE.md`, nested ones on a `Read`; or the project's and the person's instruction files dropped and the organization's kept (`managed-only`); or `CLAUDE.md` alone, as the engine reads it (`claude-md`). | Built in |
1414
1515 Each folder is a complete plugin: `.claude-plugin/plugin.json`, a
@@ -13,12 +13,19 @@
1313 through Buttons that declare the engine's own actions. The pane refreshes
1414 as Claude edits and runs shell commands, and while it is open it polls
1515 the repository's HEAD so a commit or checkout made elsewhere shows too.
16-The first successful edit of a session opens the pane by itself where the
16+The main loop's first successful edit of a session opens the pane by
17+itself, as the built-in panel opens on its first checkpoint: where the
1718 layout docks it beside the transcript (the fullscreen layout, which each
18-drawing's `viewport` says) and the terminal is wide enough (144 columns
19-when the person never chose, 110 when they kept it open before; a person
20-who closed it is left alone); where the surface does not say, nothing
21-opens by itself.
19+drawing's `viewport` says), the terminal is wide enough (144 columns when
20+the person never chose, 110 when they kept it open before; a person who
21+closed it is left alone) and file checkpointing is on; a subagent's edit
22+opens nothing, and where the surface does not say, nothing opens by
23+itself. A docked pane fetches before it opens, as the built-in panel
24+primes its data, so it never lands on `Loading diff…`; an open the engine
25+leaves waiting undrawn is withdrawn, so no later resize seats it, and the
26+next edit asks again. A session resumed or continued whose transcript
27+already holds such an edit opens the pane on the same terms as soon as the
28+width is known, as the built-in opens on the history it restores.
2229
2330 Under the fullscreen layout a terminal under 110 columns gets the
2431 built-in's line asking for a wider one and nothing opens. Without that
@@ -34,7 +41,9 @@
3441 file: its hunks ride the next prompt as context, once.
3542
3643 The pane compares the working tree against HEAD, split at the session's
37-start (the default), against HEAD plainly, or against the merge-base with
44+start (the default; the start the engine gives in `$.session.usage()`, so a
45+resumed session keeps its first and `/clear` begins anew), against HEAD
46+plainly, or against the merge-base with
3847 the default branch; the base line under the header names a base other than
3948 the session's, and the choice is kept per repository in the plugin's store.
4049 A picker shows one earlier turn's edits instead of the working tree, read
@@ -60,7 +69,7 @@
6069
6170 | event | what the hook does |
6271 | --- | --- |
63-| `session.start` | Binds the engine once and registers `/diff` (a session where another `/diff` is listed leaves the plugin idle); asks nothing of the repository, which `/diff` or the first edit pins when it comes. |
72+| `session.start` | Binds the engine once and registers `/diff` (a session where another `/diff` is listed leaves the plugin idle); asks nothing of the repository, which `/diff` or the first edit pins when it comes; off its dispatch, reads the transcript, and for a resumed session whose turns edited opens the pane as the first edit would. |
6473 | `ui.render` of `PromptHint` | Reads the terminal's width and whether its layout docks a pane, which decide whether the first edit opens the pane. |
6574 | `ui.render` of `Pane` | Draws the pane: docked, the header, base line, source picker, file list and toggles over the window of hunks; inline, the dialog. |
6675 | `command.run` of `diff` | Pins the repository when none is, opens or closes the pane (focused and closing on Escape without the fullscreen layout), says which, and remembers the choice. |
@@ -68,16 +77,17 @@
6877 | `ui.scroll` of the pane | Docked, moves the hunks under the pinned header and list (three rows a wheel tick, a page a page key), or the list when the wheel is over it, and keeps the engine's window still. |
6978 | `ui.focus` in the pane | In the dialog's list, selects the file the ring lands on, re-centres the five rows on it, and lands the ring where that row now sits. |
7079 | `command.run` of `clear`, `resume` | Closes the pane and forgets the session's state, the pinned repository with it. |
71-| `tool.call` of `Edit`, `Write`, `NotebookEdit` | After an edit that landed (not refused, not failed), refreshes an open pane; the session's first such edit opens it, pinning the repository then if the terminal has the room. |
80+| `tool.call` of `Edit`, `Write`, `NotebookEdit` | After an edit that landed (not refused, not failed), refreshes an open pane; the main loop's first such edit opens it, pinning the repository then if the terminal has the room and checkpointing is on. |
7281 | `tool.call` of `Bash`, `PowerShell` | After a command that was not refused, failed and interrupted ones too, refreshes an open pane. |
7382 | `prompt.submit` | Adds the armed file's hunks to the prompt's context and disarms. |
7483
7584 ## What it calls on `$`
7685
77-`clock.after`, `clock.every`, `clock.now`, `command.register`, `fs.list`,
78-`fs.read`, `fs.stat`, `process.run` (git, read-only), `session.messages`,
79-`store.get`, `store.set`, `telemetry.log`, `telemetry.mark`, `ui.close`,
80-`ui.invalidate`, `ui.log`, `ui.open`, `ui.resolve`, `ui.status`.
86+`clock.after`, `clock.every`, `clock.now`, `command.register`, `env.get`
87+(`CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING`), `fs.list`, `fs.read`, `fs.stat`,
88+`process.run` (git, read-only), `session.id`, `session.messages`,
89+`settings.read`, `store.get`, `store.set`, `telemetry.log`, `telemetry.mark`,
90+`ui.close`, `ui.invalidate`, `ui.log`, `ui.open`, `ui.resolve`, `ui.status`.
8191
8292 `$.telemetry` is the telemetry plugin's noun; where it is absent the rows
8393 are dropped and nothing else changes.
@@ -65,6 +65,12 @@
6565 storeSet: (key: string, value: unknown) => Promise<void>
6666
6767 /**
68+ * Whether the session checkpoints edits (`$.settings.read`, `$.env.get`):
69+ * the built-in panel opens on an edit only while it does.
70+ */
71+ isCheckpointing: () => Promise<boolean>
72+
73+ /**
6874 * `$.session.messages`.
6975 */
7076 messages: () => Promise<SessionMessage[]>
@@ -105,6 +111,14 @@
105111 sessionId: () => Promise<string>
106112
107113 /**
114+ * When the session began, `$.session.usage`'s `startedAt`: where the line
115+ * between this session's edits and earlier ones falls, a resumed session's
116+ * first start, moved by `/clear`. Not a number under an engine that
117+ * predates it, where the plugin's own start stands in.
118+ */
119+ startedAt: () => Promise<unknown>
120+
121+ /**
108122 * `$.telemetry.mark`; rejects where the telemetry built-in is absent.
109123 */
110124 mark: EngineInterface['telemetry']['mark']
@@ -8,6 +8,7 @@
88 export * from './entry-kinds-of'
99 export * from './git'
1010 export * from './host'
11+export * from './is-checkpointing'
1112 export * from './is-on-pane-surface'
1213 export * from './is-record'
1314 export * from './kept-of'
@@ -1,0 +1,3 @@
1+export * from './is-checkpointing.js'
2+
3+export * as default from '.'
@@ -1,0 +1,20 @@
1+import type { Settings } from 'claude-code'
2+
3+/**
4+ * Whether the session checkpoints Claude's edits, read as the built-in
5+ * reads it: the setting on unless set false, the variable unset or falsy.
6+ *
7+ * The built-in panel opens on an edit only through a checkpoint, so with
8+ * checkpointing off its first-edit open never happens; `/diff` still opens.
9+ *
10+ * @param settings the merged settings (`$.settings.read()`)
11+ * @param disabling `CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING` as `$.env.get`
12+ * answers it
13+ * @returns false when either turns checkpointing off
14+ */
15+export const isCheckpointing = (
16+ settings: Settings,
17+ disabling: string | undefined,
18+): boolean =>
19+ settings.fileCheckpointingEnabled !== false &&
20+ !['1', 'true', 'yes', 'on'].includes((disabling ?? '').trim().toLowerCase())
@@ -1,4 +1,5 @@
11 import type {
2+ Args,
23 On,
34 PaneOpenArgs,
45 ResultOf,
@@ -13,7 +14,9 @@
1314 import { entryKindsOf } from './entry-kinds-of'
1415 import type Git from './git'
1516 import type { Host } from './host'
17+import { isCheckpointing } from './is-checkpointing'
1618 import { isOnPaneSurface } from './is-on-pane-surface'
19+import { isRecord } from './is-record'
1720 import Limits from './limits'
1821 import { mapLimited } from './map-limited'
1922 import { messageOf } from './message-of'
@@ -23,6 +26,7 @@
2326 import PaneToggle from './pane-toggle'
2427 import Record from './record'
2528 import Tools from './tools'
29+import Turns from './turns'
2630 import Views from './views'
2731
2832 /**
@@ -30,8 +34,11 @@
3034 * pane's drawing and refresh, its opening on Claude's first edit, the ask.
3135 *
3236 * Git runs when the built-in's would: `session.start` binds the host and
33- * registers `/diff`; `/diff` or the first edit with room pins the backend
34- * where the session started, until `/clear`; an open pane alone fetches.
37+ * registers `/diff`, and off its dispatch reads the transcript, so a resumed
38+ * session whose turns already edited opens as its first edit would; `/diff`
39+ * or the main loop's first checkpointed edit with room pins the backend,
40+ * until `/clear`, which reads afresh under a pane it leaves open; a docked
41+ * pane fetches, then opens.
3542 *
3643 * @param on the engine's registrar
3744 */
@@ -43,6 +50,7 @@
4350 let isPaneOpen = false
4451 let dialogRows: number | null = null
4552 let hasAutoOpened = false
53+ let hasRestoredEdits = false
4654 let columns: number | null = null
4755 let shownSessionId: string | null = null
4856 let armed: Ask.ArmedAsk | null = null
@@ -50,6 +58,7 @@
5058 let isRefreshing = false
5159 let isRefreshQueued = false
5260 let generation = 0
61+ let landed = 0
5362 let bodyStamp: string | null = null
5463 let bodyBase: string | null = null
5564
@@ -394,7 +403,7 @@
394403 async function openPane(
395404 engine: Host,
396405 trigger: (typeof Record.SHOWN_TRIGGERS)[number],
397- ): Promise<void> {
406+ ): Promise<boolean> {
398407 const isDialog = model.isFullscreen === false
399408
400409 model = {
@@ -406,12 +415,26 @@
406415
407416 dialogRows = isDialog ? Views.dialogRowsOf(model) : null
408417
409- await engine.openPane(
418+ const landedBefore = landed
419+
420+ if (!isDialog) {
421+ await refresh(engine).catch(() => undefined)
422+ }
423+
424+ const opened = await engine.openPane(
410425 isDialog
411426 ? { ...dialogPane(), focus: true }
412427 : { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true },
413428 )
414429
430+ const isWaiting = isRecord(opened) && opened.isPlaced === false
431+
432+ if (isWaiting) {
433+ await engine.closePane({ id: Names.PANE_ID }).catch(() => undefined)
434+
435+ return false
436+ }
437+
415438 isPaneOpen = true
416439
417440 const sessionId = await engine.sessionId().catch(() => null)
@@ -421,7 +444,13 @@
421444 Record.recorderOf(engine).shown(trigger, Record.widthBucketOf(columns))
422445 }
423446
424- void refresh(engine)
447+ const isStale = isDialog || landed !== landedBefore
448+
449+ if (isStale) {
450+ void refresh(engine)
451+ }
452+
453+ return true
425454 }
426455
427456 async function closePane(engine: Host): Promise<void> {
@@ -460,6 +489,12 @@
460489 return
461490 }
462491
492+ const isCheckpointed = await engine.isCheckpointing().catch(() => true)
493+
494+ if (!isCheckpointed || isTaken()) {
495+ return
496+ }
497+
463498 await pinBackend(engine)
464499
465500 if (!backend || isTaken()) {
@@ -467,9 +502,19 @@
467502 }
468503
469504 hasAutoOpened = true
470- await openPane(engine, 'auto_open')
505+ hasAutoOpened = await openPane(engine, 'auto_open')
471506 }
472507
508+ async function openOnRestore(engine: Host): Promise<void> {
509+ const messages = await engine.messages().catch((): SessionMessage[] => [])
510+
511+ hasRestoredEdits = Turns.turnDiffsOf(messages).length > 0
512+
513+ if (hasRestoredEdits) {
514+ await openOnFirstEdit(engine)
515+ }
516+ }
517+
473518 function disarm(engine: Host) {
474519 armed = null
475520 model = { ...model, armedPath: null }
@@ -574,8 +619,14 @@
574619 redraw(engine)
575620 }
576621
622+ async function startedAtOf(engine: Host): Promise<number | null> {
623+ const startedAt: unknown = await engine.startedAt().catch(() => undefined)
624+
625+ return typeof startedAt === 'number' ? startedAt : null
626+ }
627+
577628 async function bind(engine: Host, cwd: string): Promise<void> {
578- sessionStartMs = await engine.now()
629+ sessionStartMs = (await startedAtOf(engine)) ?? (await engine.now())
579630 pin.cwd = cwd
580631
581632 try {
@@ -602,6 +653,11 @@
602653 readFile: path => $.fs.read(path),
603654 storeGet: key => $.store.get(key),
604655 storeSet: (key, value) => $.store.set(key, value),
656+ isCheckpointing: async () =>
657+ isCheckpointing(
658+ await $.settings.read(),
659+ await $.env.get('CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING'),
660+ ),
605661 messages: () => $.session.messages(),
606662 invalidate: () => $.ui.invalidate('ui.render'),
607663 status: text => $.ui.status(text),
@@ -610,12 +666,22 @@
610666 closePane: pane => $.ui.close(pane),
611667 registerCommand: spec => $.command.register(spec),
612668 sessionId: () => $.session.id(),
669+ startedAt: () =>
670+ $.session
671+ .usage()
672+ .then((usage: unknown) =>
673+ isRecord(usage) ? usage.startedAt : undefined,
674+ ),
613675 mark: entry => $.telemetry.mark(entry),
614676 log: entry => $.telemetry.log(entry),
615677 },
616678 e.cwd,
617679 )
618680
681+ if (host) {
682+ void openOnRestore(host).catch(() => undefined)
683+ }
684+
619685 return next(e)
620686 })
621687
@@ -624,12 +690,18 @@
624690 const viewport: { columns?: number; isFullscreen?: boolean } | undefined =
625691 e.viewport
626692
693+ const isFirstMeasure = columns === null && viewport?.columns !== undefined
694+
627695 columns = viewport?.columns ?? columns
628696
629697 model = {
630698 ...model,
631699 isFullscreen: viewport?.isFullscreen ?? model.isFullscreen,
632700 }
701+
702+ if (isFirstMeasure && hasRestoredEdits && host) {
703+ void openOnFirstEdit(host).catch(() => undefined)
704+ }
633705 }
634706
635707 return next(e)
@@ -699,8 +771,15 @@
699771 }
700772
701773 const isOpening = toggle === 'open'
702- await (isOpening ? openPane(host, 'manual') : closePane(host))
703774
775+ const isDone = isOpening
776+ ? await openPane(host, 'manual')
777+ : await closePane(host).then(() => true)
778+
779+ if (!isDone) {
780+ return { text: Names.RESIZE_TERMINAL_TEXT }
781+ }
782+
704783 if (!isFullscreen) {
705784 return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT }
706785 }
@@ -799,29 +878,48 @@
799878 on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => {
800879 const result = await next(e)
801880
802- if (host) {
803- if (isPaneOpen) {
804- await closePane(host).catch(() => undefined)
805- }
881+ if (!host) {
882+ return result
883+ }
806884
807- unpin()
808- hasAutoOpened = false
809- bodyStamp = null
810- bodyBase = null
811- bodyLoads.clear()
812- disarm(host)
813- model = PaneState.afterNewSession(model)
885+ const isResume = e.command === 'resume'
886+ const isKeptOpen = isPaneOpen && !isResume
887+
888+ if (isPaneOpen && isResume) {
889+ await closePane(host).catch(() => undefined)
814890 }
815891
892+ unpin()
893+ hasAutoOpened = false
894+ hasRestoredEdits = false
895+ bodyStamp = null
896+ bodyBase = null
897+ bodyLoads.clear()
898+ disarm(host)
899+ model = PaneState.afterNewSession(model)
900+
901+ sessionStartMs =
902+ (await startedAtOf(host)) ??
903+ (isResume ? sessionStartMs : await host.now())
904+
905+ if (isKeptOpen) {
906+ await pinBackend(host)
907+ void refresh(host)
908+ }
909+
910+ if (isResume) {
911+ void openOnRestore(host).catch(() => undefined)
912+ }
913+
816914 return result
817915 })
818916
819917 function afterTool(
820918 engine: Host,
821- tool: string,
919+ e: Args<'tool.call'>,
822920 result: ResultOf['tool.call'] | undefined,
823921 ) {
824- const isEdit = Tools.EDITING_TOOLS.some(name => name === tool)
922+ const isEdit = Tools.EDITING_TOOLS.some(name => name === e.tool)
825923
826924 const hasEdited =
827925 isEdit &&
@@ -829,14 +927,19 @@
829927 result.deny === undefined &&
830928 result.isError !== true
831929
832- const isStale =
833- isPaneOpen && (isEdit ? hasEdited : Tools.mayHaveWritten(result))
930+ const hasLanded = isEdit ? hasEdited : Tools.mayHaveWritten(result)
834931
835- if (isStale) {
932+ if (hasLanded) {
933+ landed += 1
934+ }
935+
936+ if (hasLanded && isPaneOpen) {
836937 scheduleRefresh(engine)
837938 }
838939
839- if (hasEdited) {
940+ const isMainLoopEdit = hasEdited && e.agentId === undefined
941+
942+ if (isMainLoopEdit) {
840943 void openOnFirstEdit(engine).catch(() => undefined)
841944 }
842945 }
@@ -853,7 +956,7 @@
853956 return result
854957 } finally {
855958 if (host) {
856- afterTool(host, e.tool, result)
959+ afterTool(host, e, result)
857960 }
858961 }
859962 },
@@ -1,0 +1,35 @@
1+import type { SessionMessage, Settings } from 'claude-code'
2+
3+/**
4+ * What the world beneath a repository session holds and answers besides
5+ * git: the store, the settings, the environment, the transcript, whether a
6+ * pane is seated.
7+ */
8+export type Beneath = {
9+ /**
10+ * What the plugin's store holds at the start; nothing when not given.
11+ */
12+ stored?: Readonly<Record<string, unknown>>
13+
14+ /**
15+ * What `$.settings.read` answers; empty when not given.
16+ */
17+ settings?: Settings
18+
19+ /**
20+ * The variables `$.env.get` answers from; none set when not given.
21+ */
22+ env?: Readonly<Record<string, string>>
23+
24+ /**
25+ * What `$.session.messages` answers each time it is read, as a resumed
26+ * session's transcript already holds turns; empty when not given.
27+ */
28+ messages?: () => readonly SessionMessage[]
29+
30+ /**
31+ * Whether an open made now is left waiting undrawn (LEFT_WAITING), as an
32+ * engine leaves an unasked open on a narrow terminal; placed when not given.
33+ */
34+ isLeftWaiting?: () => boolean
35+}
@@ -1,0 +1,5 @@
1+export type * from './beneath.js'
2+export * from './left-waiting.js'
3+export * from './slow-diff-ms.js'
4+
5+export * as default from '.'
@@ -1,0 +1,17 @@
1+import type { ResultOf } from 'claude-code'
2+
3+/**
4+ * What an engine answers an open it leaves waiting undrawn: the plugin
5+ * opened unasked on a terminal narrower than an unrequested pane is given.
6+ *
7+ * Typed through `never` so it compiles against declarations that predate the
8+ * answer, where `ui.open` resolves nothing.
9+ */
10+export const LEFT_WAITING: ResultOf['ui.open'] = {
11+ value: {
12+ isPlaced: false,
13+ reason:
14+ 'unasked below 144 columns (120 now): placed when the person opens ' +
15+ 'it, or when the terminal is widened to 144 columns',
16+ } as never,
17+}
@@ -1,0 +1,5 @@
1+/**
2+ * How long a slow repository takes to answer `git diff` in the tests that
3+ * time the pane's opening against its first fetch: well inside git's timeout.
4+ */
5+export const SLOW_DIFF_MS = 3000
@@ -1,0 +1,14 @@
1+import type { SessionMessage } from 'claude-code'
2+
3+import { editPatchOf } from './edit-patch-of.js'
4+import TurnDiffsOf from './turn-diffs-of'
5+
6+/**
7+ * A transcript a resumed session brings back whose one turn edited a file:
8+ * the prompt, the Edit that landed, its result.
9+ */
10+export const EDITED_TRANSCRIPT: readonly SessionMessage[] = [
11+ TurnDiffsOf.promptOf('change a to b'),
12+ TurnDiffsOf.editsOf(editPatchOf(['-a', '+b'])),
13+ TurnDiffsOf.toolResults(),
14+]
@@ -1,6 +1,7 @@
11 import type { Args, On } from 'claude-code'
22 import { mock } from 'claude-code/testing'
33
4+import Beneath from './beneath'
45 import { gitIn } from './git-in.js'
56 import { HINT_DRAWN } from './hint-drawn.js'
67 import { keeping } from './keeping.js'
@@ -11,24 +12,26 @@
1112 * A session in a repository git answers for from a script (REPOSITORY, in
1213 * /work, when none is given), keeping what the plugin does there.
1314 *
14- * Kept: each git run, ring move, pane opened or closed, status line. The clock
15- * starts at 0 and the engine draws the hint. A test that rewrites the
16- * script between calls changes what git answers next.
15+ * Kept: each git run, ring move, pane opened, left waiting or closed, status
16+ * line. The clock starts at 0 and the engine draws the hint; the rest of the
17+ * world is the test's (Beneath). Rewriting the script changes git's answers.
1718 *
1819 * @param on the test's `on`
1920 * @param script git's output for each invocation whose line holds the key
20- * @param stored what the plugin's store holds at the start
21- * @returns the runs, the ring's moves, the panes opened and closed, the
22- * statuses, the clock
21+ * @param beneath the store, settings, environment, transcript, whether an
22+ * open is seated
23+ * @returns the runs, the ring's moves, the panes opened, left waiting and
24+ * closed, the statuses, the clock
2325 */
2426 export function inRepository(
2527 on: On,
2628 script: Readonly<Record<string, string>> = REPOSITORY,
27- stored: Readonly<Record<string, unknown>> = {},
29+ beneath: Beneath.Beneath = {},
2830 ) {
2931 const runs: Args<'process.run'>[] = []
3032 const focused: Args<'ui.focus'>[] = []
3133 const statuses: (string | undefined)[] = []
34+ const waiting: Args<'ui.open'>[] = []
3235 const opened = keeping<Args<'ui.open'>>()
3336 const closed = keeping<Args<'ui.close'>>()
3437 const clock = startsSession(on)
@@ -51,17 +54,31 @@
5154 return { value: undefined }
5255 })
5356
54- on('ui.open', opened.hook)
57+ on('ui.open', (engine, e) => {
58+ const isWaiting = beneath.isLeftWaiting?.() === true
59+
60+ if (!isWaiting) {
61+ return opened.hook(engine, e)
62+ }
63+
64+ waiting.push(e)
65+
66+ return Beneath.LEFT_WAITING
67+ })
68+
5569 on('ui.close', closed.hook)
5670 on('ui.invalidate', () => ({ value: undefined }))
5771 on('ui.render', { component: 'PromptHint' }, () => HINT_DRAWN)
58- on('session.messages', () => ({ value: [] }))
59- mock.store(on, stored)
72+ on('session.messages', () => ({ value: [...(beneath.messages?.() ?? [])] }))
73+ on('settings.read', () => ({ value: beneath.settings ?? {} }))
74+ mock.store(on, beneath.stored ?? {})
75+ mock.env(on, beneath.env ?? {})
6076
6177 return {
6278 runs,
6379 focused,
6480 opened: opened.kept,
81+ waiting,
6582 closed: closed.kept,
6683 statuses,
6784 clock,
@@ -1,6 +1,7 @@
11 export * from './answers-of.js'
22 export * from './ask'
33 export * from './backend-of'
4+export * from './beneath'
45 export * from './big-lines.js'
56 export * from './branch-base-of'
67 export * from './builtin-holds.js'
@@ -12,6 +13,7 @@
1213 export * from './diff.js'
1314 export * from './diff-at.js'
1415 export * from './edit-patch-of.js'
16+export * from './edited-transcript.js'
1517 export * from './fetch-diff'
1618 export * from './git-hung.js'
1719 export * from './git-in.js'
@@ -42,6 +44,7 @@
4244 export * from './renamed.js'
4345 export * from './repository.js'
4446 export * from './repository-of'
47+export * from './resume.js'
4548 export * from './ring-onto.js'
4649 export * from './scripted'
4750 export * from './session.js'
@@ -54,6 +57,7 @@
5457 export * from './two-files.js'
5558 export * from './typed-prompt-of.js'
5659 export * from './unsaid-hint.js'
60+export * from './usage-at.js'
5761 export * from './vs-main.js'
5862 export * from './wheel-over-list.js'
5963 export * from './wheel-tick.js'
@@ -1,0 +1,12 @@
1+import type { CommandRunInput } from 'claude-code'
2+
3+/**
4+ * The command that brings an earlier conversation back, as the person types
5+ * it under the fullscreen layout on a 160-column terminal.
6+ */
7+export const RESUME: CommandRunInput = {
8+ command: 'resume',
9+ args: '',
10+ origin: { kind: 'composer' },
11+ presentation: { isFullscreen: true, columns: 160 },
12+}
@@ -1,0 +1,14 @@
1+/**
2+ * What `$.session.usage()` answers for a session that began at the given
3+ * moment: an empty window, no rate limits, nothing spent.
4+ *
5+ * Left untyped, so it compiles against declarations that predate `startedAt`.
6+ *
7+ * @param startedAt when the session began, by the test's clock
8+ * @returns the usage
9+ */
10+export const usageAt = (startedAt: number) => ({
11+ startedAt,
12+ context: { window: 200_000 },
13+ rateLimits: [],
14+})
@@ -94,4 +94,42 @@
9494 expect(drawn).toContain('moved.ts')
9595 expect(drawn).toContain('+1 file edited before this session (show)')
9696 })
97+
98+ test("the session began when the engine says: a resumed one's first start, again at /clear", async ($, on) => {
99+ let startedAt = 0
100+
101+ const clock = Fixtures.startsSession(on, Fixtures.SETTLE_MS)
102+
103+ on('process.run', ($, e) => ({
104+ value: Fixtures.gitIn(e.argv, Fixtures.MOVED_IN),
105+ }))
106+
107+ on('ui.open', () => ({ value: undefined }))
108+ on('ui.invalidate', () => ({ value: undefined }))
109+ on('session.messages', () => ({ value: [] }))
110+ on('session.usage', () => ({ value: Fixtures.usageAt(startedAt) }))
111+ on('command.run', { command: 'clear' }, () => ({}))
112+ Fixtures.oldFiles(on)
113+ mock.store(on)
114+
115+ await $.session.start(Fixtures.SESSION)
116+ await $.command.run(Fixtures.DIFF)
117+ await clock.advance(Fixtures.SETTLE_MS)
118+
119+ expect(
120+ Fixtures.textOf(await $.ui.render(Fixtures.PANE)),
121+ 'resumed from before the old files were written: both are the ' +
122+ "session's, as the built-in restores its start",
123+ ).toContain('2 files changed +2 -2')
124+
125+ startedAt = clock.now()
126+
127+ await $.command.run(Fixtures.CLEAR)
128+ await clock.advance(Fixtures.SETTLE_MS)
129+
130+ expect(
131+ Fixtures.textOf(await $.ui.render(Fixtures.PANE)),
132+ '/clear started the session over, past the dirty file',
133+ ).toContain('+1 file edited before this session (show)')
134+ })
97135 })
@@ -1,4 +1,4 @@
1-import type { Args, ResultOf } from 'claude-code'
1+import type { Args, ResultOf, SessionMessage } from 'claude-code'
22 import { describe, expect, mock, test, tier } from 'claude-code/testing'
33
44 import Limits from '../hooks/limits'
@@ -483,18 +483,372 @@
483483 ).toEqual(['diff'])
484484 })
485485
486- test('/clear closes the pane it finds open', async ($, on) => {
486+ test("only the main loop's edit opens the pane", async ($, on) => {
487487 const world = Fixtures.inRepository(on)
488488
489+ const edit = (agentId?: string) =>
490+ $.tool.call({
491+ tool: 'Write',
492+ file_path: '/work/app.ts',
493+ content: '2',
494+ ...(agentId !== undefined && { agentId }),
495+ })
496+
497+ on('tool.call', () => ({ result: 'written' }))
498+
499+ await $.session.start(Fixtures.SESSION)
500+ await $.ui.render(Fixtures.HINT)
501+ await edit('a-explorer')
502+ await world.clock.advance(Fixtures.SETTLE_MS)
503+
504+ expect(world.opened, 'the built-in checkpoints no subagent edit').toEqual(
505+ [],
506+ )
507+
508+ expect(world.runs, 'so nothing was asked of the repository').toEqual([])
509+
510+ await edit()
511+ await world.clock.advance(Fixtures.SETTLE_MS)
512+
513+ expect(
514+ world.opened.map(pane => pane.id),
515+ "the main loop's",
516+ ).toEqual(['diff'])
517+ })
518+
519+ test('checkpointing off in settings: edits open nothing', async ($, on) => {
520+ const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
521+ settings: { fileCheckpointingEnabled: false },
522+ })
523+
524+ on('tool.call', () => ({ result: 'edited' }))
525+
526+ await $.session.start(Fixtures.SESSION)
527+ await $.ui.render(Fixtures.HINT)
528+
529+ await $.tool.call({
530+ tool: 'Edit',
531+ file_path: '/work/app.ts',
532+ old_string: '1',
533+ new_string: '2',
534+ })
535+
536+ await world.clock.advance(Fixtures.SETTLE_MS)
537+
538+ expect(world.opened, 'the built-in opens on a checkpoint alone').toEqual([])
539+
540+ expect(world.runs, 'so nothing was asked of the repository').toEqual([])
541+
542+ expect(await $.command.run(Fixtures.DIFF)).toEqual({
543+ text: 'Diff panel shown',
544+ })
545+ })
546+
547+ test('checkpointing off by variable: edits open nothing', async ($, on) => {
548+ const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
549+ env: { CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING: 'true' },
550+ })
551+
552+ on('tool.call', () => ({ result: 'edited' }))
553+
554+ await $.session.start(Fixtures.SESSION)
555+ await $.ui.render(Fixtures.HINT)
556+
557+ await $.tool.call({
558+ tool: 'Edit',
559+ file_path: '/work/app.ts',
560+ old_string: '1',
561+ new_string: '2',
562+ })
563+
564+ await world.clock.advance(Fixtures.SETTLE_MS)
565+
566+ expect(world.opened).toEqual([])
567+ })
568+
569+ test('an open left waiting is withdrawn; /diff opens it', async ($, on) => {
570+ let isNarrow = true
571+
572+ const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
573+ stored: { [Names.STORE_OPEN_KEY]: true },
574+ isLeftWaiting: () => isNarrow,
575+ })
576+
577+ const edit = () =>
578+ $.tool.call({
579+ tool: 'Edit',
580+ file_path: '/work/app.ts',
581+ old_string: '1',
582+ new_string: '2',
583+ })
584+
585+ on('tool.call', () => ({ result: 'edited' }))
586+
587+ await $.session.start(Fixtures.SESSION)
588+ await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
589+ await edit()
590+ await world.clock.advance(Fixtures.SETTLE_MS)
591+
592+ expect(
593+ world.waiting.map(pane => pane.id),
594+ 'the edit opened',
595+ ).toEqual(['diff'])
596+
597+ expect(
598+ world.closed.map(pane => pane.id),
599+ 'and withdrew the pane the engine left waiting, so no resize seats it',
600+ ).toEqual(['diff'])
601+
602+ expect(
603+ world.runs.map(run => Fixtures.gitWordOf(run.argv)).slice(0, 2),
604+ 'the repository was found and read once before the open, as the ' +
605+ 'built-in primes; nothing polls for a pane no one sees',
606+ ).toEqual(['rev-parse --show-toplevel', 'status'])
607+
608+ expect(
609+ world.runs.map(run => Fixtures.gitWordOf(run.argv)),
610+ 'no HEAD poll for a withdrawn pane',
611+ ).not.toContain(Fixtures.POLL_WORD)
612+
613+ isNarrow = false
614+
615+ expect(
616+ await $.command.run(Fixtures.diffAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)),
617+ '/diff opens it, where a pane believed open would have been hidden',
618+ ).toEqual({ text: 'Diff panel shown' })
619+
620+ await $.command.run(Fixtures.diffAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
621+ await edit()
622+ await world.clock.advance(Fixtures.SETTLE_MS)
623+
624+ expect(
625+ world.opened.map(pane => pane.id),
626+ 'closed by hand, no edit opens it again',
627+ ).toEqual(['diff'])
628+ })
629+
630+ test('an unplaced open is tried again at the next edit', async ($, on) => {
631+ let isNarrow = true
632+
633+ const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
634+ stored: { [Names.STORE_OPEN_KEY]: true },
635+ isLeftWaiting: () => isNarrow,
636+ })
637+
638+ const edit = () =>
639+ $.tool.call({
640+ tool: 'Edit',
641+ file_path: '/work/app.ts',
642+ old_string: '1',
643+ new_string: '2',
644+ })
645+
646+ on('tool.call', () => ({ result: 'edited' }))
647+
648+ await $.session.start(Fixtures.SESSION)
649+ await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
650+ await edit()
651+ await world.clock.advance(Fixtures.SETTLE_MS)
652+
653+ expect(world.opened, 'the first try waited and was withdrawn').toEqual([])
654+
655+ isNarrow = false
656+
657+ await edit()
658+ await world.clock.advance(Fixtures.SETTLE_MS)
659+
660+ expect(
661+ world.opened.map(pane => pane.id),
662+ 'the next edit opened it, as the built-in reads the width again then',
663+ ).toEqual(['diff'])
664+ })
665+
666+ test('a docked pane opens once its first fetch settled', async ($, on) => {
667+ const opened: string[] = []
668+ const clock = Fixtures.startsSession(on)
669+
670+ on('process.run', async ($, e) => {
671+ if (e.argv.includes('--shortstat')) {
672+ await clock.sleep(Fixtures.SLOW_DIFF_MS)
673+ }
674+
675+ return { value: Fixtures.gitIn(e.argv) }
676+ })
677+
678+ on('ui.open', ($, e) => {
679+ opened.push(e.id)
680+
681+ return { value: undefined }
682+ })
683+
684+ on('ui.close', () => ({ value: undefined }))
685+ on('ui.invalidate', () => ({ value: undefined }))
686+ on('ui.render', { component: 'PromptHint' }, () => Fixtures.HINT_DRAWN)
687+ on('session.messages', () => ({ value: [] }))
688+ on('settings.read', () => ({ value: {} }))
689+ on('tool.call', () => ({ result: 'edited' }))
690+ mock.store(on)
691+ mock.env(on, {})
692+
693+ await $.session.start(Fixtures.SESSION)
694+ await $.ui.render(Fixtures.HINT)
695+
696+ await $.tool.call({
697+ tool: 'Edit',
698+ file_path: '/work/app.ts',
699+ old_string: '1',
700+ new_string: '2',
701+ })
702+
703+ await clock.advance(Fixtures.SLOW_DIFF_MS - 1)
704+
705+ expect(opened, 'git has not answered: no pane, no Loading frame').toEqual(
706+ [],
707+ )
708+
709+ await clock.advance(Fixtures.SETTLE_MS)
710+
711+ expect(opened, 'the fetch settled: the pane opens filled').toEqual(['diff'])
712+
713+ const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE))
714+
715+ expect(drawn).toContain('1 file changed')
716+ expect(drawn).not.toContain('Loading diff')
717+ })
718+
719+ test('/clear leaves the pane it finds open up and reads the repository afresh', async ($, on) => {
720+ const world = Fixtures.inRepository(on)
721+
489722 on('command.run', { command: 'clear' }, () => ({}))
490723
491724 await $.session.start(Fixtures.SESSION)
492725 await $.command.run(Fixtures.DIFF)
726+ await world.clock.advance(Fixtures.SETTLE_MS)
727+
728+ const read = world.runs.length
729+
493730 await $.command.run(Fixtures.CLEAR)
731+ await world.clock.advance(Fixtures.SETTLE_MS)
494732
495- expect(world.closed.map(pane => pane.id)).toEqual(['diff'])
733+ expect(world.closed, 'as the built-in panel stays across /clear').toEqual(
734+ [],
735+ )
736+
737+ expect(
738+ world.runs.slice(read).map(run => Fixtures.gitWordOf(run.argv))[0],
739+ 'the repository found again for the conversation that starts over',
740+ ).toBe('rev-parse --show-toplevel')
741+
742+ expect(
743+ Fixtures.textOf(await $.ui.render(Fixtures.PANE)),
744+ 'drawn from the fresh read',
745+ ).toContain('1 file changed')
496746 })
497747
748+ test('a resumed session whose turns edited opens the pane before any new edit', async ($, on) => {
749+ const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
750+ messages: () => Fixtures.EDITED_TRANSCRIPT,
751+ })
752+
753+ await $.session.start(Fixtures.SESSION)
754+
755+ expect(world.runs, 'the start itself still runs no git').toEqual([])
756+
757+ await $.ui.render(Fixtures.HINT)
758+ await world.clock.advance(Fixtures.SETTLE_MS)
759+
760+ expect(
761+ world.opened.map(pane => pane.id),
762+ 'open once the width is known, as the built-in opens on the restore',
763+ ).toEqual(['diff'])
764+ })
765+
766+ test('a resumed session opens nothing where its first edit would not', async ($, on) => {
767+ const narrow = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
768+ messages: () => Fixtures.EDITED_TRANSCRIPT,
769+ })
770+
771+ await $.session.start(Fixtures.SESSION)
772+ await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
773+ await narrow.clock.advance(Fixtures.SETTLE_MS)
774+
775+ expect(narrow.opened, 'under the unasked floor, never kept open').toEqual(
776+ [],
777+ )
778+
779+ expect(narrow.runs, 'and no git for it').toEqual([])
780+ })
781+
782+ test('a resumed session the person kept the pane open in opens it from the lower floor', async ($, on) => {
783+ const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
784+ messages: () => Fixtures.EDITED_TRANSCRIPT,
785+ stored: { [Names.STORE_OPEN_KEY]: true },
786+ })
787+
788+ await $.session.start(Fixtures.SESSION)
789+ await $.ui.render(Fixtures.hintAt(Limits.OPEN_MIN_COLUMNS))
790+ await world.clock.advance(Fixtures.SETTLE_MS)
791+
792+ expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
793+ })
794+
795+ test('a resumed session with no edits opens nothing', async ($, on) => {
796+ const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
797+ messages: () => [Fixtures.promptOf('just talk')],
798+ })
799+
800+ await $.session.start(Fixtures.SESSION)
801+ await $.ui.render(Fixtures.HINT)
802+ await world.clock.advance(Fixtures.SETTLE_MS)
803+
804+ expect(world.opened).toEqual([])
805+ expect(world.runs).toEqual([])
806+ })
807+
808+ test('a resumed session whose pane the person closed opens nothing', async ($, on) => {
809+ const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
810+ messages: () => Fixtures.EDITED_TRANSCRIPT,
811+ stored: { [Names.STORE_OPEN_KEY]: false },
812+ })
813+
814+ await $.session.start(Fixtures.SESSION)
815+ await $.ui.render(Fixtures.HINT)
816+ await world.clock.advance(Fixtures.SETTLE_MS)
817+
818+ expect(world.opened).toEqual([])
819+ expect(world.runs).toEqual([])
820+ })
821+
822+ test('/resume closes the pane, then opens it for the turns it brought back', async ($, on) => {
823+ let transcript: readonly SessionMessage[] = []
824+
825+ const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
826+ messages: () => transcript,
827+ })
828+
829+ on('command.run', { command: 'resume' }, () => {
830+ transcript = Fixtures.EDITED_TRANSCRIPT
831+
832+ return {}
833+ })
834+
835+ await $.session.start(Fixtures.SESSION)
836+ await $.ui.render(Fixtures.HINT)
837+ await $.command.run(Fixtures.DIFF)
838+ await world.clock.advance(Fixtures.SETTLE_MS)
839+ await $.command.run(Fixtures.RESUME)
840+ await world.clock.advance(Fixtures.SETTLE_MS)
841+
842+ expect(world.closed.map(pane => pane.id), 'closed at /resume').toEqual([
843+ 'diff',
844+ ])
845+
846+ expect(
847+ world.opened.map(pane => pane.id),
848+ '/diff opened it, /resume opened it again for the restored turns',
849+ ).toEqual(['diff', 'diff'])
850+ })
851+
498852 test('ask attaches the hunks on screen and calls no tool', async ($, on) => {
499853 const world = Fixtures.inRepository(on, Fixtures.oneSecret())
500854 const called: Args<'tool.call'>[] = []
@@ -563,7 +917,7 @@
563917
564918 test('a worktree opens on the base it kept', async ($, on) => {
565919 const world = Fixtures.inRepository(on, Fixtures.oneSecret(), {
566- 'base:/main/wt': 'uncommitted',
920+ stored: { 'base:/main/wt': 'uncommitted' },
567921 })
568922
569923 await $.session.start(Fixtures.WORKTREE_SESSION)
@@ -1,7 +1,7 @@
11 {
22 "name": "telemetry",
33 "version": "0.1.0",
4- "description": "Plugin analytics: adds $.telemetry in the engine.create fold, so a plugin logs an event or marks a feature's use as one first-party row per call, sent with the session's own credential.",
4+ "description": "Plugin analytics: adds $.telemetry in the engine.create fold, so a plugin logs an event or marks a feature's use as a first-party row, sent in batches with the session's own credential.",
55 "author": {
66 "name": "Anthropic"
77 },
@@ -1,32 +1,55 @@
11 # telemetry
22
33 Plugin analytics as a plugin: one `engine.create` step adds `$.telemetry` to
4-the engine interface every plugin above it is handed, built over the
5-`$.session` and `$.http` nouns beneath. `$.telemetry.log({ event, props })`
6-sends one event as one first-party row, `tengu_plugin_<event>`;
7-`$.telemetry.mark({ feature, kind, reason?, props? })` marks one use of a
8-feature as the CLI's own feature events do, `tengu_feature_<kind>` with a
9-`feature_name` and the mark's properties beside it. Each call is one POST to
10-the event-logging ingest with the session's own credential
11-(`$.session.authorize()`, resolved at each call), one attempt, nothing
12-batched; a session with no first-party credential, or an ingest that
13-refuses, rejects the caller's promise.
4+the engine interface every plugin above it is handed, built over the nouns
5+beneath, and a hook on its own two events serves the plugins built into
6+Claude Code alone: a call from a plugin a person installed or an
7+administrator listed is refused with a reason (the host stamps every call
8+with the plugin that raised it, `next.origin`, and the gate reads its tier).
9+`$.telemetry.log({ event, props })` queues one event as one first-party
10+row, `tengu_plugin_<event>`; `$.telemetry.mark({ feature, kind, reason?,
11+props? })` marks one use of a feature as the CLI's own feature events do,
12+`tengu_feature_<kind>` with a `feature_name` and the mark's properties
13+beside it. Both resolve once the row is queued. Rows go out in batches: one
14+POST to the event-logging ingest with the session's own credential
15+(`$.session.authorize()`, resolved for each batch) a few seconds after the
16+first row was queued, at once when a hundred wait, and when the session
17+ends; a batch the ingest refuses with a server error, a timeout or a rate
18+limit is tried once more. A session with no first-party credential, or an
19+ingest that still refuses, drops the batch; each outcome is one line in the
20+debug log.
1421
22+Each row carries what the CLI's own rows carry, gathered through `$` once
23+a session: an event id, the install's device id and the signed-in account's
24+ids from the CLI's global config, the session's id, model, client type,
25+entrypoint and interactivity, and an `env` block (platform and
26+architecture from one `uname` probe, terminal, shell, package managers and
27+runtimes, CI and GitHub Actions, the remote container, the deployment, the
28+Linux distribution and kernel, WSL, the working directory's version
29+control), with the repository's remote hash beside the row's properties.
30+What the engine alone knows (its version and build time, its runtime's
31+version, the process's memory, the request's betas, the subscription tier,
32+the calling agent) is not on `$`, and those columns stay empty.
33+
1534 It sends nothing wherever the CLI's own analytics are off: under
1635 `DISABLE_TELEMETRY`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` or
17-`DO_NOT_TRACK`, on any third-party provider (Bedrock, Vertex, Foundry and
18-kin), and on a deployment with its own OAuth URL. Each is read through
19-`$.env` at every call, rows go one after another, and the credential is
20-authorized afresh right before each POST, so a session that has since moved
21-to a third-party provider or a cloud gateway sends nothing more. The row's
22-`user_type` is `ant` when `USER_TYPE` says so, else `external`.
36+`DO_NOT_TRACK`, in a test run, on any third-party provider (Bedrock,
37+Vertex, Foundry and kin) the host does not manage, on a cloud gateway
38+(the environment's switch or the managed policy's login pins), and on a
39+deployment with its own OAuth URL. Each is read through `$.env` and
40+`$.settings` before every batch, so a session that has since moved to a
41+third-party provider or a gateway sends nothing more; when the switches
42+cannot be read, nothing is sent either. The row's `user_type` is `ant`
43+when `USER_TYPE` says so, else `external`.
2344
2445 Nothing free-form reaches a row. An event name and every property key is a
2546 snake_case token; a value is a finite number, a boolean, or a Choice (a
2647 string named together with the list it is chosen from), under `log` and
2748 `mark` alike; `mark` takes `ok`, `sad` or `bad`, with a `reason` required on
2849 the last two and refused on the first. An entry that breaks a rule is
29-refused before anything is sent.
50+refused before anything is queued. Of the environment, a variable whose
51+value is a secret, or names a person or a host, is read for whether it is
52+set and nothing more; a shell is its basename from a closed list.
3053
3154 `hooks/register.ts` is the module; `types/index.d.ts` is the noun's contract,
3255 the one declaration of `$.telemetry` that this mod's hooks, a mod calling the
@@ -35,20 +58,26 @@
3558 ## What it hooks
3659
3760 `engine.create`: `{ ...await next(e), telemetry }`, so the noun is added and
38-nothing beneath is replaced.
61+nothing beneath is replaced. `telemetry.*`, the gate: a caller in the
62+built-in tier (or the engine) goes on, any other is refused, and a gate
63+that throws refuses too. `session.start`, to learn whether a person is at
64+the prompt; `session.end`, to send what still waits.
3965
4066 ## What it calls on `$`
4167
42-`session.authorize`, `session.id`, `session.model`, `http.fetch`, `env.get`
43-(the switches above and `USER_TYPE`, by literal name), each on the
44-interface the fold handed it.
68+`session.authorize`, `session.id`, `session.model`, `session.surfaces`,
69+`session.cwd`, `session.repo`, `settings.read`, `env.get` (the switches and
70+the describing variables, by literal name), `fs.read`, `fs.list`,
71+`fs.exists`, `process.run` (one `sh -c` of `uname` and `command -v`),
72+`clock.after`, `clock.sleep`, `http.fetch` and `ui.log` (to the debug log),
73+each on the interface the fold handed it.
4574
46-## Where it runs
75+## Where it runs, whom it serves
4776
48-This plugin is seated by the CLI itself, on internal builds whose own
49-analytics are on, and nowhere else: `session.authorize` exists only there,
50-and the rows it writes join tables only the CLI's own events reach. It is
51-not meant to be installed or loaded with `--plugin-dir`; the folder has a
52-manifest so it reads like every other plugin, not so it can stand alone. A
53-plugin that calls `$.telemetry` where this one is absent finds no such noun
54-and should treat that as "no analytics here".
77+This plugin is seated by the CLI itself, on every build whose own analytics
78+are on, and nowhere else; it serves the plugins bundled with the CLI and
79+refuses every other caller. It is not meant to be installed or loaded with
80+`--plugin-dir`; the folder has a manifest so it reads like every other
81+plugin, not so it can stand alone. A built-in that calls `$.telemetry`
82+where this one is absent finds no such noun and should treat that as "no
83+analytics here".
@@ -1,0 +1,5 @@
1+/**
2+ * The most rows one batch carries: a queue this full goes out at once
3+ * rather than on the timer.
4+ */
5+export const BATCH_ROWS = 100
@@ -1,0 +1,3 @@
1+export * from './batch-rows.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * How long a queued row waits for company before its batch goes out, in
3+ * milliseconds; a session that ends sooner sends what waits then.
4+ */
5+export const BATCH_WINDOW_MS = 5000
@@ -1,0 +1,3 @@
1+export * from './batch-window-ms.js'
2+
3+export * as default from '.'
@@ -1,0 +1,8 @@
1+export * from './batch-rows'
2+export * from './batch-window-ms'
3+export * from './is-retriable'
4+export * from './message-of'
5+export * from './pending-row'
6+export * from './retry-delay-ms'
7+
8+export * as default from '.'
@@ -1,0 +1,6 @@
1+export * from './is-retriable.js'
2+export * from './status-request-timeout'
3+export * from './status-server-error'
4+export * from './status-too-many-requests'
5+
6+export * as default from '.'
@@ -1,0 +1,15 @@
1+import { STATUS_REQUEST_TIMEOUT } from './status-request-timeout'
2+import { STATUS_SERVER_ERROR } from './status-server-error'
3+import { STATUS_TOO_MANY_REQUESTS } from './status-too-many-requests'
4+
5+/**
6+ * Whether an answer from the ingest is worth the one retry: a server error,
7+ * a timeout or a rate limit; anything else it refused stays refused.
8+ *
9+ * @param status the HTTP status the ingest answered
10+ * @returns true for 5xx, 408 and 429
11+ */
12+export const isRetriable = (status: number) =>
13+ status >= STATUS_SERVER_ERROR ||
14+ status === STATUS_REQUEST_TIMEOUT ||
15+ status === STATUS_TOO_MANY_REQUESTS
@@ -1,0 +1,3 @@
1+export * from './status-request-timeout.js'
2+
3+export * as default from '.'
@@ -1,0 +1,4 @@
1+/**
2+ * HTTP 408: the ingest gave up waiting for the request; worth the one retry.
3+ */
4+export const STATUS_REQUEST_TIMEOUT = 408
@@ -1,0 +1,3 @@
1+export * from './status-server-error.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * HTTP 500, the first server-side status: it and everything above is the
3+ * ingest's own failure and worth the one retry.
4+ */
5+export const STATUS_SERVER_ERROR = 500
@@ -1,0 +1,3 @@
1+export * from './status-too-many-requests.js'
2+
3+export * as default from '.'
@@ -1,0 +1,4 @@
1+/**
2+ * HTTP 429: the ingest is shedding load; worth the one retry after a pause.
3+ */
4+export const STATUS_TOO_MANY_REQUESTS = 429
@@ -1,0 +1,3 @@
1+export * from './message-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,9 @@
1+/**
2+ * What a batch's failure says, for the refusal each of its rows rejects
3+ * with: the error's own message, or the value as text.
4+ *
5+ * @param error what sending the batch threw
6+ * @returns the message
7+ */
8+export const messageOf = (error: unknown) =>
9+ error instanceof Error ? error.message : String(error)
@@ -1,0 +1,3 @@
1+export type * from './pending-row.js'
2+
3+export * as default from '.'
@@ -1,0 +1,11 @@
1+import type Entries from '../../entries'
2+
3+/**
4+ * One row waiting for its batch: the checked fields, and the id and time
5+ * it was logged with.
6+ */
7+export type PendingRow = {
8+ fields: Entries.Fields
9+ eventId: string
10+ loggedAt: string
11+}
@@ -1,0 +1,3 @@
1+export * from './retry-delay-ms.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * How long the one retry waits after a POST that failed or was answered with
3+ * a status worth retrying, in milliseconds.
4+ */
5+export const RETRY_DELAY_MS = 500
@@ -1,0 +1,12 @@
1+/**
2+ * Where the CLI's global config file is, read off the environment; read to
3+ * find the file, never sent.
4+ *
5+ * The config directory when one is named, else the home directory (the
6+ * profile directory on Windows).
7+ */
8+export type ConfigLocation = {
9+ readonly configDir: string | undefined
10+ readonly home: string | undefined
11+ readonly userProfile: string | undefined
12+}
@@ -1,0 +1,3 @@
1+export type * from './config-location.js'
2+
3+export * as default from '.'
@@ -1,0 +1,12 @@
1+/**
2+ * The client types the CLI names by entrypoint alone: the SDKs, the VS Code
3+ * and desktop hosts, and the local agent.
4+ */
5+export const CLIENT_TYPE_BY_ENTRYPOINT: Readonly<Record<string, string>> = {
6+ 'sdk-ts': 'sdk-typescript',
7+ 'sdk-py': 'sdk-python',
8+ 'sdk-cli': 'sdk-cli',
9+ 'claude-vscode': 'claude-vscode',
10+ 'local-agent': 'local-agent',
11+ 'claude-desktop': 'claude-desktop',
12+}
@@ -1,0 +1,3 @@
1+export * from './client-type-by-entrypoint.js'
2+
3+export * as default from '.'
@@ -1,0 +1,26 @@
1+import type { Facts } from '../../facts'
2+import IsAnalyticsOff from '../../is-analytics-off'
3+import { CLIENT_TYPE_BY_ENTRYPOINT } from './client-type-by-entrypoint'
4+
5+/**
6+ * The row's `client_type` as the CLI decides it at start: a GitHub Action,
7+ * an SDK or host by entrypoint, `remote` with an ingress token, else `cli`.
8+ *
9+ * @param facts the variables as read once for the session
10+ * @returns the client type
11+ */
12+export function clientTypeOf(facts: Facts) {
13+ const isRemote =
14+ facts.entrypoint === 'remote' ||
15+ facts.hasSessionAccessToken ||
16+ facts.hasSessionIngressTokenFile ||
17+ facts.hasWebsocketAuthFileDescriptor ||
18+ IsAnalyticsOff.isEnvTruthy(facts.claudeCodeRemote)
19+
20+ const isGithubAction = IsAnalyticsOff.isEnvTruthy(facts.githubActions)
21+
22+ return isGithubAction
23+ ? 'github-action'
24+ : (CLIENT_TYPE_BY_ENTRYPOINT[facts.entrypoint ?? ''] ??
25+ (isRemote ? 'remote' : 'cli'))
26+}
@@ -1,0 +1,4 @@
1+export * from './client-type-by-entrypoint'
2+export * from './client-type-of.js'
3+
4+export * as default from '.'
@@ -1,0 +1,17 @@
1+import type { EnvironmentFields } from '../environment-fields'
2+import type { Identity } from '../identity'
3+import type { SessionFields } from '../session-fields'
4+
5+/**
6+ * Everything a row carries that holds for the whole session, gathered once
7+ * before the first batch.
8+ *
9+ * Who it is from, the session's constant fields, its `env` block, and the
10+ * repository's remote hash for the metadata.
11+ */
12+export type Context = {
13+ readonly identity: Identity
14+ readonly session: SessionFields
15+ readonly environment: EnvironmentFields
16+ readonly remoteHash: string | undefined
17+}
@@ -1,0 +1,3 @@
1+export type * from './context.js'
2+
3+export * as default from '.'
@@ -1,0 +1,30 @@
1+import type { TelemetryDeps } from '../telemetry-deps'
2+import type { Context } from './context'
3+import { environmentFieldsOf } from './environment-fields-of'
4+import { probeOf } from './probe-of'
5+import { sessionFieldsOf } from './session-fields-of'
6+
7+/**
8+ * Gathers and shapes what holds for the whole session, once: who the rows
9+ * are from, the session's fields, its `env` block and the remote hash.
10+ *
11+ * Nothing in it is a secret or a path: every field is the closed value the
12+ * CLI's own column holds.
13+ *
14+ * @param deps the calls on the nouns beneath
15+ * @param isInteractive whether a person is at the prompt
16+ * @returns the session's context for every batch after
17+ */
18+export async function contextOf(
19+ deps: TelemetryDeps,
20+ isInteractive: boolean,
21+): Promise<Context> {
22+ const probe = await probeOf(deps, isInteractive)
23+
24+ return {
25+ identity: probe.identity,
26+ session: sessionFieldsOf(probe.facts),
27+ environment: environmentFieldsOf(probe),
28+ remoteHash: probe.remoteHash,
29+ }
30+}
@@ -1,0 +1,13 @@
1+/**
2+ * The row's `entrypoint` field: the variable as the CLI or its host set it
3+ * when spelled like one (a short lowercase token), else `other`.
4+ *
5+ * @param variable CLAUDE_CODE_ENTRYPOINT as read; undefined stays so
6+ * @returns the field, or undefined
7+ */
8+export function entrypointOf(variable: string | undefined) {
9+ const isSpelled =
10+ variable === undefined || /^[a-z][a-z0-9_-]{0,39}$/.test(variable)
11+
12+ return isSpelled ? variable : 'other'
13+}
@@ -1,0 +1,3 @@
1+export * from './entrypoint-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,41 @@
1+import type { GithubActionsFields } from './github-actions-fields'
2+
3+/**
4+ * The `env` block every row of the session carries, as the CLI's own rows
5+ * carry it, before its keys are spelled for the wire (Entries.wireOf).
6+ *
7+ * The machine, the terminal and shell, CI and GitHub Actions, the remote
8+ * container, the deployment. `isClaudeAiAuth` follows the credential each
9+ * batch is sent with and joins these then; an absent field is undefined.
10+ */
11+export type EnvironmentFields = {
12+ readonly platform: string
13+ readonly platformRaw: string
14+ readonly arch: string
15+ readonly terminal: string
16+ readonly shell: string
17+ readonly packageManagers: string
18+ readonly runtimes: string
19+ readonly isCi: boolean
20+ readonly isClaubbit: boolean
21+ readonly isGithubAction: boolean
22+ readonly isClaudeCodeAction: boolean
23+ readonly isClaudeCodeRemote: boolean
24+ readonly isLocalAgentMode: boolean
25+ readonly isConductor: boolean
26+ readonly deploymentEnvironment: string
27+ readonly remoteEnvironmentType: string | undefined
28+ readonly claudeCodeContainerId: string | undefined
29+ readonly claudeCodeRemoteSessionId: string | undefined
30+ readonly tags: readonly string[] | undefined
31+ readonly githubEventName: string | undefined
32+ readonly githubActionsRunnerEnvironment: string | undefined
33+ readonly githubActionsRunnerOs: string | undefined
34+ readonly githubActionRef: string | undefined
35+ readonly githubActionsMetadata: GithubActionsFields | undefined
36+ readonly wslVersion: string | undefined
37+ readonly linuxDistroId: string | undefined
38+ readonly linuxDistroVersion: string | undefined
39+ readonly linuxKernel: string | undefined
40+ readonly vcs: string | undefined
41+}
@@ -1,0 +1,9 @@
1+/**
2+ * The GitHub Actions ids a row's `env` block carries when the session runs
3+ * in a workflow: the actor, the repository and its owner, by number.
4+ */
5+export type GithubActionsFields = {
6+ readonly actorId: string | undefined
7+ readonly repositoryId: string | undefined
8+ readonly repositoryOwnerId: string | undefined
9+}
@@ -1,0 +1,3 @@
1+export type * from './github-actions-fields.js'
2+
3+export * as default from '.'
@@ -1,0 +1,4 @@
1+export type * from './environment-fields.js'
2+export * from './github-actions-fields'
3+
4+export * as default from '.'
@@ -1,0 +1,11 @@
1+/**
2+ * The row's `github_action_ref`: what follows `claude-code-action/` in the
3+ * action's path, as the CLI reads it; undefined when the path names none.
4+ *
5+ * @param actionPath GITHUB_ACTION_PATH as read
6+ * @returns the ref, or undefined
7+ */
8+export const actionRefOf = (actionPath: string | undefined) =>
9+ actionPath?.includes('claude-code-action/')
10+ ? actionPath.split('claude-code-action/')[1]
11+ : undefined
@@ -1,0 +1,3 @@
1+export * from './action-ref-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,60 @@
1+import Deployment from '../../deployment'
2+import IsAnalyticsOff from '../../is-analytics-off'
3+import type { EnvironmentFields } from '../environment-fields'
4+import type { Probe } from '../probe'
5+import { shellOf } from '../shell-of'
6+import { terminalOf } from '../terminal-of'
7+import { actionRefOf } from './action-ref-of'
8+import { githubActionsFieldsOf } from './github-actions-fields-of'
9+import { tagsOf } from './tags-of'
10+
11+/**
12+ * The rows' `env` block from what was probed, valued as the CLI's own is;
13+ * the GitHub Actions fields only inside a workflow, the Linux ones on Linux.
14+ *
15+ * @param probe what was gathered for the session
16+ * @returns the block
17+ */
18+export function environmentFieldsOf(probe: Probe): EnvironmentFields {
19+ const { facts, machine, platform } = probe
20+ const isGithubAction = IsAnalyticsOff.isEnvTruthy(facts.githubActions)
21+ const isLinux = machine.platformRaw === 'linux'
22+
23+ return {
24+ platform,
25+ platformRaw: machine.platformRaw,
26+ arch: machine.arch,
27+ terminal: terminalOf(facts, platform, probe.isInteractive),
28+ shell: shellOf(facts.shellPath),
29+ packageManagers: machine.packageManagers,
30+ runtimes: machine.runtimes,
31+ isCi: IsAnalyticsOff.isEnvTruthy(facts.ci),
32+ isClaubbit: IsAnalyticsOff.isEnvTruthy(facts.claubbit),
33+ isGithubAction,
34+ isClaudeCodeAction: IsAnalyticsOff.isEnvTruthy(facts.claudeCodeAction),
35+ isClaudeCodeRemote: IsAnalyticsOff.isEnvTruthy(facts.claudeCodeRemote),
36+ isLocalAgentMode: facts.entrypoint === 'local-agent',
37+ isConductor: facts.bundleIdentifier === 'com.conductor.app',
38+ deploymentEnvironment: Deployment.deploymentOf(probe.signals, platform),
39+ remoteEnvironmentType: facts.remoteEnvironmentType,
40+ claudeCodeContainerId: facts.containerId,
41+ claudeCodeRemoteSessionId: facts.remoteSessionId,
42+ tags: tagsOf(facts.tags),
43+ githubEventName: isGithubAction ? facts.githubEventName : undefined,
44+ githubActionsRunnerEnvironment: isGithubAction
45+ ? facts.runnerEnvironment
46+ : undefined,
47+ githubActionsRunnerOs: isGithubAction ? facts.runnerOs : undefined,
48+ githubActionRef: isGithubAction
49+ ? actionRefOf(facts.githubActionPath)
50+ : undefined,
51+ githubActionsMetadata: isGithubAction
52+ ? githubActionsFieldsOf(facts)
53+ : undefined,
54+ wslVersion: probe.wslVersion,
55+ linuxDistroId: probe.distro.id,
56+ linuxDistroVersion: probe.distro.version,
57+ linuxKernel: isLinux ? machine.kernel : undefined,
58+ vcs: probe.vcs,
59+ }
60+}
@@ -1,0 +1,26 @@
1+import type { Facts } from '../../../facts'
2+import type EnvironmentFields from '../../environment-fields'
3+
4+/**
5+ * The GitHub Actions ids for the row's `env` block; undefined when the
6+ * workflow names none of the three.
7+ *
8+ * @param facts the variables as read once for the session
9+ * @returns the ids, or undefined
10+ */
11+export function githubActionsFieldsOf(
12+ facts: Facts,
13+): EnvironmentFields.GithubActionsFields | undefined {
14+ const hasAny =
15+ facts.githubActorId !== undefined ||
16+ facts.githubRepositoryId !== undefined ||
17+ facts.githubRepositoryOwnerId !== undefined
18+
19+ return hasAny
20+ ? {
21+ actorId: facts.githubActorId,
22+ repositoryId: facts.githubRepositoryId,
23+ repositoryOwnerId: facts.githubRepositoryOwnerId,
24+ }
25+ : undefined
26+}
@@ -1,0 +1,3 @@
1+export * from './github-actions-fields-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,6 @@
1+export * from './action-ref-of'
2+export * from './environment-fields-of.js'
3+export * from './github-actions-fields-of'
4+export * from './tags-of'
5+
6+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './tags-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,15 @@
1+/**
2+ * The row's `tags` field: the variable split on commas and trimmed, or
3+ * undefined when it names none.
4+ *
5+ * @param variable CLAUDE_CODE_TAGS as read
6+ * @returns the field, or undefined
7+ */
8+export function tagsOf(variable: string | undefined) {
9+ const listed = (variable ?? '')
10+ .split(',')
11+ .map(tag => tag.trim())
12+ .filter(Boolean)
13+
14+ return listed.length > 0 ? listed : undefined
15+}
@@ -1,0 +1,19 @@
1+import type { ConfigLocation } from '../../../config-location'
2+
3+/**
4+ * Where the CLI keeps its global config: `.claude.json` under the config
5+ * directory when one is named, else under the home or profile directory.
6+ *
7+ * @param location the three variables as read
8+ * @returns the file's path, or undefined when no directory is named
9+ */
10+export function globalConfigPath(location: ConfigLocation): string | undefined {
11+ const directory = location.configDir || location.home || location.userProfile
12+
13+ const separator =
14+ directory?.includes('\\') && !directory.includes('/') ? '\\' : '/'
15+
16+ return directory
17+ ? `${directory.replace(/[\\/]+$/, '')}${separator}.claude.json`
18+ : undefined
19+}
@@ -1,0 +1,3 @@
1+export * from './global-config-path.js'
2+
3+export * as default from '.'
@@ -1,0 +1,13 @@
1+/**
2+ * Who the rows are from, as the CLI's global config records it: the
3+ * install's device id, and the signed-in account's ids and address.
4+ *
5+ * Each is undefined when the config has none or cannot be read; the address
6+ * rides only on an internal build's rows, as the CLI's own does.
7+ */
8+export type Identity = {
9+ readonly deviceId: string | undefined
10+ readonly accountUuid: string | undefined
11+ readonly organizationUuid: string | undefined
12+ readonly email: string | undefined
13+}
@@ -1,0 +1,3 @@
1+export type * from './identity.js'
2+
3+export * as default from '.'
@@ -1,0 +1,54 @@
1+import type { ConfigLocation } from '../../config-location'
2+import Entries from '../../entries'
3+import { globalConfigPath } from './global-config-path'
4+import type { Identity } from './identity'
5+import { plausibleUuid } from './plausible-uuid'
6+
7+/**
8+ * Reads the identity the CLI's global config records: the device id it
9+ * minted for this install, and the account it is signed in to.
10+ *
11+ * A config that is missing, unreadable or not JSON yields an identity with
12+ * nothing in it; the address is kept on an internal build alone.
13+ *
14+ * @param read reads a file as text
15+ * @param location where the config is
16+ * @param isInternal whether this is an internal build
17+ * @returns the identity, each field undefined unless the config has it
18+ */
19+export async function identityOf(
20+ read: (path: string) => Promise<string>,
21+ location: ConfigLocation,
22+ isInternal: boolean,
23+): Promise<Identity> {
24+ const path = globalConfigPath(location)
25+
26+ let config: unknown
27+
28+ try {
29+ config = path === undefined ? undefined : JSON.parse(await read(path))
30+ } catch {
31+ config = undefined
32+ }
33+
34+ const record = Entries.isRecord(config) ? config : {}
35+
36+ const account = Entries.isRecord(record.oauthAccount)
37+ ? record.oauthAccount
38+ : {}
39+
40+ const deviceId = record.userID
41+ const email = account.emailAddress
42+
43+ const isDeviceId =
44+ typeof deviceId === 'string' && /^[A-Za-z0-9_-]{8,128}$/.test(deviceId)
45+
46+ const isEmailKept = isInternal && typeof email === 'string' && email !== ''
47+
48+ return {
49+ deviceId: isDeviceId ? deviceId : undefined,
50+ accountUuid: plausibleUuid(account.accountUuid),
51+ organizationUuid: plausibleUuid(account.organizationUuid),
52+ email: isEmailKept ? email : undefined,
53+ }
54+}
@@ -1,0 +1,6 @@
1+export * from './global-config-path'
2+export * from './identity'
3+export * from './identity-of.js'
4+export * from './plausible-uuid'
5+
6+export * as default from '.'
@@ -1,0 +1,5 @@
1+export * from './plausible-uuid.js'
2+export * from './uuid-length-floor'
3+export * from './uuid-length-limit'
4+
5+export * as default from '.'
@@ -1,0 +1,18 @@
1+import { UUID_LENGTH_FLOOR } from './uuid-length-floor'
2+import { UUID_LENGTH_LIMIT } from './uuid-length-limit'
3+
4+/**
5+ * A value the config holds as an account or organization id, when it is a
6+ * string long enough to be one; a number or a trivial string is dropped.
7+ *
8+ * @param value what the config holds under the key
9+ * @returns the id, or undefined
10+ */
11+export function plausibleUuid(value: unknown): string | undefined {
12+ const isPlausible =
13+ typeof value === 'string' &&
14+ value.length >= UUID_LENGTH_FLOOR &&
15+ value.length <= UUID_LENGTH_LIMIT
16+
17+ return isPlausible ? value : undefined
18+}
@@ -1,0 +1,3 @@
1+export * from './uuid-length-floor.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * The shortest string taken for an account or organization id, the CLI's
3+ * own floor: long enough to sweep trivial junk, no format assumed.
4+ */
5+export const UUID_LENGTH_FLOOR = 8
@@ -1,0 +1,3 @@
1+export * from './uuid-length-limit.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * The longest string taken for an account or organization id or a device
3+ * id; anything longer is not an id and is dropped.
4+ */
5+export const UUID_LENGTH_LIMIT = 128
@@ -1,0 +1,21 @@
1+export * from './client-type-of'
2+export * from './context'
3+export * from './context-of.js'
4+export * from './entrypoint-of'
5+export * from './environment-fields'
6+export * from './environment-fields-of'
7+export * from './identity'
8+export * from './linux-distro-of'
9+export * from './machine'
10+export * from './probe'
11+export * from './probe-of'
12+export * from './remote-hash-of'
13+export * from './session-fields'
14+export * from './session-fields-of'
15+export * from './shell-of'
16+export * from './terminal-of'
17+export * from './vcs-of'
18+export * from './version-of'
19+export * from './wsl-version-of'
20+
21+export * as default from '.'
@@ -1,0 +1,5 @@
1+export * from './linux-distro'
2+export * from './linux-distro-of.js'
3+export * from './os-release-value'
4+
5+export * as default from '.'
@@ -1,0 +1,3 @@
1+export type * from './linux-distro.js'
2+
3+export * as default from '.'
@@ -1,0 +1,8 @@
1+/**
2+ * What `/etc/os-release` says of a Linux machine for the row: the
3+ * distribution's `ID` and `VERSION_ID`, undefined when absent or unread.
4+ */
5+export type LinuxDistro = {
6+ readonly id: string | undefined
7+ readonly version: string | undefined
8+}
@@ -1,0 +1,26 @@
1+import type { LinuxDistro } from './linux-distro'
2+import { osReleaseValue } from './os-release-value'
3+
4+/**
5+ * Reads the distribution's id and version off `/etc/os-release`, as the
6+ * CLI's own rows do; a file that is missing or unreadable yields neither.
7+ *
8+ * @param readOsRelease reads `/etc/os-release` as text
9+ * @returns the distribution
10+ */
11+export async function linuxDistroOf(
12+ readOsRelease: () => Promise<string>,
13+): Promise<LinuxDistro> {
14+ let content: string
15+
16+ try {
17+ content = await readOsRelease()
18+ } catch {
19+ content = ''
20+ }
21+
22+ return {
23+ id: osReleaseValue(content, 'ID'),
24+ version: osReleaseValue(content, 'VERSION_ID'),
25+ }
26+}
@@ -1,0 +1,3 @@
1+export * from './os-release-value.js'
2+
3+export * as default from '.'
@@ -1,0 +1,14 @@
1+/**
2+ * One key's value in an os-release file, unquoted; undefined when the file
3+ * has no such line.
4+ *
5+ * @param content the file's text
6+ * @param key the key
7+ * @returns the value, or undefined
8+ */
9+export const osReleaseValue = (content: string, key: 'ID' | 'VERSION_ID') =>
10+ content
11+ .split('\n')
12+ .map(line => /^(ID|VERSION_ID)=(.*)$/.exec(line))
13+ .find(match => match?.[1] === key)?.[2]
14+ ?.replace(/^"|"$/g, '')
@@ -1,0 +1,18 @@
1+/**
2+ * The architecture as the CLI's runtime names it, from what `uname -m` or
3+ * Windows' PROCESSOR_ARCHITECTURE says: `x64`, `arm64`, `ia32`, else as is.
4+ *
5+ * @param machine the architecture as the system spells it
6+ * @returns the runtime's spelling
7+ */
8+export function archOf(machine: string) {
9+ const spelled = machine.trim().toLowerCase()
10+
11+ const named: readonly (readonly [boolean, string])[] = [
12+ [spelled === 'x86_64' || spelled === 'amd64', 'x64'],
13+ [spelled === 'aarch64' || spelled === 'arm64', 'arm64'],
14+ [spelled === 'x86' || /^i[3-6]86$/.test(spelled), 'ia32'],
15+ ]
16+
17+ return named.find(([isIt]) => isIt)?.[1] ?? (spelled || 'unknown')
18+}
@@ -1,0 +1,3 @@
1+export * from './arch-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,8 @@
1+export * from './arch-of'
2+export * from './machine'
3+export * from './machine-of.js'
4+export * from './platform-of'
5+export * from './probe-script'
6+export * from './probe-timeout-ms'
7+
8+export * as default from '.'
@@ -1,0 +1,3 @@
1+export type * from './machine.js'
2+
3+export * as default from '.'
@@ -1,0 +1,15 @@
1+/**
2+ * What one probe of the machine answers for the rows' `env` block: the
3+ * system's own name, the architecture, the kernel, and what is on the PATH.
4+ *
5+ * `platformRaw` is the system's name lowercased (`darwin`, `linux`,
6+ * `freebsd`, `win32`); the package managers and runtimes are comma-joined,
7+ * an empty string for none.
8+ */
9+export type Machine = {
10+ readonly platformRaw: string
11+ readonly arch: string
12+ readonly kernel: string | undefined
13+ readonly packageManagers: string
14+ readonly runtimes: string
15+}
@@ -1,0 +1,53 @@
1+import type { ProcessRunResult } from 'claude-code'
2+
3+import type { Facts } from '../../facts'
4+import { archOf } from './arch-of'
5+import type { Machine } from './machine'
6+import { PROBE_SCRIPT } from './probe-script'
7+
8+/**
9+ * Probes the machine once: on Windows (the `OS` variable says so) from the
10+ * environment alone, elsewhere with one `sh` run of PROBE_SCRIPT.
11+ *
12+ * A probe that fails or exits non-zero leaves the system unnamed
13+ * (`unknown`) and the lists empty; nothing is retried.
14+ *
15+ * @param run runs one program to its end
16+ * @param facts the variables as read, for the Windows answer
17+ * @returns the machine as probed
18+ */
19+export async function machineOf(
20+ run: (argv: readonly string[]) => Promise<ProcessRunResult>,
21+ facts: Facts,
22+): Promise<Machine> {
23+ if (facts.os === 'Windows_NT') {
24+ return {
25+ platformRaw: 'win32',
26+ arch: archOf(facts.processorArchitecture ?? ''),
27+ kernel: undefined,
28+ packageManagers: '',
29+ runtimes: '',
30+ }
31+ }
32+
33+ let result: ProcessRunResult | undefined
34+
35+ try {
36+ result = await run(['sh', '-c', PROBE_SCRIPT])
37+ } catch {
38+ result = undefined
39+ }
40+
41+ const [system = '', kernel = '', machine = '', managers = '', runtimes = ''] =
42+ (result?.exitCode === 0 ? result.stdout.split('\n') : []).map(line =>
43+ line.trim(),
44+ )
45+
46+ return {
47+ platformRaw: system.toLowerCase() || 'unknown',
48+ arch: archOf(machine),
49+ kernel: kernel || undefined,
50+ packageManagers: managers.replace(/,$/, ''),
51+ runtimes: runtimes.replace(/,$/, ''),
52+ }
53+}
@@ -1,0 +1,3 @@
1+export * from './platform-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,17 @@
1+/**
2+ * The row's `platform` as the CLI's own rows have it: the host platform the
3+ * environment names when it is one of the three, else by the system's name.
4+ *
5+ * @param hostPlatform CLAUDE_CODE_HOST_PLATFORM, when set
6+ * @param platformRaw the system's own name lowercased
7+ * @returns `win32`, `darwin`, or `linux` for anything else
8+ */
9+export function platformOf(
10+ hostPlatform: string | undefined,
11+ platformRaw: string,
12+) {
13+ const named = ['win32', 'darwin', 'linux'].find(one => one === hostPlatform)
14+ const isOwnName = platformRaw === 'win32' || platformRaw === 'darwin'
15+
16+ return named ?? (isOwnName ? platformRaw : 'linux')
17+}
@@ -1,0 +1,3 @@
1+export * from './probe-script.js'
2+
3+export * as default from '.'
@@ -1,0 +1,17 @@
1+/**
2+ * The one shell script the plugin runs, once a session, anywhere but
3+ * Windows; five lines come back.
4+ *
5+ * `uname -s`, `uname -r`, `uname -m`, then the package managers found of
6+ * npm, yarn and pnpm comma-joined, then the runtimes found of bun, deno and
7+ * node.
8+ */
9+export const PROBE_SCRIPT = [
10+ 'uname -s',
11+ 'uname -r',
12+ 'uname -m',
13+ 'for c in npm yarn pnpm; do command -v "$c" >/dev/null 2>&1 && ' +
14+ 'printf "%s," "$c"; done; echo',
15+ 'for c in bun deno node; do command -v "$c" >/dev/null 2>&1 && ' +
16+ 'printf "%s," "$c"; done; echo',
17+].join('\n')
@@ -1,0 +1,3 @@
1+export * from './probe-timeout-ms.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * How long the one machine probe may run before the host stops it, in
3+ * milliseconds; a stopped probe leaves the machine unnamed.
4+ */
5+export const PROBE_TIMEOUT_MS = 5000
@@ -1,0 +1,3 @@
1+export type * from './probe.js'
2+
3+export * as default from '.'
@@ -1,0 +1,22 @@
1+import type Deployment from '../../deployment'
2+import type { Facts } from '../../facts'
3+import type { Identity } from '../identity'
4+import type { LinuxDistro } from '../linux-distro-of'
5+import type { Machine } from '../machine'
6+
7+/**
8+ * Everything gathered for the session's context before it is shaped: the
9+ * variables, the identity, the machine, the Linux files and the directory.
10+ */
11+export type Probe = {
12+ readonly facts: Facts
13+ readonly signals: readonly Deployment.DeploymentSignal[]
14+ readonly identity: Identity
15+ readonly machine: Machine
16+ readonly platform: string
17+ readonly isInteractive: boolean
18+ readonly distro: LinuxDistro
19+ readonly wslVersion: string | undefined
20+ readonly vcs: string | undefined
21+ readonly remoteHash: string | undefined
22+}
@@ -1,0 +1,5 @@
1+export * from './linux-files'
2+export * from './linux-files-of'
3+export * from './probe-of.js'
4+
5+export * as default from '.'
@@ -1,0 +1,3 @@
1+export type * from './linux-files.js'
2+
3+export * as default from '.'
@@ -1,0 +1,12 @@
1+import type Deployment from '../../../deployment'
2+import type { LinuxDistro } from '../../linux-distro-of'
3+
4+/**
5+ * What the Linux files say for the rows: the distribution, the WSL release,
6+ * and the two deployments whose signal is a file (EC2, Docker).
7+ */
8+export type LinuxFiles = {
9+ readonly distro: LinuxDistro
10+ readonly wslVersion: string | undefined
11+ readonly fileSignals: readonly Deployment.DeploymentSignal[]
12+}
@@ -1,0 +1,3 @@
1+export * from './linux-files-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,40 @@
1+import type { TelemetryDeps } from '../../../telemetry-deps'
2+import { linuxDistroOf } from '../../linux-distro-of'
3+import { wslVersionOf } from '../../wsl-version-of'
4+import type { LinuxFiles } from '../linux-files'
5+
6+/**
7+ * Reads the four Linux files the rows draw on, on Linux alone; elsewhere,
8+ * and for each file that cannot be read, the answer is empty.
9+ *
10+ * @param deps the calls on the nouns beneath
11+ * @param isLinux whether the machine named itself Linux
12+ * @returns what the files say
13+ */
14+export async function linuxFilesOf(
15+ deps: TelemetryDeps,
16+ isLinux: boolean,
17+): Promise<LinuxFiles> {
18+ const [distro, wslVersion, isEc2, isDocker] = await Promise.all([
19+ isLinux
20+ ? linuxDistroOf(() => deps.read('/etc/os-release'))
21+ : { id: undefined, version: undefined },
22+ isLinux ? wslVersionOf(() => deps.read('/proc/version')) : undefined,
23+ isLinux
24+ ? deps.read('/sys/hypervisor/uuid').then(
25+ uuid => uuid.startsWith('ec2'),
26+ () => false,
27+ )
28+ : false,
29+ isLinux ? deps.exists('/.dockerenv').catch(() => false) : false,
30+ ])
31+
32+ return {
33+ distro,
34+ wslVersion,
35+ fileSignals: [
36+ ['aws-ec2', isEc2],
37+ ['docker', isDocker],
38+ ],
39+ }
40+}
@@ -1,0 +1,54 @@
1+import type { TelemetryDeps } from '../../telemetry-deps'
2+import Identity from '../identity'
3+import Machine from '../machine'
4+import type { Probe } from '../probe'
5+import { remoteHashOf } from '../remote-hash-of'
6+import { vcsOf } from '../vcs-of'
7+import { linuxFilesOf } from './linux-files-of'
8+
9+/**
10+ * Gathers what holds for the whole session through the nouns beneath: the
11+ * variables, the global config, one machine probe, the directory, the remote.
12+ *
13+ * Each read that fails leaves its part empty; the Linux files are read on
14+ * Linux alone.
15+ *
16+ * @param deps the calls on the nouns beneath
17+ * @param isInteractive whether a person is at the prompt
18+ * @returns what was gathered, for context-of to shape
19+ */
20+export async function probeOf(
21+ deps: TelemetryDeps,
22+ isInteractive: boolean,
23+): Promise<Probe> {
24+ const [facts, signals, location, environment, cwd, repo] = await Promise.all([
25+ deps.facts(),
26+ deps.deployment(),
27+ deps.configLocation(),
28+ deps.environment(),
29+ deps.cwd().catch(() => undefined),
30+ deps.repo().catch(() => null),
31+ ])
32+
33+ const [identity, machine, vcs, remoteHash] = await Promise.all([
34+ Identity.identityOf(deps.read, location, environment.userType === 'ant'),
35+ Machine.machineOf(deps.run, facts),
36+ cwd === undefined ? undefined : vcsOf(deps.list, cwd, facts.hasP4Port),
37+ remoteHashOf(repo?.remote),
38+ ])
39+
40+ const files = await linuxFilesOf(deps, machine.platformRaw === 'linux')
41+
42+ return {
43+ facts,
44+ signals: [...signals, ...files.fileSignals],
45+ identity,
46+ machine,
47+ platform: Machine.platformOf(facts.hostPlatform, machine.platformRaw),
48+ isInteractive,
49+ distro: files.distro,
50+ wslVersion: files.wslVersion,
51+ vcs,
52+ remoteHash,
53+ }
54+}
@@ -1,0 +1,12 @@
1+import { HEX_RADIX } from './hex-radix'
2+
3+/**
4+ * A buffer's bytes as lowercase hex, two digits each.
5+ *
6+ * @param buffer the bytes
7+ * @returns the hex text
8+ */
9+export const hexOf = (buffer: ArrayBuffer) =>
10+ [...new Uint8Array(buffer)]
11+ .map(byte => byte.toString(HEX_RADIX).padStart(2, '0'))
12+ .join('')
@@ -1,0 +1,4 @@
1+/**
2+ * Sixteen: the radix a byte is written in as two hex digits.
3+ */
4+export const HEX_RADIX = 16
@@ -1,0 +1,3 @@
1+export * from './hex-radix.js'
2+
3+export * as default from '.'
@@ -1,0 +1,4 @@
1+export * from './hex-of.js'
2+export * from './hex-radix'
3+
4+export * as default from '.'
@@ -1,0 +1,6 @@
1+export * from './hex-of'
2+export * from './normalized-remote-of'
3+export * from './remote-hash-length'
4+export * from './remote-hash-of.js'
5+
6+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './normalized-remote-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,24 @@
1+/**
2+ * A git remote URL as the CLI normalizes it before hashing: `host/path`
3+ * lowercased, the user and a trailing `.git` dropped.
4+ *
5+ * The scp form (`git@host:owner/repo`) and the URL form (`https://` or
6+ * `ssh://`) alike; undefined for anything else.
7+ *
8+ * @param remote the remote URL as `$.session.repo()` answers it
9+ * @returns the normalized remote, or undefined
10+ */
11+export function normalizedRemoteOf(remote: string): string | undefined {
12+ const trimmed = remote.trim()
13+
14+ const [, host, path] =
15+ /^git@([^:/@]+):(.+?)(?:\.git)?$/.exec(trimmed) ??
16+ /^(?:https?|ssh):\/\/(?:[^@/?#]*@)?([^/?#@]+)\/(.+?)(?:\.git)?$/.exec(
17+ trimmed,
18+ ) ??
19+ []
20+
21+ const isRecognized = host !== undefined && path !== undefined
22+
23+ return isRecognized ? `${host}/${path}`.toLowerCase() : undefined
24+}
@@ -1,0 +1,3 @@
1+export * from './remote-hash-length.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * How many hex digits of the remote's SHA-256 a row carries as `rh`: the
3+ * CLI's own sixteen, so the plugin's rows join where the CLI's do.
4+ */
5+export const REMOTE_HASH_LENGTH = 16
@@ -1,0 +1,24 @@
1+import { hexOf } from './hex-of'
2+import { normalizedRemoteOf } from './normalized-remote-of'
3+import { REMOTE_HASH_LENGTH } from './remote-hash-length'
4+
5+/**
6+ * The rows' `rh`: the first hex digits of the SHA-256 of the session's
7+ * normalized origin remote, the key the CLI's own rows join a repository by.
8+ *
9+ * @param remote the origin remote as `$.session.repo()` answers it
10+ * @returns the hash, or undefined outside a recognized remote
11+ */
12+export async function remoteHashOf(
13+ remote: string | null | undefined,
14+): Promise<string | undefined> {
15+ const normalized = remote ? normalizedRemoteOf(remote) : undefined
16+
17+ if (normalized === undefined) {
18+ return undefined
19+ }
20+
21+ return hexOf(
22+ await crypto.subtle.digest('SHA-256', new TextEncoder().encode(normalized)),
23+ ).slice(0, REMOTE_HASH_LENGTH)
24+}
@@ -1,0 +1,3 @@
1+export type * from './session-fields.js'
2+
3+export * as default from '.'
@@ -1,0 +1,15 @@
1+/**
2+ * The row fields that are the session's for its whole life, before their
3+ * keys are spelled for the wire.
4+ *
5+ * The client, the entrypoint, the SDK and a benchmark run's ids, each
6+ * undefined when the environment names none.
7+ */
8+export type SessionFields = {
9+ readonly clientType: string
10+ readonly entrypoint: string | undefined
11+ readonly agentSdkVersion: string | undefined
12+ readonly sweBenchRunId: string | undefined
13+ readonly sweBenchInstanceId: string | undefined
14+ readonly sweBenchTaskId: string | undefined
15+}
@@ -1,0 +1,3 @@
1+export * from './session-fields-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,21 @@
1+import type { Facts } from '../../facts'
2+import { clientTypeOf } from '../client-type-of'
3+import { entrypointOf } from '../entrypoint-of'
4+import type { SessionFields } from '../session-fields'
5+import { versionOf } from '../version-of'
6+
7+/**
8+ * The session's constant row fields from the variables as read: the client,
9+ * the entrypoint, the SDK's version and a benchmark run's ids.
10+ *
11+ * @param facts the variables as read once for the session
12+ * @returns the fields
13+ */
14+export const sessionFieldsOf = (facts: Facts): SessionFields => ({
15+ clientType: clientTypeOf(facts),
16+ entrypoint: entrypointOf(facts.entrypoint),
17+ agentSdkVersion: versionOf(facts.agentSdkVersion),
18+ sweBenchRunId: facts.sweBenchRunId || undefined,
19+ sweBenchInstanceId: facts.sweBenchInstanceId || undefined,
20+ sweBenchTaskId: facts.sweBenchTaskId || undefined,
21+})
@@ -1,0 +1,4 @@
1+export * from './known-shells'
2+export * from './shell-of.js'
3+
4+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './known-shells.js'
2+
3+export * as default from '.'
@@ -1,0 +1,23 @@
1+/**
2+ * The shell names a row may carry, the CLI's own list: the login shell's
3+ * basename when it is one of these, else `other`; `none` when unset.
4+ */
5+export const KNOWN_SHELLS: readonly string[] = [
6+ 'zsh',
7+ 'bash',
8+ 'fish',
9+ 'sh',
10+ 'dash',
11+ 'ash',
12+ 'ksh',
13+ 'tcsh',
14+ 'csh',
15+ 'nu',
16+ 'nushell',
17+ 'pwsh',
18+ 'powershell',
19+ 'cmd',
20+ 'elvish',
21+ 'xonsh',
22+ 'ion',
23+]
@@ -1,0 +1,24 @@
1+import { KNOWN_SHELLS } from './known-shells'
2+
3+/**
4+ * The row's `shell`: the login shell's basename when the CLI's list knows
5+ * it, `other` when it does not, `none` when no shell is set.
6+ *
7+ * The path itself, which can name a person, never reaches a row.
8+ *
9+ * @param shellPath SHELL (or COMSPEC on Windows) as read
10+ * @returns the shell's name from the closed list
11+ */
12+export function shellOf(shellPath: string | undefined) {
13+ const base = (shellPath ?? '')
14+ .split(/[/\\]/)
15+ .at(-1)
16+ ?.toLowerCase()
17+ .replace(/\.exe$/, '')
18+
19+ if (!shellPath) {
20+ return 'none'
21+ }
22+
23+ return base !== undefined && KNOWN_SHELLS.includes(base) ? base : 'other'
24+}
@@ -1,0 +1,4 @@
1+export * from './jetbrains-ides'
2+export * from './terminal-of.js'
3+
4+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './jetbrains-ides.js'
2+
3+export * as default from '.'
@@ -1,0 +1,22 @@
1+/**
2+ * The JetBrains IDEs the CLI tells apart by the terminal's bundle
3+ * identifier, in the order it tries them.
4+ */
5+export const JETBRAINS_IDES: readonly string[] = [
6+ 'pycharm',
7+ 'intellij',
8+ 'webstorm',
9+ 'phpstorm',
10+ 'rubymine',
11+ 'clion',
12+ 'goland',
13+ 'rider',
14+ 'datagrip',
15+ 'appcode',
16+ 'dataspell',
17+ 'aqua',
18+ 'gateway',
19+ 'fleet',
20+ 'jetbrains',
21+ 'androidstudio',
22+]
@@ -1,0 +1,74 @@
1+import type { Facts } from '../../facts'
2+import { JETBRAINS_IDES } from './jetbrains-ides'
3+
4+/**
5+ * The row's `terminal` as the CLI names the terminal it runs in, read off
6+ * the same variables in the same order.
7+ *
8+ * The IDE hosts first, then TERM and TERM_PROGRAM, the multiplexers, the
9+ * Linux and Windows terminals, WSL, an ssh session, and what TERM still
10+ * tells; `non-interactive` with no person at the prompt, else `unknown`.
11+ *
12+ * @param facts the variables as read once for the session
13+ * @param platform the row's platform (the JetBrains check differs on darwin)
14+ * @param isInteractive whether a person is at the prompt
15+ * @returns the terminal's name
16+ */
17+export function terminalOf(
18+ facts: Facts,
19+ platform: string,
20+ isInteractive: boolean,
21+) {
22+ const askpass = (facts.vscodeGitAskpassMain ?? '').toLowerCase()
23+ const bundle = (facts.bundleIdentifier ?? '').toLowerCase()
24+ const term = facts.term ?? ''
25+ const termProgram = facts.termProgram ?? ''
26+ const isJediTerm = facts.terminalEmulator === 'JetBrains-JediTerm'
27+ const jetbrains = JETBRAINS_IDES.find(ide => bundle.includes(ide))
28+
29+ const candidates: readonly (readonly [boolean, string])[] = [
30+ [isJediTerm && platform !== 'darwin', 'pycharm'],
31+ [facts.hasCursorTraceId || askpass.includes('cursor'), 'cursor'],
32+ [askpass.includes('windsurf'), 'windsurf'],
33+ [askpass.includes('antigravity'), 'antigravity'],
34+ [bundle.includes('vscodium'), 'codium'],
35+ [bundle.includes('windsurf') || bundle.includes('devin'), 'windsurf'],
36+ [bundle.includes('com.google.android.studio'), 'androidstudio'],
37+ [jetbrains !== undefined, jetbrains ?? ''],
38+ [facts.hasVisualStudioVersion, 'visualstudio'],
39+ [isJediTerm, 'pycharm'],
40+ [term === 'xterm-ghostty', 'ghostty'],
41+ [term.includes('kitty'), 'kitty'],
42+ [/^devin([ -]desktop)?$/i.test(termProgram), 'windsurf'],
43+ [termProgram !== '', termProgram],
44+ [facts.hasTmux, 'tmux'],
45+ [facts.hasSty, 'screen'],
46+ [facts.hasKonsoleVersion, 'konsole'],
47+ [facts.hasGnomeTerminalService, 'gnome-terminal'],
48+ [facts.hasXtermVersion, 'xterm'],
49+ [facts.hasVteVersion, 'vte-based'],
50+ [facts.hasTerminatorUuid, 'terminator'],
51+ [facts.hasKittyWindowId, 'kitty'],
52+ [facts.hasAlacrittyLog, 'alacritty'],
53+ [facts.hasTilixId, 'tilix'],
54+ [facts.hasWtSession, 'windows-terminal'],
55+ [facts.hasSessionName && term === 'cygwin', 'cygwin'],
56+ [facts.msystem !== undefined, (facts.msystem ?? '').toLowerCase()],
57+ [
58+ facts.hasConEmuAnsi || facts.hasConEmuPid || facts.hasConEmuTask,
59+ 'conemu',
60+ ],
61+ [facts.wslDistroName !== undefined, `wsl-${facts.wslDistroName}`],
62+ [
63+ facts.hasSshConnection || facts.hasSshClient || facts.hasSshTty,
64+ 'ssh-session',
65+ ],
66+ [term.includes('alacritty'), 'alacritty'],
67+ [term.includes('rxvt'), 'rxvt'],
68+ [term.includes('termite'), 'termite'],
69+ [term !== '', term],
70+ [!isInteractive, 'non-interactive'],
71+ ]
72+
73+ return candidates.find(([isHeld]) => isHeld)?.[1] ?? 'unknown'
74+}
@@ -1,0 +1,17 @@
1+/**
2+ * The list with each value once, first occurrence kept, order kept.
3+ *
4+ * @param values the list
5+ * @returns the distinct values
6+ */
7+export function distinct(values: readonly string[]): string[] {
8+ const seen = new Set<string>()
9+
10+ return values.filter(value => {
11+ const isSeen = seen.has(value)
12+
13+ seen.add(value)
14+
15+ return !isSeen
16+ })
17+}
@@ -1,0 +1,3 @@
1+export * from './distinct.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+export * from './distinct'
2+export * from './vcs-markers'
3+export * from './vcs-of.js'
4+
5+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './vcs-markers.js'
2+
3+export * as default from '.'
@@ -1,0 +1,14 @@
1+/**
2+ * The entries that mark a working directory as a version-control checkout,
3+ * each with the system it names, in the CLI's own order.
4+ */
5+export const VCS_MARKERS: readonly (readonly [string, string])[] = [
6+ ['.git', 'git'],
7+ ['.hg', 'mercurial'],
8+ ['.svn', 'svn'],
9+ ['.p4config', 'perforce'],
10+ ['$tf', 'tfs'],
11+ ['.tfvc', 'tfs'],
12+ ['.jj', 'jujutsu'],
13+ ['.sl', 'sapling'],
14+]
@@ -1,0 +1,38 @@
1+import type { FsEntry } from 'claude-code'
2+
3+import { distinct } from './distinct'
4+import { VCS_MARKERS } from './vcs-markers'
5+
6+/**
7+ * The row's `vcs`: the version-control systems whose marker is in the
8+ * session's directory, Perforce too when P4PORT is set, comma-joined.
9+ *
10+ * @param list lists a directory
11+ * @param cwd the session's directory
12+ * @param hasP4Port whether P4PORT is set
13+ * @returns the systems found, or undefined for none or an unlistable directory
14+ */
15+export async function vcsOf(
16+ list: (path: string) => Promise<readonly FsEntry[]>,
17+ cwd: string,
18+ hasP4Port: boolean,
19+): Promise<string | undefined> {
20+ let entries: readonly FsEntry[]
21+
22+ try {
23+ entries = await list(cwd)
24+ } catch {
25+ entries = []
26+ }
27+
28+ const names = new Set(entries.map(entry => entry.name))
29+
30+ const found = distinct([
31+ ...(hasP4Port ? ['perforce'] : []),
32+ ...VCS_MARKERS.flatMap(([marker, system]) =>
33+ names.has(marker) ? [system] : [],
34+ ),
35+ ])
36+
37+ return found.length > 0 ? found.join(',') : undefined
38+}
@@ -1,0 +1,3 @@
1+export * from './version-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,13 @@
1+/**
2+ * A version string reduced to what a row may carry, as the CLI reduces the
3+ * Agent SDK's: its semver core, `unknown` as said, else `other`.
4+ *
5+ * @param version the variable as read; undefined stays undefined
6+ * @returns the version for the row, or undefined
7+ */
8+export function versionOf(version: string | undefined) {
9+ const isKept = version === undefined || version === 'unknown'
10+ const core = /^\d{1,8}\.\d{1,8}\.\d{1,8}(?!\d)/.exec(version ?? '')?.[0]
11+
12+ return isKept ? version : (core ?? 'other')
13+}
@@ -1,0 +1,3 @@
1+export * from './wsl-version-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,22 @@
1+/**
2+ * The WSL release a Linux kernel string names, as the CLI reads
3+ * `/proc/version`: the digit after `WSL`, `1` for the `Microsoft` kernels.
4+ *
5+ * @param readProcVersion reads `/proc/version` as text
6+ * @returns the WSL version, or undefined off WSL or when the file is unread
7+ */
8+export async function wslVersionOf(
9+ readProcVersion: () => Promise<string>,
10+): Promise<string | undefined> {
11+ let kernel: string
12+
13+ try {
14+ kernel = (await readProcVersion()).toLowerCase()
15+ } catch {
16+ return undefined
17+ }
18+
19+ const isMicrosoft = kernel.includes('microsoft')
20+
21+ return /wsl(\d+)/.exec(kernel)?.[1] ?? (isMicrosoft ? '1' : undefined)
22+}
@@ -1,0 +1,6 @@
1+import type { DEPLOYMENTS } from '../deployments'
2+
3+/**
4+ * One of the deployments a row can name (DEPLOYMENTS).
5+ */
6+export type DeploymentName = (typeof DEPLOYMENTS)[number]
@@ -1,0 +1,3 @@
1+export type * from './deployment-name.js'
2+
3+export * as default from '.'
@@ -1,0 +1,18 @@
1+import type { DeploymentSignal } from './deployment-signal'
2+import { DEPLOYMENTS } from './deployments'
3+
4+/**
5+ * The row's `deployment_environment`: the first deployment in the CLI's
6+ * order whose signal holds, else `unknown-<platform>` as it names none.
7+ *
8+ * @param signals each deployment read, with whether its signal holds
9+ * @param platform the row's platform, for the fallback
10+ * @returns the deployment's name
11+ */
12+export const deploymentOf = (
13+ signals: readonly DeploymentSignal[],
14+ platform: string,
15+) =>
16+ DEPLOYMENTS.find(deployment =>
17+ signals.some(([name, isHeld]) => isHeld && name === deployment),
18+ ) ?? `unknown-${platform}`
@@ -1,0 +1,7 @@
1+import type { DeploymentName } from '../deployment-name'
2+
3+/**
4+ * One deployment and whether its signal holds: a variable set, on, or
5+ * holding the value the CLI looks for, or one of the two marker files.
6+ */
7+export type DeploymentSignal = readonly [DeploymentName, boolean]
@@ -1,0 +1,3 @@
1+export type * from './deployment-signal.js'
2+
3+export * as default from '.'
@@ -1,0 +1,43 @@
1+/**
2+ * Every deployment a row can name, in the order the CLI's own detection
3+ * tries them; the first whose signal holds names the row.
4+ *
5+ * Cloud development environments, then cloud platforms, CI, and container
6+ * orchestration last.
7+ */
8+export const DEPLOYMENTS = [
9+ 'codespaces',
10+ 'gitpod',
11+ 'coder',
12+ 'devpod',
13+ 'daytona',
14+ 'gcp-cloud-workstations',
15+ 'aws-cloud9',
16+ 'replit',
17+ 'glitch',
18+ 'vercel',
19+ 'railway',
20+ 'render',
21+ 'netlify',
22+ 'heroku',
23+ 'fly.io',
24+ 'cloudflare-pages',
25+ 'deno-deploy',
26+ 'aws-lambda',
27+ 'aws-fargate',
28+ 'aws-ecs',
29+ 'aws-ec2',
30+ 'gcp-cloud-run',
31+ 'gcp',
32+ 'azure-app-service',
33+ 'azure-functions',
34+ 'digitalocean-app-platform',
35+ 'huggingface-spaces',
36+ 'github-actions',
37+ 'gitlab-ci',
38+ 'circleci',
39+ 'buildkite',
40+ 'ci',
41+ 'kubernetes',
42+ 'docker',
43+] as const
@@ -1,0 +1,3 @@
1+export * from './deployments.js'
2+
3+export * as default from '.'
@@ -1,0 +1,6 @@
1+export * from './deployment-name'
2+export * from './deployment-of.js'
3+export * from './deployment-signal'
4+export * from './deployments'
5+
6+export * as default from '.'
@@ -1,34 +1,23 @@
1-import type { Fields } from './fields'
2-import type { RowSession } from './row-session'
1+import type Batching from '../batching'
2+import type { BatchStamp } from './batch-stamp'
3+import { eventDataOf } from './event-data-of'
4+import { wireOf } from './wire-of'
35
46 /**
5- * The first-party event batch for one entry, shaped as the CLI's own
6- * event exporter shapes its batches.
7+ * The first-party event batch for the queued rows, shaped as the CLI's own
8+ * event exporter shapes its batches: one ClaudeCodeInternalEvent per row.
79 *
8- * The ClaudeCodeInternalEvent JSON, one event per batch; the properties ride
9- * as base64 JSON in `additional_metadata`, the field the exporter uses;
10- * `user_type` is what the environment's USER_TYPE says of the build.
11- *
12- * @param fields the entry as checked
13- * @param session the session's id and model, and the build's user type
14- * @returns the batch's JSON text, ready to send to the exporter
10+ * @param rows the rows as they were queued, oldest first
11+ * @param stamp what the batch stamps on every row
12+ * @returns the batch's JSON text, ready to POST to the ingest
1513 */
16-export function batchOf(fields: Fields, session: RowSession) {
17- const metadata = JSON.stringify(fields.props)
18-
19- return JSON.stringify({
20- events: [
21- {
22- event_type: 'ClaudeCodeInternalEvent',
23- event_data: {
24- event_name: fields.name,
25- client_timestamp: new Date().toISOString(),
26- session_id: session.sessionId,
27- model: session.model,
28- user_type: session.userType,
29- additional_metadata: btoa(metadata),
30- },
31- },
32- ],
14+export const batchOf = (
15+ rows: readonly Batching.PendingRow[],
16+ stamp: BatchStamp,
17+) =>
18+ JSON.stringify({
19+ events: rows.map(row => ({
20+ event_type: 'ClaudeCodeInternalEvent',
21+ event_data: wireOf(eventDataOf(row, stamp)),
22+ })),
3323 })
34-}
@@ -1,0 +1,17 @@
1+import type { Context } from '../../context'
2+
3+/**
4+ * What one batch stamps on each of its rows beside the row's own fields:
5+ * the session as read when the batch went out, and its context.
6+ *
7+ * `userType` is `ant` or `external` as USER_TYPE says of the build;
8+ * `isClaudeAiAuth` follows the credential the batch is sent with.
9+ */
10+export type BatchStamp = {
11+ readonly sessionId: string
12+ readonly model: string
13+ readonly userType: string
14+ readonly isInteractive: boolean
15+ readonly isClaudeAiAuth: boolean
16+ readonly context: Context
17+}
@@ -1,0 +1,3 @@
1+export type * from './batch-stamp.js'
2+
3+export * as default from '.'
@@ -1,0 +1,21 @@
1+import type { Context } from '../../../context'
2+
3+/**
4+ * The row's `auth`: the signed-in account's and organization's ids, or
5+ * undefined when the identity holds neither (an API key, no login).
6+ *
7+ * @param identity who the rows are from
8+ * @returns the ids, or undefined
9+ */
10+export function authOf(identity: Context['identity']) {
11+ const hasEither =
12+ identity.accountUuid !== undefined ||
13+ identity.organizationUuid !== undefined
14+
15+ return hasEither
16+ ? {
17+ accountUuid: identity.accountUuid,
18+ organizationUuid: identity.organizationUuid,
19+ }
20+ : undefined
21+}
@@ -1,0 +1,3 @@
1+export * from './auth-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,31 @@
1+import type Batching from '../../batching'
2+import type { BatchStamp } from '../batch-stamp'
3+import { authOf } from './auth-of'
4+import { metadataOf } from './metadata-of'
5+
6+/**
7+ * One row as a ClaudeCodeInternalEvent, keyed as the plugin builds it;
8+ * wireOf spells it for the ingest.
9+ *
10+ * Its id, name and time, who and where it is from, the session's fields,
11+ * its `env` block, and its properties in `additionalMetadata`.
12+ *
13+ * @param row the row as it was queued
14+ * @param stamp what the batch stamps on every row
15+ * @returns the event's data
16+ */
17+export const eventDataOf = (row: Batching.PendingRow, stamp: BatchStamp) => ({
18+ eventId: row.eventId,
19+ eventName: row.fields.name,
20+ clientTimestamp: row.loggedAt,
21+ deviceId: stamp.context.identity.deviceId,
22+ email: stamp.context.identity.email,
23+ auth: authOf(stamp.context.identity),
24+ sessionId: stamp.sessionId,
25+ model: stamp.model,
26+ userType: stamp.userType,
27+ isInteractive: stamp.isInteractive,
28+ ...stamp.context.session,
29+ env: { ...stamp.context.environment, isClaudeAiAuth: stamp.isClaudeAiAuth },
30+ additionalMetadata: metadataOf(row, stamp.context.remoteHash),
31+})
@@ -1,0 +1,5 @@
1+export * from './auth-of'
2+export * from './event-data-of.js'
3+export * from './metadata-of'
4+
5+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './metadata-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,14 @@
1+import type Batching from '../../../batching'
2+
3+/**
4+ * The row's `additional_metadata`: its properties as base64 JSON, the
5+ * repository's remote hash beside them as the CLI's own rows carry it.
6+ *
7+ * @param row the row as it was queued
8+ * @param remoteHash the session's remote hash, when it is in a repository
9+ * @returns the base64 text
10+ */
11+export const metadataOf = (
12+ row: Batching.PendingRow,
13+ remoteHash: string | undefined,
14+) => btoa(JSON.stringify({ rh: remoteHash, ...row.fields.props }))
@@ -1,4 +1,5 @@
11 export * from './batch-of.js'
2+export * from './batch-stamp'
23 export * from './checked-fields.js'
34 export * from './checked-mark.js'
45 export * from './checked-props'
@@ -6,6 +7,7 @@
67 export * from './choice-token'
78 export * from './choices-limit'
89 export * from './core-event-prefix'
10+export * from './event-data-of'
911 export * from './event-prefix'
1012 export * from './feature-prefix'
1113 export * from './fields'
@@ -19,7 +21,7 @@
1921 export * from './method'
2022 export * from './prop-limit'
2123 export * from './refusal'
22-export * from './row-session'
2324 export * from './token'
25+export * from './wire-of'
2426
2527 export * as default from '.'
Dmods/telemetry/hooks/entries/row-session/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './row-session.js'
2-
3-export * as default from '.'
Dmods/telemetry/hooks/entries/row-session/row-session.ts+0 -9
@@ -1,9 +1,0 @@
1-/**
2- * What one row says of the session that sent it: its id, its model, and
3- * the build's user type (`ant` or `external`) as the environment names it.
4- */
5-export type RowSession = {
6- sessionId: string
7- model: string
8- userType: string
9-}
@@ -1,0 +1,3 @@
1+export * from './wire-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,24 @@
1+import { isRecord } from '../is-record'
2+
3+/**
4+ * A value with every object key respelled for the event-logging ingest:
5+ * `platformRaw` as `platform_raw`, all the way down; arrays and leaves kept.
6+ *
7+ * @param value the row, or a part of it, keyed as the plugin builds it
8+ * @returns the same value keyed as the ingest reads it
9+ */
10+export function wireOf(value: unknown): unknown {
11+ const isList = Array.isArray(value)
12+ const isObject = isRecord(value)
13+
14+ return isList
15+ ? value.map(wireOf)
16+ : isObject
17+ ? Object.fromEntries(
18+ Object.entries(value).map(([key, member]) => [
19+ key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`),
20+ wireOf(member),
21+ ]),
22+ )
23+ : value
24+}
@@ -1,16 +1,19 @@
11 /**
2- * What the plugin reads of the environment before each row: the
2+ * What the plugin reads of the environment before each batch: the
33 * build's user type and every switch that turns the CLI's analytics off.
44 *
55 * Each field is the variable's value as `$.env.get` answers it, undefined
6- * when unset; analytics-off/ decides what they mean together.
6+ * when unset; is-analytics-off/ decides what they mean together.
77 */
88 export type Environment = {
99 readonly userType: string | undefined
10+ readonly nodeEnv: string | undefined
1011 readonly disableTelemetry: string | undefined
1112 readonly disableNonessentialTraffic: string | undefined
1213 readonly doNotTrack: string | undefined
1314 readonly customOauthUrl: string | undefined
15+ readonly useGateway: string | undefined
16+ readonly providerManagedByHost: string | undefined
1417 readonly useBedrock: string | undefined
1518 readonly useVertex: string | undefined
1619 readonly useFoundry: string | undefined
@@ -1,0 +1,66 @@
1+/**
2+ * What the plugin reads of the environment once a session to describe it in
3+ * every row, as the CLI's own rows describe theirs.
4+ *
5+ * Each field is one variable as `$.env.get` answers it, undefined when
6+ * unset, or only whether it is set where its value is a secret or names a
7+ * person or a host; context/ reduces each to the value the CLI's column has.
8+ */
9+export type Facts = {
10+ readonly entrypoint: string | undefined
11+ readonly agentSdkVersion: string | undefined
12+ readonly hostPlatform: string | undefined
13+ readonly os: string | undefined
14+ readonly processorArchitecture: string | undefined
15+ readonly shellPath: string | undefined
16+ readonly ci: string | undefined
17+ readonly claubbit: string | undefined
18+ readonly githubActions: string | undefined
19+ readonly claudeCodeAction: string | undefined
20+ readonly claudeCodeRemote: string | undefined
21+ readonly remoteEnvironmentType: string | undefined
22+ readonly containerId: string | undefined
23+ readonly remoteSessionId: string | undefined
24+ readonly tags: string | undefined
25+ readonly hasSessionAccessToken: boolean
26+ readonly hasSessionIngressTokenFile: boolean
27+ readonly hasWebsocketAuthFileDescriptor: boolean
28+ readonly githubEventName: string | undefined
29+ readonly runnerEnvironment: string | undefined
30+ readonly runnerOs: string | undefined
31+ readonly githubActionPath: string | undefined
32+ readonly githubActorId: string | undefined
33+ readonly githubRepositoryId: string | undefined
34+ readonly githubRepositoryOwnerId: string | undefined
35+ readonly sweBenchRunId: string | undefined
36+ readonly sweBenchInstanceId: string | undefined
37+ readonly sweBenchTaskId: string | undefined
38+ readonly hasP4Port: boolean
39+ readonly wslDistroName: string | undefined
40+ readonly hasCursorTraceId: boolean
41+ readonly vscodeGitAskpassMain: string | undefined
42+ readonly bundleIdentifier: string | undefined
43+ readonly hasVisualStudioVersion: boolean
44+ readonly terminalEmulator: string | undefined
45+ readonly term: string | undefined
46+ readonly termProgram: string | undefined
47+ readonly hasTmux: boolean
48+ readonly hasSty: boolean
49+ readonly hasKonsoleVersion: boolean
50+ readonly hasGnomeTerminalService: boolean
51+ readonly hasXtermVersion: boolean
52+ readonly hasVteVersion: boolean
53+ readonly hasTerminatorUuid: boolean
54+ readonly hasKittyWindowId: boolean
55+ readonly hasAlacrittyLog: boolean
56+ readonly hasTilixId: boolean
57+ readonly hasWtSession: boolean
58+ readonly hasSessionName: boolean
59+ readonly msystem: string | undefined
60+ readonly hasConEmuAnsi: boolean
61+ readonly hasConEmuPid: boolean
62+ readonly hasConEmuTask: boolean
63+ readonly hasSshConnection: boolean
64+ readonly hasSshClient: boolean
65+ readonly hasSshTty: boolean
66+}
@@ -1,0 +1,3 @@
1+export type * from './facts.js'
2+
3+export * as default from '.'
@@ -1,0 +1,14 @@
1+import type { Caught } from 'claude-code'
2+
3+import { REFUSED } from '../refused'
4+
5+/**
6+ * The gate's `.catch`: a gate that threw before going on refuses the call;
7+ * a refusal from beneath (a malformed entry) is passed up as it is.
8+ *
9+ * @param e the entry as the caller passed it
10+ * @param next the replay-safe continuation the catch is handed
11+ * @returns beneath's own answer replayed, or the refusal
12+ */
13+export const caught = <E, R>(e: E, next: ((e: E) => R) & Caught) =>
14+ next.called ? next(e) : REFUSED
@@ -1,0 +1,3 @@
1+export * from './caught.js'
2+
3+export * as default from '.'
@@ -1,0 +1,7 @@
1+import type { Origin } from 'claude-code'
2+
3+/**
4+ * What the gate reads of the continuation it is handed: the hooks beneath
5+ * to go on to, and who raised the call, as the host stamped it.
6+ */
7+export type GateNext<E, R> = ((e: E) => R) & { readonly origin?: Origin }
@@ -1,0 +1,3 @@
1+export type * from './gate-next.js'
2+
3+export * as default from '.'
@@ -1,0 +1,7 @@
1+export * from './caught'
2+export * from './gate-next'
3+export * from './refused'
4+export * from './served'
5+export * from './served-tiers'
6+
7+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './refused.js'
2+
3+export * as default from '.'
@@ -1,0 +1,7 @@
1+/**
2+ * The answer a caller outside the served tiers gets from `$.telemetry`: its
3+ * promise rejects with this, and the host notes it in the debug log.
4+ */
5+export const REFUSED = {
6+ deny: '$.telemetry serves the plugins built into Claude Code alone',
7+} as const
@@ -1,0 +1,3 @@
1+export * from './served.js'
2+
3+export * as default from '.'
@@ -1,0 +1,19 @@
1+import type { GateNext } from '../gate-next'
2+import { REFUSED } from '../refused'
3+import { SERVED_TIERS } from '../served-tiers'
4+
5+/**
6+ * The gate on `telemetry.log` and `telemetry.mark`: a caller seated in
7+ * SERVED_TIERS goes on beneath, any other is REFUSED; no origin throws.
8+ *
9+ * @param e the entry as the caller passed it
10+ * @param next the hooks beneath, carrying who raised the call
11+ * @returns what beneath answers, or the refusal
12+ */
13+export function served<E, R>(e: E, next: GateNext<E, R>): R | typeof REFUSED {
14+ if (next.origin?.tier === undefined) {
15+ throw new Error('$.telemetry: the call names no origin')
16+ }
17+
18+ return SERVED_TIERS.includes(next.origin.tier) ? next(e) : REFUSED
19+}
@@ -1,0 +1,3 @@
1+export * from './served-tiers.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * The tiers `$.telemetry` serves: the plugins bundled in the binary, and the
3+ * engine itself. A caller seated anywhere else is refused, on every build.
4+ */
5+export const SERVED_TIERS: readonly string[] = ['builtin', 'core']
@@ -1,4 +1,6 @@
11 {
2- "description": "Plugin analytics: an engine.create step adds $.telemetry (log, mark) over $.session, $.http and $.env beneath; sends nothing wherever the CLI's analytics are off",
3- "modules": ["./register.ts"]
2+ "description": "Plugin analytics: an engine.create step adds $.telemetry (log, mark) over $.session, $.env, $.settings, $.fs, $.process, $.clock and $.http beneath; rows go out in batches; sends nothing wherever the CLI's analytics are off",
3+ "modules": [
4+ "./register.ts"
5+ ]
46 }
@@ -1,7 +1,15 @@
1+export * from './batching'
2+export * from './config-location'
3+export * from './context'
4+export * from './deployment'
15 export * from './entries'
26 export * from './environment'
7+export * from './facts'
8+export * from './gate'
39 export * from './is-analytics-off'
10+export * from './policy-pins'
411 export * from './register.js'
12+export * from './sender'
513 export * from './telemetry-deps'
614 export * from './telemetry-of'
715
@@ -1,34 +1,44 @@
11 import type { Environment } from '../environment'
2+import type { PolicyPins } from '../policy-pins'
23 import { isEnvSet } from './is-env-set'
34 import { isEnvTruthy } from './is-env-truthy'
45
56 /**
67 * Whether the CLI's own analytics would be off here, read off the
7- * environment alone: then the plugin sends nothing either.
8+ * environment and the managed policy: then the plugin sends nothing either.
89 *
9- * Off when DISABLE_TELEMETRY or the nonessential-traffic switch is set at
10- * all or DO_NOT_TRACK is on, on any third-party provider (no first-party
11- * credential rides there), and on a deployment with its own OAuth URL.
10+ * Off under test, when DISABLE_TELEMETRY or the nonessential-traffic switch
11+ * is set at all or DO_NOT_TRACK is on, on a third-party provider the host
12+ * does not manage, on a gateway, and on a deployment with its own OAuth URL.
1213 *
13- * @param environment the variables as read once for the session
14+ * @param environment the switches as read for this batch
15+ * @param policy the managed settings' gateway pins
1416 * @returns true when no row may be sent
1517 */
16-export function isAnalyticsOff(environment: Environment) {
18+export function isAnalyticsOff(environment: Environment, policy: PolicyPins) {
1719 const isPrivate =
20+ environment.nodeEnv === 'test' ||
1821 isEnvSet(environment.disableTelemetry) ||
1922 isEnvSet(environment.disableNonessentialTraffic) ||
2023 isEnvTruthy(environment.doNotTrack)
2124
22- const isThirdParty = [
23- environment.useBedrock,
24- environment.useVertex,
25- environment.useFoundry,
26- environment.useAnthropicAws,
27- environment.useAnthropicGoogleCloud,
28- environment.useMantle,
29- ].some(isEnvTruthy)
25+ const isThirdParty =
26+ !isEnvTruthy(environment.providerManagedByHost) &&
27+ [
28+ environment.useBedrock,
29+ environment.useVertex,
30+ environment.useFoundry,
31+ environment.useAnthropicAws,
32+ environment.useAnthropicGoogleCloud,
33+ environment.useMantle,
34+ ].some(isEnvTruthy)
3035
36+ const isGateway =
37+ isEnvTruthy(environment.useGateway) ||
38+ policy.forceLoginMethod === 'gateway' ||
39+ policy.forceLoginGatewayUrl !== undefined
40+
3141 const isCustomDeployment = isEnvSet(environment.customOauthUrl?.trim())
3242
33- return isPrivate || isThirdParty || isCustomDeployment
43+ return isPrivate || isThirdParty || isGateway || isCustomDeployment
3444 }
@@ -1,0 +1,3 @@
1+export type * from './policy-pins.js'
2+
3+export * as default from '.'
@@ -1,0 +1,10 @@
1+/**
2+ * The two managed-settings keys that pin a session to a cloud gateway, as
3+ * `$.settings.read({ source: "policy" })` answers them.
4+ *
5+ * A session so pinned sends its rows nowhere, as the CLI's own analytics do.
6+ */
7+export type PolicyPins = {
8+ readonly forceLoginMethod?: unknown
9+ readonly forceLoginGatewayUrl?: unknown
10+}
@@ -1,32 +1,64 @@
11 import type { EngineInterface, On } from 'claude-code'
22
3+import Context from './context'
4+import Gate from './gate'
5+import IsAnalyticsOff from './is-analytics-off'
6+import type { Sender } from './sender'
37 import { telemetryOf } from './telemetry-of'
48
59 /**
6- * Registers the plugin's one hook: its engine.create step adds
7- * `$.telemetry` over the nouns beneath, the plugin's own `$` as core built.
10+ * Registers the plugin's hooks: its engine.create step adds `$.telemetry`
11+ * over the nouns beneath; session.start and session.end feed and flush it.
812 *
9- * `log` runs after the fold, reaching `$.session` and `$.http` through the
10- * nouns beneath; each is one call on them.
13+ * `log` and `mark` queue a row for a built-in caller and refuse any other;
14+ * a gate that throws refuses too, a refusal from beneath stays as it is. A
15+ * batch goes out on a timer, when full, and when the session ends.
1116 *
1217 * @param on the engine's registrar
1318 */
1419 export function register(on: On) {
15- on('engine.create', async ($, e, next) => {
20+ let sender: Sender | undefined
21+ let isInteractive: boolean | undefined
22+
23+ on('telemetry.*', (_$, e, next) => Gate.served(e, next)).catch(
24+ (_$, e, next) => Gate.caught(e, next),
25+ )
26+
27+ on('session.start', (_$, e, next) => {
28+ isInteractive = e.isInteractive
29+
30+ return next(e)
31+ })
32+
33+ on('session.end', async (_$, e, next) => {
34+ await sender?.flush()
35+
36+ return next(e)
37+ })
38+
39+ on('engine.create', async (_$, e, next) => {
1640 const beneath = await next(e)
1741
18- const telemetry: EngineInterface['telemetry'] = telemetryOf({
42+ sender = telemetryOf({
1943 authorize: () => beneath.session.authorize(),
2044 id: () => beneath.session.id(),
2145 model: () => beneath.session.model(),
46+ isInteractive: async () =>
47+ isInteractive ??
48+ (await beneath.session.surfaces()).includes('terminal'),
2249 environment: async () => ({
2350 userType: await beneath.env.get('USER_TYPE'),
51+ nodeEnv: await beneath.env.get('NODE_ENV'),
2452 disableTelemetry: await beneath.env.get('DISABLE_TELEMETRY'),
2553 disableNonessentialTraffic: await beneath.env.get(
2654 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
2755 ),
2856 doNotTrack: await beneath.env.get('DO_NOT_TRACK'),
2957 customOauthUrl: await beneath.env.get('CLAUDE_CODE_CUSTOM_OAUTH_URL'),
58+ useGateway: await beneath.env.get('CLAUDE_CODE_USE_GATEWAY'),
59+ providerManagedByHost: await beneath.env.get(
60+ 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
61+ ),
3062 useBedrock: await beneath.env.get('CLAUDE_CODE_USE_BEDROCK'),
3163 useVertex: await beneath.env.get('CLAUDE_CODE_USE_VERTEX'),
3264 useFoundry: await beneath.env.get('CLAUDE_CODE_USE_FOUNDRY'),
@@ -36,9 +68,286 @@
3668 ),
3769 useMantle: await beneath.env.get('CLAUDE_CODE_USE_MANTLE'),
3870 }),
71+ policy: () => beneath.settings.read({ source: 'policy' }),
72+ facts: async () => ({
73+ entrypoint: await beneath.env.get('CLAUDE_CODE_ENTRYPOINT'),
74+ agentSdkVersion: await beneath.env.get('CLAUDE_AGENT_SDK_VERSION'),
75+ hostPlatform: await beneath.env.get('CLAUDE_CODE_HOST_PLATFORM'),
76+ os: await beneath.env.get('OS'),
77+ processorArchitecture: await beneath.env.get('PROCESSOR_ARCHITECTURE'),
78+ shellPath:
79+ (await beneath.env.get('SHELL')) ??
80+ (await beneath.env.get('COMSPEC')),
81+ ci: await beneath.env.get('CI'),
82+ claubbit: await beneath.env.get('CLAUBBIT'),
83+ githubActions: await beneath.env.get('GITHUB_ACTIONS'),
84+ claudeCodeAction: await beneath.env.get('CLAUDE_CODE_ACTION'),
85+ claudeCodeRemote: await beneath.env.get('CLAUDE_CODE_REMOTE'),
86+ remoteEnvironmentType: await beneath.env.get(
87+ 'CLAUDE_CODE_REMOTE_ENVIRONMENT_TYPE',
88+ ),
89+ containerId: await beneath.env.get('CLAUDE_CODE_CONTAINER_ID'),
90+ remoteSessionId: await beneath.env.get('CLAUDE_CODE_REMOTE_SESSION_ID'),
91+ tags: await beneath.env.get('CLAUDE_CODE_TAGS'),
92+ hasSessionAccessToken: IsAnalyticsOff.isEnvSet(
93+ await beneath.env.get('CLAUDE_CODE_SESSION_ACCESS_TOKEN'),
94+ ),
95+ hasSessionIngressTokenFile: IsAnalyticsOff.isEnvSet(
96+ await beneath.env.get('CLAUDE_SESSION_INGRESS_TOKEN_FILE'),
97+ ),
98+ hasWebsocketAuthFileDescriptor: IsAnalyticsOff.isEnvSet(
99+ await beneath.env.get('CLAUDE_CODE_WEBSOCKET_AUTH_FILE_DESCRIPTOR'),
100+ ),
101+ githubEventName: await beneath.env.get('GITHUB_EVENT_NAME'),
102+ runnerEnvironment: await beneath.env.get('RUNNER_ENVIRONMENT'),
103+ runnerOs: await beneath.env.get('RUNNER_OS'),
104+ githubActionPath: await beneath.env.get('GITHUB_ACTION_PATH'),
105+ githubActorId: await beneath.env.get('GITHUB_ACTOR_ID'),
106+ githubRepositoryId: await beneath.env.get('GITHUB_REPOSITORY_ID'),
107+ githubRepositoryOwnerId: await beneath.env.get(
108+ 'GITHUB_REPOSITORY_OWNER_ID',
109+ ),
110+ sweBenchRunId: await beneath.env.get('SWE_BENCH_RUN_ID'),
111+ sweBenchInstanceId: await beneath.env.get('SWE_BENCH_INSTANCE_ID'),
112+ sweBenchTaskId: await beneath.env.get('SWE_BENCH_TASK_ID'),
113+ hasP4Port: IsAnalyticsOff.isEnvSet(await beneath.env.get('P4PORT')),
114+ wslDistroName: await beneath.env.get('WSL_DISTRO_NAME'),
115+ hasCursorTraceId: IsAnalyticsOff.isEnvSet(
116+ await beneath.env.get('CURSOR_TRACE_ID'),
117+ ),
118+ vscodeGitAskpassMain: await beneath.env.get('VSCODE_GIT_ASKPASS_MAIN'),
119+ bundleIdentifier: await beneath.env.get('__CFBundleIdentifier'),
120+ hasVisualStudioVersion: IsAnalyticsOff.isEnvSet(
121+ await beneath.env.get('VisualStudioVersion'),
122+ ),
123+ terminalEmulator: await beneath.env.get('TERMINAL_EMULATOR'),
124+ term: await beneath.env.get('TERM'),
125+ termProgram: await beneath.env.get('TERM_PROGRAM'),
126+ hasTmux: IsAnalyticsOff.isEnvSet(await beneath.env.get('TMUX')),
127+ hasSty: IsAnalyticsOff.isEnvSet(await beneath.env.get('STY')),
128+ hasKonsoleVersion: IsAnalyticsOff.isEnvSet(
129+ await beneath.env.get('KONSOLE_VERSION'),
130+ ),
131+ hasGnomeTerminalService: IsAnalyticsOff.isEnvSet(
132+ await beneath.env.get('GNOME_TERMINAL_SERVICE'),
133+ ),
134+ hasXtermVersion: IsAnalyticsOff.isEnvSet(
135+ await beneath.env.get('XTERM_VERSION'),
136+ ),
137+ hasVteVersion: IsAnalyticsOff.isEnvSet(
138+ await beneath.env.get('VTE_VERSION'),
139+ ),
140+ hasTerminatorUuid: IsAnalyticsOff.isEnvSet(
141+ await beneath.env.get('TERMINATOR_UUID'),
142+ ),
143+ hasKittyWindowId: IsAnalyticsOff.isEnvSet(
144+ await beneath.env.get('KITTY_WINDOW_ID'),
145+ ),
146+ hasAlacrittyLog: IsAnalyticsOff.isEnvSet(
147+ await beneath.env.get('ALACRITTY_LOG'),
148+ ),
149+ hasTilixId: IsAnalyticsOff.isEnvSet(await beneath.env.get('TILIX_ID')),
150+ hasWtSession: IsAnalyticsOff.isEnvSet(
151+ await beneath.env.get('WT_SESSION'),
152+ ),
153+ hasSessionName: IsAnalyticsOff.isEnvSet(
154+ await beneath.env.get('SESSIONNAME'),
155+ ),
156+ msystem: await beneath.env.get('MSYSTEM'),
157+ hasConEmuAnsi: IsAnalyticsOff.isEnvSet(
158+ await beneath.env.get('ConEmuANSI'),
159+ ),
160+ hasConEmuPid: IsAnalyticsOff.isEnvSet(
161+ await beneath.env.get('ConEmuPID'),
162+ ),
163+ hasConEmuTask: IsAnalyticsOff.isEnvSet(
164+ await beneath.env.get('ConEmuTask'),
165+ ),
166+ hasSshConnection: IsAnalyticsOff.isEnvSet(
167+ await beneath.env.get('SSH_CONNECTION'),
168+ ),
169+ hasSshClient: IsAnalyticsOff.isEnvSet(
170+ await beneath.env.get('SSH_CLIENT'),
171+ ),
172+ hasSshTty: IsAnalyticsOff.isEnvSet(await beneath.env.get('SSH_TTY')),
173+ }),
174+ deployment: async () => [
175+ [
176+ 'codespaces',
177+ IsAnalyticsOff.isEnvTruthy(await beneath.env.get('CODESPACES')),
178+ ],
179+ [
180+ 'gitpod',
181+ IsAnalyticsOff.isEnvSet(await beneath.env.get('GITPOD_WORKSPACE_ID')),
182+ ],
183+ ['coder', IsAnalyticsOff.isEnvTruthy(await beneath.env.get('CODER'))],
184+ [
185+ 'coder',
186+ IsAnalyticsOff.isEnvSet(
187+ await beneath.env.get('CODER_WORKSPACE_NAME'),
188+ ),
189+ ],
190+ ['devpod', IsAnalyticsOff.isEnvTruthy(await beneath.env.get('DEVPOD'))],
191+ [
192+ 'devpod',
193+ IsAnalyticsOff.isEnvSet(
194+ await beneath.env.get('DEVPOD_WORKSPACE_UID'),
195+ ),
196+ ],
197+ [
198+ 'daytona',
199+ IsAnalyticsOff.isEnvSet(await beneath.env.get('DAYTONA_WS_ID')),
200+ ],
201+ [
202+ 'gcp-cloud-workstations',
203+ IsAnalyticsOff.isEnvTruthy(
204+ await beneath.env.get('GOOGLE_CLOUD_WORKSTATIONS'),
205+ ),
206+ ],
207+ [
208+ 'aws-cloud9',
209+ IsAnalyticsOff.isEnvSet(await beneath.env.get('C9_PID')),
210+ ],
211+ [
212+ 'aws-cloud9',
213+ IsAnalyticsOff.isEnvSet(await beneath.env.get('C9_USER')),
214+ ],
215+ ['replit', IsAnalyticsOff.isEnvSet(await beneath.env.get('REPL_ID'))],
216+ ['replit', IsAnalyticsOff.isEnvSet(await beneath.env.get('REPL_SLUG'))],
217+ [
218+ 'glitch',
219+ IsAnalyticsOff.isEnvSet(await beneath.env.get('PROJECT_DOMAIN')),
220+ ],
221+ ['vercel', IsAnalyticsOff.isEnvTruthy(await beneath.env.get('VERCEL'))],
222+ [
223+ 'railway',
224+ IsAnalyticsOff.isEnvSet(
225+ await beneath.env.get('RAILWAY_ENVIRONMENT_NAME'),
226+ ),
227+ ],
228+ [
229+ 'railway',
230+ IsAnalyticsOff.isEnvSet(
231+ await beneath.env.get('RAILWAY_SERVICE_NAME'),
232+ ),
233+ ],
234+ ['render', IsAnalyticsOff.isEnvTruthy(await beneath.env.get('RENDER'))],
235+ [
236+ 'netlify',
237+ IsAnalyticsOff.isEnvTruthy(await beneath.env.get('NETLIFY')),
238+ ],
239+ ['heroku', IsAnalyticsOff.isEnvSet(await beneath.env.get('DYNO'))],
240+ [
241+ 'fly.io',
242+ IsAnalyticsOff.isEnvSet(await beneath.env.get('FLY_APP_NAME')),
243+ ],
244+ [
245+ 'fly.io',
246+ IsAnalyticsOff.isEnvSet(await beneath.env.get('FLY_MACHINE_ID')),
247+ ],
248+ [
249+ 'cloudflare-pages',
250+ IsAnalyticsOff.isEnvTruthy(await beneath.env.get('CF_PAGES')),
251+ ],
252+ [
253+ 'deno-deploy',
254+ IsAnalyticsOff.isEnvSet(await beneath.env.get('DENO_DEPLOYMENT_ID')),
255+ ],
256+ [
257+ 'aws-lambda',
258+ IsAnalyticsOff.isEnvSet(
259+ await beneath.env.get('AWS_LAMBDA_FUNCTION_NAME'),
260+ ),
261+ ],
262+ [
263+ 'aws-fargate',
264+ (await beneath.env.get('AWS_EXECUTION_ENV')) === 'AWS_ECS_FARGATE',
265+ ],
266+ [
267+ 'aws-ecs',
268+ (await beneath.env.get('AWS_EXECUTION_ENV')) === 'AWS_ECS_EC2',
269+ ],
270+ [
271+ 'gcp-cloud-run',
272+ IsAnalyticsOff.isEnvSet(await beneath.env.get('K_SERVICE')),
273+ ],
274+ [
275+ 'gcp',
276+ IsAnalyticsOff.isEnvSet(
277+ await beneath.env.get('GOOGLE_CLOUD_PROJECT'),
278+ ),
279+ ],
280+ [
281+ 'azure-app-service',
282+ IsAnalyticsOff.isEnvSet(await beneath.env.get('WEBSITE_SITE_NAME')),
283+ ],
284+ [
285+ 'azure-app-service',
286+ IsAnalyticsOff.isEnvSet(await beneath.env.get('WEBSITE_SKU')),
287+ ],
288+ [
289+ 'azure-functions',
290+ IsAnalyticsOff.isEnvSet(
291+ await beneath.env.get('AZURE_FUNCTIONS_ENVIRONMENT'),
292+ ),
293+ ],
294+ [
295+ 'digitalocean-app-platform',
296+ IsAnalyticsOff.isEnvSet(await beneath.env.get('APP_URL')) &&
297+ String(await beneath.env.get('APP_URL')).includes(
298+ 'ondigitalocean.app',
299+ ),
300+ ],
301+ [
302+ 'huggingface-spaces',
303+ IsAnalyticsOff.isEnvSet(
304+ await beneath.env.get('SPACE_CREATOR_USER_ID'),
305+ ),
306+ ],
307+ [
308+ 'github-actions',
309+ IsAnalyticsOff.isEnvTruthy(await beneath.env.get('GITHUB_ACTIONS')),
310+ ],
311+ [
312+ 'gitlab-ci',
313+ IsAnalyticsOff.isEnvTruthy(await beneath.env.get('GITLAB_CI')),
314+ ],
315+ [
316+ 'circleci',
317+ IsAnalyticsOff.isEnvSet(await beneath.env.get('CIRCLECI')),
318+ ],
319+ [
320+ 'buildkite',
321+ IsAnalyticsOff.isEnvSet(await beneath.env.get('BUILDKITE')),
322+ ],
323+ ['ci', IsAnalyticsOff.isEnvTruthy(await beneath.env.get('CI'))],
324+ [
325+ 'kubernetes',
326+ IsAnalyticsOff.isEnvSet(
327+ await beneath.env.get('KUBERNETES_SERVICE_HOST'),
328+ ),
329+ ],
330+ ],
331+ configLocation: async () => ({
332+ configDir: await beneath.env.get('CLAUDE_CONFIG_DIR'),
333+ home: await beneath.env.get('HOME'),
334+ userProfile: await beneath.env.get('USERPROFILE'),
335+ }),
336+ cwd: () => beneath.session.cwd(),
337+ repo: () => beneath.session.repo(),
338+ read: path => beneath.fs.read(path),
339+ list: path => beneath.fs.list(path),
340+ exists: path => beneath.fs.exists(path),
341+ run: argv =>
342+ beneath.process.run(argv, { timeoutMs: Context.PROBE_TIMEOUT_MS }),
39343 fetch: (url, init) => beneath.http.fetch(url, init),
344+ after: (ms, fn) => beneath.clock.after(ms, fn),
345+ sleep: ms => beneath.clock.sleep(ms),
346+ debug: text => beneath.ui.log(text, { to: 'debug' }),
40347 })
41348
349+ const telemetry: EngineInterface['telemetry'] = sender.telemetry
350+
42351 return { ...beneath, telemetry }
43352 })
44353 }
@@ -1,0 +1,3 @@
1+export type * from './sender.js'
2+
3+export * as default from '.'
@@ -1,0 +1,14 @@
1+import type { Telemetry } from '../../types'
2+
3+/**
4+ * What `telemetryOf` builds: the `$.telemetry` noun, and the flush the
5+ * plugin's session.end hook calls to send what still waits.
6+ */
7+export type Sender = {
8+ telemetry: Telemetry
9+
10+ /**
11+ * Sends every queued row now, in one batch; resolves once it settled.
12+ */
13+ flush: () => Promise<void>
14+}
@@ -1,10 +1,25 @@
1-import type { HttpInit, HttpResponse, SessionAuthorization } from 'claude-code'
1+import type {
2+ FsEntry,
3+ HttpInit,
4+ HttpResponse,
5+ ProcessRunResult,
6+ SessionAuthorization,
7+ SessionRepo,
8+ Timer,
9+} from 'claude-code'
210
11+import type { ConfigLocation } from '../config-location'
12+import type Deployment from '../deployment'
313 import type { Environment } from '../environment'
14+import type { Facts } from '../facts'
15+import type { PolicyPins } from '../policy-pins'
416
517 /**
6- * What `telemetryOf` reaches on the nouns beneath: the session's authorize
7- * and reads, and one fetch. Each is a call on the plugin's own `$`.
18+ * What `telemetryOf` reaches on the nouns beneath, each a call on the
19+ * plugin's own `$`.
20+ *
21+ * The session, the environment, files, one process, the clock, one fetch
22+ * and the debug log.
823 */
924 export type TelemetryDeps = {
1025 /**
@@ -13,23 +28,94 @@
1328 authorize: () => Promise<SessionAuthorization>
1429
1530 /**
16- * The session's id, for the row.
31+ * The session's id, for the batch.
1732 */
1833 id: () => Promise<string>
1934
2035 /**
21- * The session's model, for the row.
36+ * The session's model, for the batch.
2237 */
2338 model: () => Promise<string>
2439
2540 /**
26- * Reads, at each call, what the row and the off switch need of the
27- * environment: the build's user type and every analytics-off variable.
41+ * Whether a person is at the prompt: what `session.start` said, else
42+ * whether the session draws on a terminal.
2843 */
44+ isInteractive: () => Promise<boolean>
45+
46+ /**
47+ * Reads, before each batch, the build's user type and every analytics-off
48+ * variable.
49+ */
2950 environment: () => Promise<Environment>
3051
3152 /**
32- * Posts the batch to the ingest with the credential handle.
53+ * Reads the managed policy's gateway pins.
3354 */
55+ policy: () => Promise<PolicyPins>
56+
57+ /**
58+ * Reads, once a session, the variables that describe it in a row.
59+ */
60+ facts: () => Promise<Facts>
61+
62+ /**
63+ * Reads, once a session, whether each deployment's variable holds.
64+ */
65+ deployment: () => Promise<readonly Deployment.DeploymentSignal[]>
66+
67+ /**
68+ * Reads where the CLI's global config file is.
69+ */
70+ configLocation: () => Promise<ConfigLocation>
71+
72+ /**
73+ * The directory the session runs in.
74+ */
75+ cwd: () => Promise<string>
76+
77+ /**
78+ * The repository the session runs in, or null.
79+ */
80+ repo: () => Promise<SessionRepo | null>
81+
82+ /**
83+ * Reads a file as text; rejects when it cannot be read.
84+ */
85+ read: (path: string) => Promise<string>
86+
87+ /**
88+ * Lists a directory; rejects when it cannot be listed.
89+ */
90+ list: (path: string) => Promise<readonly FsEntry[]>
91+
92+ /**
93+ * Whether a path exists.
94+ */
95+ exists: (path: string) => Promise<boolean>
96+
97+ /**
98+ * Runs one program to its end.
99+ */
100+ run: (argv: readonly string[]) => Promise<ProcessRunResult>
101+
102+ /**
103+ * Posts a batch to the ingest with the credential handle.
104+ */
34105 fetch: (url: string, init: HttpInit) => Promise<HttpResponse>
106+
107+ /**
108+ * Calls `fn` once after `ms` milliseconds.
109+ */
110+ after: (ms: number, fn: () => void) => Timer
111+
112+ /**
113+ * Resolves after `ms` milliseconds.
114+ */
115+ sleep: (ms: number) => Promise<void>
116+
117+ /**
118+ * Writes one line to the debug log.
119+ */
120+ debug: (text: string) => void
35121 }
@@ -1,74 +1,191 @@
1+import type { HttpResponse, Timer } from 'claude-code'
2+
13 import type { Telemetry } from '../../types'
4+import Batching from '../batching'
5+import Context from '../context'
26 import Entries from '../entries'
7+import type { Environment } from '../environment'
38 import { isAnalyticsOff } from '../is-analytics-off'
9+import type { Sender } from '../sender'
410 import type { TelemetryDeps } from '../telemetry-deps'
511
612 /**
7- * Builds `$.telemetry`: `log` and `mark` check the entry, read the environment
8- * and authorize afresh, build the first-party row and POST it to the ingest.
13+ * Builds `$.telemetry` and its flush: `log` and `mark` check the entry and
14+ * queue a row; a batch goes out on a timer, when full, or when flushed.
915 *
10- * One POST per call, rows one after another, nothing kept between them: a
11- * session that has moved to a third-party provider or a gateway, or turned
12- * analytics off, sends nothing more; no credential or a refusal rejects.
16+ * Each batch reads the switches afresh: analytics off, or unreadable, sends
17+ * nothing; no credential, or an ingest that refuses after one retry, drops
18+ * the batch. Each outcome is one debug line; the context is gathered once.
1319 *
1420 * @param deps the calls on the nouns beneath
15- * @returns the `$.telemetry` interface, `log` and `mark`
21+ * @returns the noun and its flush
1622 */
17-export function telemetryOf(deps: TelemetryDeps): Telemetry {
18- let queue: Promise<unknown> = Promise.resolve()
23+export function telemetryOf(deps: TelemetryDeps): Sender {
24+ let pending: Batching.PendingRow[] = []
25+ let timer: Timer | undefined
26+ let context: Promise<Context.Context> | undefined
27+ let priming: Promise<void> | undefined
28+ let sending: Promise<void> = Promise.resolve()
1929
20- async function post(
21- fields: Entries.Fields,
22- method: Entries.Method,
23- ): Promise<void> {
24- const environment = await deps.environment()
30+ function gathered(): Promise<Context.Context> {
31+ const gathering =
32+ context ??
33+ deps
34+ .isInteractive()
35+ .then(isInteractive => Context.contextOf(deps, isInteractive))
36+ .catch((error: unknown) => {
37+ context = undefined
38+ throw error
39+ })
2540
26- if (isAnalyticsOff(environment)) {
27- return
28- }
41+ context = gathering
2942
30- const body = Entries.batchOf(fields, {
31- sessionId: await deps.id(),
32- model: await deps.model(),
33- userType: environment.userType === 'ant' ? 'ant' : 'external',
34- })
43+ return gathering
44+ }
3545
36- const auth = await deps.authorize()
46+ async function sendingEnvironment(): Promise<Environment | undefined> {
47+ try {
48+ const [environment, policy] = await Promise.all([
49+ deps.environment(),
50+ deps.policy(),
51+ ])
3752
38- if (!auth) {
39- throw Entries.refusal(
40- 'this session has no first-party credential to authorize',
41- method,
53+ return isAnalyticsOff(environment, policy) ? undefined : environment
54+ } catch (error) {
55+ deps.debug(
56+ 'telemetry: the analytics switches could not be read, so nothing ' +
57+ `is sent (${Batching.messageOf(error)})`,
4258 )
59+
60+ return undefined
4361 }
62+ }
4463
45- const response = await deps.fetch(Entries.INGEST_URL, {
64+ async function primed(): Promise<void> {
65+ const environment = await sendingEnvironment()
66+
67+ if (environment) {
68+ await gathered()
69+ }
70+ }
71+
72+ const post = (body: string, auth: string): Promise<HttpResponse> =>
73+ deps.fetch(Entries.INGEST_URL, {
4674 method: 'POST',
4775 headers: {
4876 'Content-Type': 'application/json',
4977 'x-service-name': 'claude-code',
5078 },
51- auth: auth.handle,
79+ auth,
5280 body,
5381 })
5482
83+ async function postedWithRetry(
84+ body: string,
85+ auth: string,
86+ ): Promise<HttpResponse> {
87+ let first: HttpResponse | undefined
88+
89+ try {
90+ first = await post(body, auth)
91+ } catch {
92+ first = undefined
93+ }
94+
95+ const isSettled =
96+ first !== undefined && (first.ok || !Batching.isRetriable(first.status))
97+
98+ if (first !== undefined && isSettled) {
99+ return first
100+ }
101+
102+ await deps.sleep(Batching.RETRY_DELAY_MS)
103+
104+ return post(body, auth)
105+ }
106+
107+ async function send(rows: readonly Batching.PendingRow[]): Promise<void> {
108+ const environment = await sendingEnvironment()
109+
110+ if (!environment) {
111+ return
112+ }
113+
114+ const authorization = await deps.authorize()
115+
116+ if (!authorization) {
117+ throw new Error('this session has no first-party credential to authorize')
118+ }
119+
120+ const settled = await gathered()
121+
122+ const response = await postedWithRetry(
123+ Entries.batchOf(rows, {
124+ sessionId: await deps.id(),
125+ model: await deps.model(),
126+ userType: environment.userType === 'ant' ? 'ant' : 'external',
127+ isInteractive: await deps.isInteractive(),
128+ isClaudeAiAuth:
129+ authorization.kind === 'bearer' &&
130+ settled.identity.accountUuid !== undefined,
131+ context: settled,
132+ }),
133+ authorization.handle,
134+ )
135+
55136 if (!response.ok) {
56- throw Entries.refusal(`the ingest answered ${response.status}`, method)
137+ throw new Error(`the ingest answered ${response.status}`)
57138 }
139+
140+ deps.debug(`telemetry: sent ${rows.length} row(s)`)
58141 }
59142
60- function queued(
61- fields: Entries.Fields,
62- method: Entries.Method,
63- ): Promise<void> {
64- const turn = queue.then(() => post(fields, method))
65- queue = turn.catch(() => undefined)
143+ function flush(): Promise<void> {
144+ timer?.cancel()
145+ timer = undefined
66146
67- return turn
147+ const rows = pending
148+
149+ pending = []
150+
151+ if (rows.length === 0) {
152+ return sending
153+ }
154+
155+ sending = sending.then(() =>
156+ send(rows).catch((error: unknown) => {
157+ deps.debug(
158+ `telemetry: ${rows.length} row(s) not sent: ` +
159+ Batching.messageOf(error),
160+ )
161+ }),
162+ )
163+
164+ return sending
68165 }
69166
70- return {
71- log: async entry => queued(Entries.checkedFields(entry), 'log'),
72- mark: async entry => queued(Entries.checkedMark(entry), 'mark'),
167+ function queued(fields: Entries.Fields) {
168+ priming ??= primed().catch(() => undefined)
169+
170+ pending.push({
171+ fields,
172+ eventId: crypto.randomUUID(),
173+ loggedAt: new Date().toISOString(),
174+ })
175+
176+ if (pending.length >= Batching.BATCH_ROWS) {
177+ void flush()
178+
179+ return
180+ }
181+
182+ timer ??= deps.after(Batching.BATCH_WINDOW_MS, () => void flush())
73183 }
184+
185+ const telemetry: Telemetry = {
186+ log: async entry => queued(Entries.checkedFields(entry)),
187+ mark: async entry => queued(Entries.checkedMark(entry)),
188+ }
189+
190+ return { telemetry, flush }
74191 }
Dmods/telemetry/tests/fixtures/analytics-off-environments.ts
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/tests/fixtures/base64-alphabet.ts
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/tests/fixtures/base64-decoded.ts
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/tests/fixtures/base64-digit-bits.ts
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/tests/fixtures/batch-of.ts
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/tests/fixtures/ingest-batch.ts
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/tests/fixtures/ingest-event.ts
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/tests/fixtures/marking.ts
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/tests/fixtures/recording.ts
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/tests/fixtures/row-of.ts
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.