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
# Conflicts: # mods/diff/README.md # mods/diff/hooks/register.ts
Large file, diff not shown.
| @@ -6,8 +6,16 @@ | ||
| 6 | 6 | <author><name>Anthropic</name></author> |
| 7 | 7 | <link rel="alternate" type="text/html" href="https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md"/> |
| 8 | 8 | <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> | |
| 10 | 10 | <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"><p>• 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</p> | |
| 16 | +<p>• Added an Auto mode server row to /status showing whether this session's auto mode classifier runs on the server</p></content> | |
| 17 | + </entry> | |
| 18 | + <entry> | |
| 11 | 19 | <id>https://github.com/anthropics/claude-code/releases/tag/v2.1.277</id> |
| 12 | 20 | <title>Claude Code v2.1.277</title> |
| 13 | 21 | <link rel="alternate" type="text/html" href="https://github.com/anthropics/claude-code/releases/tag/v2.1.277"/> |
| @@ -1150,14 +1158,4 @@ | ||
| 1150 | 1158 | <p>• [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</p> |
| 1151 | 1159 | <p>• [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</p></content> |
| 1152 | 1160 | </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"><p>• Fixed Bash commands failing with "task output swap refused (tasks dir moved or linked)" on some Macs</p> | |
| 1159 | -<p>• Fixed "always allow" not saving in a project that has no .claude/settings.local.json yet</p> | |
| 1160 | -<p>• 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</p> | |
| 1161 | -<p>• 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</p></content> | |
| 1162 | - </entry> | |
| 1163 | 1161 | </feed> |
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | | --- | --- | --- | |
| 10 | 10 | | [`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 | |
| 11 | 11 | | [`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 | | |
| 13 | 13 | | [`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 | |
| 14 | 14 | |
| 15 | 15 | Each folder is a complete plugin: `.claude-plugin/plugin.json`, a |
| @@ -25,7 +25,10 @@ | ||
| 25 | 25 | leaves the opening to a later edit. A docked pane fetches before it opens, |
| 26 | 26 | as the built-in panel primes its data, so it never lands on |
| 27 | 27 | `Loading diff…`; an open the engine leaves waiting undrawn is withdrawn, so |
| 28 | -no later resize seats it, and the next edit asks again. | |
| 28 | +no later resize seats it, and the next edit asks again. A session resumed | |
| 29 | +or continued whose transcript already holds such an edit opens the pane on | |
| 30 | +the same terms as soon as the width is known, as the built-in opens on the | |
| 31 | +history it restores. | |
| 29 | 32 | |
| 30 | 33 | Under the fullscreen layout a terminal under 110 columns gets the |
| 31 | 34 | built-in's line asking for a wider one and nothing opens. Without that |
| @@ -41,7 +44,9 @@ | ||
| 41 | 44 | file: its hunks ride the next prompt as context, once. |
| 42 | 45 | |
| 43 | 46 | The pane compares the working tree against HEAD, split at the session's |
| 44 | -start (the default), against HEAD plainly, or against the merge-base with | |
| 47 | +start (the default; the start the engine gives in `$.session.usage()`, so a | |
| 48 | +resumed session keeps its first and `/clear` begins anew), against HEAD | |
| 49 | +plainly, or against the merge-base with | |
| 45 | 50 | the default branch; the base line under the header names a base other than |
| 46 | 51 | the session's, and the choice is kept per repository in the plugin's store. |
| 47 | 52 | A picker shows one earlier turn's edits instead of the working tree, read |
| @@ -68,7 +73,7 @@ | ||
| 68 | 73 | |
| 69 | 74 | | event | what the hook does | |
| 70 | 75 | | --- | --- | |
| 71 | -| `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. | | |
| 76 | +| `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. | | |
| 72 | 77 | | `ui.render` of `PromptHint` | Reads the terminal's width and whether its layout docks a pane, which decide whether the first edit may open the pane. | |
| 73 | 78 | | `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. | |
| 74 | 79 | | `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. | |
| @@ -111,6 +111,14 @@ | ||
| 111 | 111 | sessionId: () => Promise<string> |
| 112 | 112 | |
| 113 | 113 | /** |
| 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 | + /** | |
| 114 | 122 | * `$.telemetry.mark`; rejects where the telemetry built-in is absent. |
| 115 | 123 | */ |
| 116 | 124 | mark: EngineInterface['telemetry']['mark'] |
| @@ -27,6 +27,7 @@ | ||
| 27 | 27 | import PaneToggle from './pane-toggle' |
| 28 | 28 | import Record from './record' |
| 29 | 29 | import Tools from './tools' |
| 30 | +import Turns from './turns' | |
| 30 | 31 | import Views from './views' |
| 31 | 32 | |
| 32 | 33 | /** |
| @@ -34,9 +35,12 @@ | ||
| 34 | 35 | * pane's drawing and refresh, its opening on Claude's first edit, the ask. |
| 35 | 36 | * |
| 36 | 37 | * Git runs when the built-in's would: `session.start` binds the host and |
| 37 | - * registers `/diff`; `/diff` or the main loop's first checkpointed edit with | |
| 38 | - * room pins the backend, until `/clear`; a docked pane fetches, then opens, | |
| 39 | - * and such an edit inside the tree fetches until one lists a file to open on. | |
| 38 | + * registers `/diff`, and off its dispatch reads the transcript, so a resumed | |
| 39 | + * session whose turns already edited opens as its first edit would; `/diff` | |
| 40 | + * or the main loop's first checkpointed edit with room pins the backend, | |
| 41 | + * until `/clear`, which reads afresh under a pane it leaves open; a docked | |
| 42 | + * pane fetches, then opens, and such an edit inside the tree fetches until | |
| 43 | + * one lists a file to open on. | |
| 40 | 44 | * |
| 41 | 45 | * @param on the engine's registrar |
| 42 | 46 | */ |
| @@ -48,6 +52,7 @@ | ||
| 48 | 52 | let isPaneOpen = false |
| 49 | 53 | let dialogRows: number | null = null |
| 50 | 54 | let hasAutoOpened = false |
| 55 | + let hasRestoredEdits = false | |
| 51 | 56 | let isAutoOpening = false |
| 52 | 57 | let landed = 0 |
| 53 | 58 | let opens = 0 |
| @@ -539,7 +544,7 @@ | ||
| 539 | 544 | |
| 540 | 545 | async function openOnFirstEdit( |
| 541 | 546 | engine: Host, |
| 542 | - path: string | null, | |
| 547 | + path: string | null = null, | |
| 543 | 548 | ): Promise<void> { |
| 544 | 549 | if (isTaken()) { |
| 545 | 550 | return |
| @@ -582,6 +587,16 @@ | ||
| 582 | 587 | } |
| 583 | 588 | } |
| 584 | 589 | |
| 590 | + async function openOnRestore(engine: Host): Promise<void> { | |
| 591 | + const messages = await engine.messages().catch((): SessionMessage[] => []) | |
| 592 | + | |
| 593 | + hasRestoredEdits = Turns.turnDiffsOf(messages).length > 0 | |
| 594 | + | |
| 595 | + if (hasRestoredEdits) { | |
| 596 | + await openOnFirstEdit(engine) | |
| 597 | + } | |
| 598 | + } | |
| 599 | + | |
| 585 | 600 | function disarm(engine: Host) { |
| 586 | 601 | armed = null |
| 587 | 602 | model = { ...model, armedPath: null } |
| @@ -686,8 +701,14 @@ | ||
| 686 | 701 | redraw(engine) |
| 687 | 702 | } |
| 688 | 703 | |
| 704 | + async function startedAtOf(engine: Host): Promise<number | null> { | |
| 705 | + const startedAt: unknown = await engine.startedAt().catch(() => undefined) | |
| 706 | + | |
| 707 | + return typeof startedAt === 'number' ? startedAt : null | |
| 708 | + } | |
| 709 | + | |
| 689 | 710 | async function bind(engine: Host, cwd: string): Promise<void> { |
| 690 | - sessionStartMs = await engine.now() | |
| 711 | + sessionStartMs = (await startedAtOf(engine)) ?? (await engine.now()) | |
| 691 | 712 | pin.cwd = cwd |
| 692 | 713 | |
| 693 | 714 | try { |
| @@ -727,12 +748,22 @@ | ||
| 727 | 748 | closePane: pane => $.ui.close(pane), |
| 728 | 749 | registerCommand: spec => $.command.register(spec), |
| 729 | 750 | sessionId: () => $.session.id(), |
| 751 | + startedAt: () => | |
| 752 | + $.session | |
| 753 | + .usage() | |
| 754 | + .then((usage: unknown) => | |
| 755 | + isRecord(usage) ? usage.startedAt : undefined, | |
| 756 | + ), | |
| 730 | 757 | mark: entry => $.telemetry.mark(entry), |
| 731 | 758 | log: entry => $.telemetry.log(entry), |
| 732 | 759 | }, |
| 733 | 760 | e.cwd, |
| 734 | 761 | ) |
| 735 | 762 | |
| 763 | + if (host) { | |
| 764 | + void openOnRestore(host).catch(() => undefined) | |
| 765 | + } | |
| 766 | + | |
| 736 | 767 | return next(e) |
| 737 | 768 | }) |
| 738 | 769 | |
| @@ -741,12 +772,18 @@ | ||
| 741 | 772 | const viewport: { columns?: number; isFullscreen?: boolean } | undefined = |
| 742 | 773 | e.viewport |
| 743 | 774 | |
| 775 | + const isFirstMeasure = columns === null && viewport?.columns !== undefined | |
| 776 | + | |
| 744 | 777 | columns = viewport?.columns ?? columns |
| 745 | 778 | |
| 746 | 779 | model = { |
| 747 | 780 | ...model, |
| 748 | 781 | isFullscreen: viewport?.isFullscreen ?? model.isFullscreen, |
| 749 | 782 | } |
| 783 | + | |
| 784 | + if (isFirstMeasure && hasRestoredEdits && host) { | |
| 785 | + void openOnFirstEdit(host).catch(() => undefined) | |
| 786 | + } | |
| 750 | 787 | } |
| 751 | 788 | |
| 752 | 789 | return next(e) |
| @@ -923,20 +960,39 @@ | ||
| 923 | 960 | on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => { |
| 924 | 961 | const result = await next(e) |
| 925 | 962 | |
| 926 | - if (host) { | |
| 927 | - if (isPaneOpen) { | |
| 928 | - await closePane(host).catch(() => undefined) | |
| 929 | - } | |
| 963 | + if (!host) { | |
| 964 | + return result | |
| 965 | + } | |
| 930 | 966 | |
| 931 | - unpin() | |
| 932 | - hasAutoOpened = false | |
| 933 | - bodyStamp = null | |
| 934 | - bodyBase = null | |
| 935 | - bodyLoads.clear() | |
| 936 | - disarm(host) | |
| 937 | - model = PaneState.afterNewSession(model) | |
| 967 | + const isResume = e.command === 'resume' | |
| 968 | + const isKeptOpen = isPaneOpen && !isResume | |
| 969 | + | |
| 970 | + if (isPaneOpen && isResume) { | |
| 971 | + await closePane(host).catch(() => undefined) | |
| 938 | 972 | } |
| 939 | 973 | |
| 974 | + unpin() | |
| 975 | + hasAutoOpened = false | |
| 976 | + hasRestoredEdits = false | |
| 977 | + bodyStamp = null | |
| 978 | + bodyBase = null | |
| 979 | + bodyLoads.clear() | |
| 980 | + disarm(host) | |
| 981 | + model = PaneState.afterNewSession(model) | |
| 982 | + | |
| 983 | + sessionStartMs = | |
| 984 | + (await startedAtOf(host)) ?? | |
| 985 | + (isResume ? sessionStartMs : await host.now()) | |
| 986 | + | |
| 987 | + if (isKeptOpen) { | |
| 988 | + await pinBackend(host) | |
| 989 | + void refresh(host) | |
| 990 | + } | |
| 991 | + | |
| 992 | + if (isResume) { | |
| 993 | + void openOnRestore(host).catch(() => undefined) | |
| 994 | + } | |
| 995 | + | |
| 940 | 996 | return result |
| 941 | 997 | }) |
| 942 | 998 |
| @@ -1,8 +1,9 @@ | ||
| 1 | -import type { Settings } from 'claude-code' | |
| 1 | +import type { SessionMessage, Settings } from 'claude-code' | |
| 2 | 2 | |
| 3 | 3 | /** |
| 4 | 4 | * What the world beneath a repository session holds and answers besides |
| 5 | - * git: the store, the settings, the environment, whether a pane is seated. | |
| 5 | + * git: the store, the settings, the environment, the transcript, whether a | |
| 6 | + * pane is seated. | |
| 6 | 7 | */ |
| 7 | 8 | export type Beneath = { |
| 8 | 9 | /** |
| @@ -21,6 +22,12 @@ | ||
| 21 | 22 | env?: Readonly<Record<string, string>> |
| 22 | 23 | |
| 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 | + /** | |
| 24 | 31 | * Whether an open made now is left waiting undrawn (LEFT_WAITING), as an |
| 25 | 32 | * engine leaves an unasked open on a narrow terminal; placed when not given. |
| 26 | 33 | */ |
| @@ -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 | +] |
| @@ -18,7 +18,8 @@ | ||
| 18 | 18 | * |
| 19 | 19 | * @param on the test's `on` |
| 20 | 20 | * @param script git's output for each invocation whose line holds the key |
| 21 | - * @param beneath the store, settings, environment, whether an open is seated | |
| 21 | + * @param beneath the store, settings, environment, transcript, whether an | |
| 22 | + * open is seated | |
| 22 | 23 | * @returns the runs, the ring's moves, the panes opened, left waiting and |
| 23 | 24 | * closed, the statuses, the clock |
| 24 | 25 | */ |
| @@ -68,7 +69,7 @@ | ||
| 68 | 69 | on('ui.close', closed.hook) |
| 69 | 70 | on('ui.invalidate', () => ({ value: undefined })) |
| 70 | 71 | on('ui.render', { component: 'PromptHint' }, () => HINT_DRAWN) |
| 71 | - on('session.messages', () => ({ value: [] })) | |
| 72 | + on('session.messages', () => ({ value: [...(beneath.messages?.() ?? [])] })) | |
| 72 | 73 | on('settings.read', () => ({ value: beneath.settings ?? {} })) |
| 73 | 74 | mock.store(on, beneath.stored ?? {}) |
| 74 | 75 | mock.env(on, beneath.env ?? {}) |
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | export * from './diff.js' |
| 14 | 14 | export * from './diff-at.js' |
| 15 | 15 | export * from './edit-patch-of.js' |
| 16 | +export * from './edited-transcript.js' | |
| 16 | 17 | export * from './fetch-diff' |
| 17 | 18 | export * from './git-hung.js' |
| 18 | 19 | export * from './git-in.js' |
| @@ -43,6 +44,7 @@ | ||
| 43 | 44 | export * from './renamed.js' |
| 44 | 45 | export * from './repository.js' |
| 45 | 46 | export * from './repository-of' |
| 47 | +export * from './resume.js' | |
| 46 | 48 | export * from './ring-onto.js' |
| 47 | 49 | export * from './scripted' |
| 48 | 50 | export * from './session.js' |
| @@ -55,6 +57,7 @@ | ||
| 55 | 57 | export * from './two-files.js' |
| 56 | 58 | export * from './typed-prompt-of.js' |
| 57 | 59 | export * from './unsaid-hint.js' |
| 60 | +export * from './usage-at.js' | |
| 58 | 61 | export * from './vs-main.js' |
| 59 | 62 | export * from './wheel-over-list.js' |
| 60 | 63 | 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 @@ | ||
| 94 | 94 | expect(drawn).toContain('moved.ts') |
| 95 | 95 | expect(drawn).toContain('+1 file edited before this session (show)') |
| 96 | 96 | }) |
| 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 | + }) | |
| 97 | 135 | }) |
| @@ -1,4 +1,4 @@ | ||
| 1 | -import type { Args, ResultOf } from 'claude-code' | |
| 1 | +import type { Args, ResultOf, SessionMessage } from 'claude-code' | |
| 2 | 2 | import { describe, expect, mock, test, tier } from 'claude-code/testing' |
| 3 | 3 | |
| 4 | 4 | import Limits from '../hooks/limits' |
| @@ -1170,18 +1170,139 @@ | ||
| 1170 | 1170 | expect(drawn).not.toContain('Loading diff') |
| 1171 | 1171 | }) |
| 1172 | 1172 | |
| 1173 | - test('/clear closes the pane it finds open', async ($, on) => { | |
| 1173 | + test('/clear leaves the pane it finds open up and reads the repository afresh', async ($, on) => { | |
| 1174 | 1174 | const world = Fixtures.inRepository(on) |
| 1175 | 1175 | |
| 1176 | 1176 | on('command.run', { command: 'clear' }, () => ({})) |
| 1177 | 1177 | |
| 1178 | 1178 | await $.session.start(Fixtures.SESSION) |
| 1179 | 1179 | await $.command.run(Fixtures.DIFF) |
| 1180 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 1181 | + | |
| 1182 | + const read = world.runs.length | |
| 1183 | + | |
| 1180 | 1184 | await $.command.run(Fixtures.CLEAR) |
| 1185 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 1181 | 1186 | |
| 1182 | - expect(world.closed.map(pane => pane.id)).toEqual(['diff']) | |
| 1187 | + expect(world.closed, 'as the built-in panel stays across /clear').toEqual( | |
| 1188 | + [], | |
| 1189 | + ) | |
| 1190 | + | |
| 1191 | + expect( | |
| 1192 | + world.runs.slice(read).map(run => Fixtures.gitWordOf(run.argv))[0], | |
| 1193 | + 'the repository found again for the conversation that starts over', | |
| 1194 | + ).toBe('rev-parse --show-toplevel') | |
| 1195 | + | |
| 1196 | + expect( | |
| 1197 | + Fixtures.textOf(await $.ui.render(Fixtures.PANE)), | |
| 1198 | + 'drawn from the fresh read', | |
| 1199 | + ).toContain('1 file changed') | |
| 1183 | 1200 | }) |
| 1184 | 1201 | |
| 1202 | + test('a resumed session whose turns edited opens the pane before any new edit', async ($, on) => { | |
| 1203 | + const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { | |
| 1204 | + messages: () => Fixtures.EDITED_TRANSCRIPT, | |
| 1205 | + }) | |
| 1206 | + | |
| 1207 | + await $.session.start(Fixtures.SESSION) | |
| 1208 | + | |
| 1209 | + expect(world.runs, 'the start itself still runs no git').toEqual([]) | |
| 1210 | + | |
| 1211 | + await $.ui.render(Fixtures.HINT) | |
| 1212 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 1213 | + | |
| 1214 | + expect( | |
| 1215 | + world.opened.map(pane => pane.id), | |
| 1216 | + 'open once the width is known, as the built-in opens on the restore', | |
| 1217 | + ).toEqual(['diff']) | |
| 1218 | + }) | |
| 1219 | + | |
| 1220 | + test('a resumed session opens nothing where its first edit would not', async ($, on) => { | |
| 1221 | + const narrow = Fixtures.inRepository(on, Fixtures.REPOSITORY, { | |
| 1222 | + messages: () => Fixtures.EDITED_TRANSCRIPT, | |
| 1223 | + }) | |
| 1224 | + | |
| 1225 | + await $.session.start(Fixtures.SESSION) | |
| 1226 | + await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)) | |
| 1227 | + await narrow.clock.advance(Fixtures.SETTLE_MS) | |
| 1228 | + | |
| 1229 | + expect(narrow.opened, 'under the unasked floor, never kept open').toEqual( | |
| 1230 | + [], | |
| 1231 | + ) | |
| 1232 | + | |
| 1233 | + expect(narrow.runs, 'and no git for it').toEqual([]) | |
| 1234 | + }) | |
| 1235 | + | |
| 1236 | + test('a resumed session the person kept the pane open in opens it from the lower floor', async ($, on) => { | |
| 1237 | + const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { | |
| 1238 | + messages: () => Fixtures.EDITED_TRANSCRIPT, | |
| 1239 | + stored: { [Names.STORE_OPEN_KEY]: true }, | |
| 1240 | + }) | |
| 1241 | + | |
| 1242 | + await $.session.start(Fixtures.SESSION) | |
| 1243 | + await $.ui.render(Fixtures.hintAt(Limits.OPEN_MIN_COLUMNS)) | |
| 1244 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 1245 | + | |
| 1246 | + expect(world.opened.map(pane => pane.id)).toEqual(['diff']) | |
| 1247 | + }) | |
| 1248 | + | |
| 1249 | + test('a resumed session with no edits opens nothing', async ($, on) => { | |
| 1250 | + const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { | |
| 1251 | + messages: () => [Fixtures.promptOf('just talk')], | |
| 1252 | + }) | |
| 1253 | + | |
| 1254 | + await $.session.start(Fixtures.SESSION) | |
| 1255 | + await $.ui.render(Fixtures.HINT) | |
| 1256 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 1257 | + | |
| 1258 | + expect(world.opened).toEqual([]) | |
| 1259 | + expect(world.runs).toEqual([]) | |
| 1260 | + }) | |
| 1261 | + | |
| 1262 | + test('a resumed session whose pane the person closed opens nothing', async ($, on) => { | |
| 1263 | + const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { | |
| 1264 | + messages: () => Fixtures.EDITED_TRANSCRIPT, | |
| 1265 | + stored: { [Names.STORE_OPEN_KEY]: false }, | |
| 1266 | + }) | |
| 1267 | + | |
| 1268 | + await $.session.start(Fixtures.SESSION) | |
| 1269 | + await $.ui.render(Fixtures.HINT) | |
| 1270 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 1271 | + | |
| 1272 | + expect(world.opened).toEqual([]) | |
| 1273 | + expect(world.runs).toEqual([]) | |
| 1274 | + }) | |
| 1275 | + | |
| 1276 | + test('/resume closes the pane, then opens it for the turns it brought back', async ($, on) => { | |
| 1277 | + let transcript: readonly SessionMessage[] = [] | |
| 1278 | + | |
| 1279 | + const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { | |
| 1280 | + messages: () => transcript, | |
| 1281 | + }) | |
| 1282 | + | |
| 1283 | + on('command.run', { command: 'resume' }, () => { | |
| 1284 | + transcript = Fixtures.EDITED_TRANSCRIPT | |
| 1285 | + | |
| 1286 | + return {} | |
| 1287 | + }) | |
| 1288 | + | |
| 1289 | + await $.session.start(Fixtures.SESSION) | |
| 1290 | + await $.ui.render(Fixtures.HINT) | |
| 1291 | + await $.command.run(Fixtures.DIFF) | |
| 1292 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 1293 | + await $.command.run(Fixtures.RESUME) | |
| 1294 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 1295 | + | |
| 1296 | + expect(world.closed.map(pane => pane.id), 'closed at /resume').toEqual([ | |
| 1297 | + 'diff', | |
| 1298 | + ]) | |
| 1299 | + | |
| 1300 | + expect( | |
| 1301 | + world.opened.map(pane => pane.id), | |
| 1302 | + '/diff opened it, /resume opened it again for the restored turns', | |
| 1303 | + ).toEqual(['diff', 'diff']) | |
| 1304 | + }) | |
| 1305 | + | |
| 1185 | 1306 | test('ask attaches the hunks on screen and calls no tool', async ($, on) => { |
| 1186 | 1307 | const world = Fixtures.inRepository(on, Fixtures.oneSecret()) |
| 1187 | 1308 | const called: Args<'tool.call'>[] = [] |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "telemetry", |
| 3 | 3 | "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.", | |
| 5 | 5 | "author": { |
| 6 | 6 | "name": "Anthropic" |
| 7 | 7 | }, |
| @@ -1,32 +1,55 @@ | ||
| 1 | 1 | # telemetry |
| 2 | 2 | |
| 3 | 3 | 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. | |
| 14 | 21 | |
| 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 | + | |
| 15 | 34 | It sends nothing wherever the CLI's own analytics are off: under |
| 16 | 35 | `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`. | |
| 23 | 44 | |
| 24 | 45 | Nothing free-form reaches a row. An event name and every property key is a |
| 25 | 46 | snake_case token; a value is a finite number, a boolean, or a Choice (a |
| 26 | 47 | string named together with the list it is chosen from), under `log` and |
| 27 | 48 | `mark` alike; `mark` takes `ok`, `sad` or `bad`, with a `reason` required on |
| 28 | 49 | 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. | |
| 30 | 53 | |
| 31 | 54 | `hooks/register.ts` is the module; `types/index.d.ts` is the noun's contract, |
| 32 | 55 | the one declaration of `$.telemetry` that this mod's hooks, a mod calling the |
| @@ -35,20 +58,26 @@ | ||
| 35 | 58 | ## What it hooks |
| 36 | 59 | |
| 37 | 60 | `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. | |
| 39 | 65 | |
| 40 | 66 | ## What it calls on `$` |
| 41 | 67 | |
| 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. | |
| 45 | 74 | |
| 46 | -## Where it runs | |
| 75 | +## Where it runs, whom it serves | |
| 47 | 76 | |
| 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' | |
| 3 | 5 | |
| 4 | 6 | /** |
| 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. | |
| 7 | 9 | * |
| 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 | |
| 15 | 13 | */ |
| 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 | + })), | |
| 33 | 23 | }) |
| 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 @@ | ||
| 1 | 1 | export * from './batch-of.js' |
| 2 | +export * from './batch-stamp' | |
| 2 | 3 | export * from './checked-fields.js' |
| 3 | 4 | export * from './checked-mark.js' |
| 4 | 5 | export * from './checked-props' |
| @@ -6,6 +7,7 @@ | ||
| 6 | 7 | export * from './choice-token' |
| 7 | 8 | export * from './choices-limit' |
| 8 | 9 | export * from './core-event-prefix' |
| 10 | +export * from './event-data-of' | |
| 9 | 11 | export * from './event-prefix' |
| 10 | 12 | export * from './feature-prefix' |
| 11 | 13 | export * from './fields' |
| @@ -19,7 +21,7 @@ | ||
| 19 | 21 | export * from './method' |
| 20 | 22 | export * from './prop-limit' |
| 21 | 23 | export * from './refusal' |
| 22 | -export * from './row-session' | |
| 23 | 24 | export * from './token' |
| 25 | +export * from './wire-of' | |
| 24 | 26 | |
| 25 | 27 | export * as default from '.' |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './row-session.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 @@ | ||
| 1 | 1 | /** |
| 2 | - * What the plugin reads of the environment before each row: the | |
| 2 | + * What the plugin reads of the environment before each batch: the | |
| 3 | 3 | * build's user type and every switch that turns the CLI's analytics off. |
| 4 | 4 | * |
| 5 | 5 | * 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. | |
| 7 | 7 | */ |
| 8 | 8 | export type Environment = { |
| 9 | 9 | readonly userType: string | undefined |
| 10 | + readonly nodeEnv: string | undefined | |
| 10 | 11 | readonly disableTelemetry: string | undefined |
| 11 | 12 | readonly disableNonessentialTraffic: string | undefined |
| 12 | 13 | readonly doNotTrack: string | undefined |
| 13 | 14 | readonly customOauthUrl: string | undefined |
| 15 | + readonly useGateway: string | undefined | |
| 16 | + readonly providerManagedByHost: string | undefined | |
| 14 | 17 | readonly useBedrock: string | undefined |
| 15 | 18 | readonly useVertex: string | undefined |
| 16 | 19 | 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 @@ | ||
| 1 | 1 | { |
| 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 | + ] | |
| 4 | 6 | } |
| @@ -1,7 +1,15 @@ | ||
| 1 | +export * from './batching' | |
| 2 | +export * from './config-location' | |
| 3 | +export * from './context' | |
| 4 | +export * from './deployment' | |
| 1 | 5 | export * from './entries' |
| 2 | 6 | export * from './environment' |
| 7 | +export * from './facts' | |
| 8 | +export * from './gate' | |
| 3 | 9 | export * from './is-analytics-off' |
| 10 | +export * from './policy-pins' | |
| 4 | 11 | export * from './register.js' |
| 12 | +export * from './sender' | |
| 5 | 13 | export * from './telemetry-deps' |
| 6 | 14 | export * from './telemetry-of' |
| 7 | 15 |
| @@ -1,34 +1,44 @@ | ||
| 1 | 1 | import type { Environment } from '../environment' |
| 2 | +import type { PolicyPins } from '../policy-pins' | |
| 2 | 3 | import { isEnvSet } from './is-env-set' |
| 3 | 4 | import { isEnvTruthy } from './is-env-truthy' |
| 4 | 5 | |
| 5 | 6 | /** |
| 6 | 7 | * 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. | |
| 8 | 9 | * |
| 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. | |
| 12 | 13 | * |
| 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 | |
| 14 | 16 | * @returns true when no row may be sent |
| 15 | 17 | */ |
| 16 | -export function isAnalyticsOff(environment: Environment) { | |
| 18 | +export function isAnalyticsOff(environment: Environment, policy: PolicyPins) { | |
| 17 | 19 | const isPrivate = |
| 20 | + environment.nodeEnv === 'test' || | |
| 18 | 21 | isEnvSet(environment.disableTelemetry) || |
| 19 | 22 | isEnvSet(environment.disableNonessentialTraffic) || |
| 20 | 23 | isEnvTruthy(environment.doNotTrack) |
| 21 | 24 | |
| 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) | |
| 30 | 35 | |
| 36 | + const isGateway = | |
| 37 | + isEnvTruthy(environment.useGateway) || | |
| 38 | + policy.forceLoginMethod === 'gateway' || | |
| 39 | + policy.forceLoginGatewayUrl !== undefined | |
| 40 | + | |
| 31 | 41 | const isCustomDeployment = isEnvSet(environment.customOauthUrl?.trim()) |
| 32 | 42 | |
| 33 | - return isPrivate || isThirdParty || isCustomDeployment | |
| 43 | + return isPrivate || isThirdParty || isGateway || isCustomDeployment | |
| 34 | 44 | } |
| @@ -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 @@ | ||
| 1 | 1 | import type { EngineInterface, On } from 'claude-code' |
| 2 | 2 | |
| 3 | +import Context from './context' | |
| 4 | +import Gate from './gate' | |
| 5 | +import IsAnalyticsOff from './is-analytics-off' | |
| 6 | +import type { Sender } from './sender' | |
| 3 | 7 | import { telemetryOf } from './telemetry-of' |
| 4 | 8 | |
| 5 | 9 | /** |
| 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. | |
| 8 | 12 | * |
| 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. | |
| 11 | 16 | * |
| 12 | 17 | * @param on the engine's registrar |
| 13 | 18 | */ |
| 14 | 19 | 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) => { | |
| 16 | 40 | const beneath = await next(e) |
| 17 | 41 | |
| 18 | - const telemetry: EngineInterface['telemetry'] = telemetryOf({ | |
| 42 | + sender = telemetryOf({ | |
| 19 | 43 | authorize: () => beneath.session.authorize(), |
| 20 | 44 | id: () => beneath.session.id(), |
| 21 | 45 | model: () => beneath.session.model(), |
| 46 | + isInteractive: async () => | |
| 47 | + isInteractive ?? | |
| 48 | + (await beneath.session.surfaces()).includes('terminal'), | |
| 22 | 49 | environment: async () => ({ |
| 23 | 50 | userType: await beneath.env.get('USER_TYPE'), |
| 51 | + nodeEnv: await beneath.env.get('NODE_ENV'), | |
| 24 | 52 | disableTelemetry: await beneath.env.get('DISABLE_TELEMETRY'), |
| 25 | 53 | disableNonessentialTraffic: await beneath.env.get( |
| 26 | 54 | 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', |
| 27 | 55 | ), |
| 28 | 56 | doNotTrack: await beneath.env.get('DO_NOT_TRACK'), |
| 29 | 57 | 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 | + ), | |
| 30 | 62 | useBedrock: await beneath.env.get('CLAUDE_CODE_USE_BEDROCK'), |
| 31 | 63 | useVertex: await beneath.env.get('CLAUDE_CODE_USE_VERTEX'), |
| 32 | 64 | useFoundry: await beneath.env.get('CLAUDE_CODE_USE_FOUNDRY'), |
| @@ -36,9 +68,286 @@ | ||
| 36 | 68 | ), |
| 37 | 69 | useMantle: await beneath.env.get('CLAUDE_CODE_USE_MANTLE'), |
| 38 | 70 | }), |
| 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 }), | |
| 39 | 343 | 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' }), | |
| 40 | 347 | }) |
| 41 | 348 | |
| 349 | + const telemetry: EngineInterface['telemetry'] = sender.telemetry | |
| 350 | + | |
| 42 | 351 | return { ...beneath, telemetry } |
| 43 | 352 | }) |
| 44 | 353 | } |
| @@ -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' | |
| 2 | 10 | |
| 11 | +import type { ConfigLocation } from '../config-location' | |
| 12 | +import type Deployment from '../deployment' | |
| 3 | 13 | import type { Environment } from '../environment' |
| 14 | +import type { Facts } from '../facts' | |
| 15 | +import type { PolicyPins } from '../policy-pins' | |
| 4 | 16 | |
| 5 | 17 | /** |
| 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. | |
| 8 | 23 | */ |
| 9 | 24 | export type TelemetryDeps = { |
| 10 | 25 | /** |
| @@ -13,23 +28,94 @@ | ||
| 13 | 28 | authorize: () => Promise<SessionAuthorization> |
| 14 | 29 | |
| 15 | 30 | /** |
| 16 | - * The session's id, for the row. | |
| 31 | + * The session's id, for the batch. | |
| 17 | 32 | */ |
| 18 | 33 | id: () => Promise<string> |
| 19 | 34 | |
| 20 | 35 | /** |
| 21 | - * The session's model, for the row. | |
| 36 | + * The session's model, for the batch. | |
| 22 | 37 | */ |
| 23 | 38 | model: () => Promise<string> |
| 24 | 39 | |
| 25 | 40 | /** |
| 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. | |
| 28 | 43 | */ |
| 44 | + isInteractive: () => Promise<boolean> | |
| 45 | + | |
| 46 | + /** | |
| 47 | + * Reads, before each batch, the build's user type and every analytics-off | |
| 48 | + * variable. | |
| 49 | + */ | |
| 29 | 50 | environment: () => Promise<Environment> |
| 30 | 51 | |
| 31 | 52 | /** |
| 32 | - * Posts the batch to the ingest with the credential handle. | |
| 53 | + * Reads the managed policy's gateway pins. | |
| 33 | 54 | */ |
| 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 | + */ | |
| 34 | 105 | 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 | |
| 35 | 121 | } |
| @@ -1,74 +1,191 @@ | ||
| 1 | +import type { HttpResponse, Timer } from 'claude-code' | |
| 2 | + | |
| 1 | 3 | import type { Telemetry } from '../../types' |
| 4 | +import Batching from '../batching' | |
| 5 | +import Context from '../context' | |
| 2 | 6 | import Entries from '../entries' |
| 7 | +import type { Environment } from '../environment' | |
| 3 | 8 | import { isAnalyticsOff } from '../is-analytics-off' |
| 9 | +import type { Sender } from '../sender' | |
| 4 | 10 | import type { TelemetryDeps } from '../telemetry-deps' |
| 5 | 11 | |
| 6 | 12 | /** |
| 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. | |
| 9 | 15 | * |
| 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. | |
| 13 | 19 | * |
| 14 | 20 | * @param deps the calls on the nouns beneath |
| 15 | - * @returns the `$.telemetry` interface, `log` and `mark` | |
| 21 | + * @returns the noun and its flush | |
| 16 | 22 | */ |
| 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() | |
| 19 | 29 | |
| 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 | + }) | |
| 25 | 40 | |
| 26 | - if (isAnalyticsOff(environment)) { | |
| 27 | - return | |
| 28 | - } | |
| 41 | + context = gathering | |
| 29 | 42 | |
| 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 | + } | |
| 35 | 45 | |
| 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 | + ]) | |
| 37 | 52 | |
| 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)})`, | |
| 42 | 58 | ) |
| 59 | + | |
| 60 | + return undefined | |
| 43 | 61 | } |
| 62 | + } | |
| 44 | 63 | |
| 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, { | |
| 46 | 74 | method: 'POST', |
| 47 | 75 | headers: { |
| 48 | 76 | 'Content-Type': 'application/json', |
| 49 | 77 | 'x-service-name': 'claude-code', |
| 50 | 78 | }, |
| 51 | - auth: auth.handle, | |
| 79 | + auth, | |
| 52 | 80 | body, |
| 53 | 81 | }) |
| 54 | 82 | |
| 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 | + | |
| 55 | 136 | if (!response.ok) { |
| 56 | - throw Entries.refusal(`the ingest answered ${response.status}`, method) | |
| 137 | + throw new Error(`the ingest answered ${response.status}`) | |
| 57 | 138 | } |
| 139 | + | |
| 140 | + deps.debug(`telemetry: sent ${rows.length} row(s)`) | |
| 58 | 141 | } |
| 59 | 142 | |
| 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 | |
| 66 | 146 | |
| 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 | |
| 68 | 165 | } |
| 69 | 166 | |
| 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()) | |
| 73 | 183 | } |
| 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 } | |
| 74 | 191 | } |
| @@ -1,18 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * One environment per switch that turns Claude Code's analytics off: the | |
| 3 | - * plugin sends nothing under any of them. | |
| 4 | - */ | |
| 5 | -export const ANALYTICS_OFF_ENVIRONMENTS: readonly Readonly< | |
| 6 | - Record<string, string> | |
| 7 | ->[] = [ | |
| 8 | - { DISABLE_TELEMETRY: '0' }, | |
| 9 | - { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' }, | |
| 10 | - { DO_NOT_TRACK: 'true' }, | |
| 11 | - { CLAUDE_CODE_USE_BEDROCK: '1' }, | |
| 12 | - { CLAUDE_CODE_USE_VERTEX: 'yes' }, | |
| 13 | - { CLAUDE_CODE_USE_FOUNDRY: 'on' }, | |
| 14 | - { CLAUDE_CODE_USE_ANTHROPIC_AWS: 'TRUE' }, | |
| 15 | - { CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD: '1' }, | |
| 16 | - { CLAUDE_CODE_USE_MANTLE: '1' }, | |
| 17 | - { CLAUDE_CODE_CUSTOM_OAUTH_URL: 'https://auth.example.invalid' }, | |
| 18 | -] |
| @@ -1,5 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * The base64 digits in value order. | |
| 3 | - */ | |
| 4 | -export const BASE64_ALPHABET = | |
| 5 | - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' |
| @@ -1,23 +1,0 @@ | ||
| 1 | -import { BASE64_ALPHABET } from './base64-alphabet.js' | |
| 2 | -import { BASE64_DIGIT_BITS } from './base64-digit-bits.js' | |
| 3 | - | |
| 4 | -/** | |
| 5 | - * The text a base64 string encodes, byte for byte (the rows carry ASCII | |
| 6 | - * JSON), so a test reads a batch's `additional_metadata` back. | |
| 7 | - * | |
| 8 | - * @param encoded the base64 text | |
| 9 | - * @returns the decoded text | |
| 10 | - */ | |
| 11 | -export const base64Decoded = (encoded: string): string => | |
| 12 | - ( | |
| 13 | - [...encoded.replace(/=+$/, '')] | |
| 14 | - .map(digit => | |
| 15 | - BASE64_ALPHABET.indexOf(digit) | |
| 16 | - .toString(2) | |
| 17 | - .padStart(BASE64_DIGIT_BITS, '0'), | |
| 18 | - ) | |
| 19 | - .join('') | |
| 20 | - .match(/[01]{8}/g) ?? [] | |
| 21 | - ) | |
| 22 | - .map(byte => String.fromCharCode(parseInt(byte, 2))) | |
| 23 | - .join('') |
| @@ -1,4 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * How many bits one base64 digit carries. | |
| 3 | - */ | |
| 4 | -export const BASE64_DIGIT_BITS = 6 |
| @@ -1,12 +1,0 @@ | ||
| 1 | -import type { Args } from 'claude-code' | |
| 2 | - | |
| 3 | -import type { IngestBatch } from './ingest-batch.js' | |
| 4 | - | |
| 5 | -/** | |
| 6 | - * The batch a post to the ingest carries, as it was sent. | |
| 7 | - * | |
| 8 | - * @param post the `http.fetch` the plugin made | |
| 9 | - * @returns the body, parsed | |
| 10 | - */ | |
| 11 | -export const batchOf = (post: Args<'http.fetch'>): IngestBatch => | |
| 12 | - JSON.parse(String(post.init?.body)) |
| @@ -1,0 +1,4 @@ | ||
| 1 | +/** | |
| 2 | + * Where the CLI's global config is under SENDING_ENV's HOME. | |
| 3 | + */ | |
| 4 | +export const CONFIG_PATH = '/Users/person/.claude.json' |
| @@ -1,0 +1,10 @@ | ||
| 1 | +import type { Telemetry } from '../../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A `telemetry` that takes every entry and does nothing with it: what the | |
| 5 | + * replacing plugin tries to hand up in place of the real one. | |
| 6 | + */ | |
| 7 | +export const DEAF_TELEMETRY: Telemetry = { | |
| 8 | + log: async () => undefined, | |
| 9 | + mark: async () => undefined, | |
| 10 | +} |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * What the global config records as this install's device id. | |
| 3 | + */ | |
| 4 | +export const DEVICE_ID = | |
| 5 | + 'ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01' |
| @@ -1,0 +1,10 @@ | ||
| 1 | +import type { SessionEndInput } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The session as it ends: the person left. | |
| 5 | + */ | |
| 6 | +export const ENDED: SessionEndInput = { | |
| 7 | + reason: 'prompt_input_exit', | |
| 8 | + sessionId: 'the-session', | |
| 9 | + resume: { id: 'the-session' }, | |
| 10 | +} |
| @@ -1,0 +1,20 @@ | ||
| 1 | +/** | |
| 2 | + * One environment per switch that turns Claude Code's analytics off: the | |
| 3 | + * plugin sends nothing under any of them. | |
| 4 | + */ | |
| 5 | +export const ANALYTICS_OFF_ENVIRONMENTS: readonly Readonly< | |
| 6 | + Record<string, string> | |
| 7 | +>[] = [ | |
| 8 | + { DISABLE_TELEMETRY: '0' }, | |
| 9 | + { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' }, | |
| 10 | + { DO_NOT_TRACK: 'true' }, | |
| 11 | + { NODE_ENV: 'test' }, | |
| 12 | + { CLAUDE_CODE_USE_GATEWAY: '1' }, | |
| 13 | + { CLAUDE_CODE_USE_BEDROCK: '1' }, | |
| 14 | + { CLAUDE_CODE_USE_VERTEX: 'yes' }, | |
| 15 | + { CLAUDE_CODE_USE_FOUNDRY: 'on' }, | |
| 16 | + { CLAUDE_CODE_USE_ANTHROPIC_AWS: 'TRUE' }, | |
| 17 | + { CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD: '1' }, | |
| 18 | + { CLAUDE_CODE_USE_MANTLE: '1' }, | |
| 19 | + { CLAUDE_CODE_CUSTOM_OAUTH_URL: 'https://auth.example.invalid' }, | |
| 20 | +] |
| @@ -1,0 +1,20 @@ | ||
| 1 | +import { SENDING_ENV } from './sending-env.js' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * SENDING_ENV inside a GitHub Actions workflow running the Claude Code | |
| 5 | + * action from a pull request. | |
| 6 | + */ | |
| 7 | +export const GITHUB_ACTION_ENV: Readonly<Record<string, string>> = { | |
| 8 | + ...SENDING_ENV, | |
| 9 | + CI: 'true', | |
| 10 | + GITHUB_ACTIONS: 'true', | |
| 11 | + GITHUB_EVENT_NAME: 'pull_request', | |
| 12 | + RUNNER_OS: 'Linux', | |
| 13 | + RUNNER_ENVIRONMENT: 'github-hosted', | |
| 14 | + GITHUB_ACTION_PATH: '/runner/_actions/anthropics/claude-code-action/v1', | |
| 15 | + GITHUB_ACTOR_ID: '11', | |
| 16 | + GITHUB_REPOSITORY_ID: '22', | |
| 17 | + GITHUB_REPOSITORY_OWNER_ID: '33', | |
| 18 | + CLAUDE_CODE_ENTRYPOINT: 'claude-code-github-action', | |
| 19 | + CLAUDE_CODE_ACTION: '1', | |
| 20 | +} |
| @@ -1,0 +1,11 @@ | ||
| 1 | +import { SENDING_ENV } from './sending-env.js' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * SENDING_ENV on Bedrock under a host that manages the provider: the CLI's | |
| 5 | + * analytics stay on there unless DISABLE_TELEMETRY says otherwise. | |
| 6 | + */ | |
| 7 | +export const HOST_MANAGED_BEDROCK_ENV: Readonly<Record<string, string>> = { | |
| 8 | + ...SENDING_ENV, | |
| 9 | + CLAUDE_CODE_USE_BEDROCK: '1', | |
| 10 | + CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1', | |
| 11 | +} |
| @@ -1,0 +1,9 @@ | ||
| 1 | +export * from './analytics-off-environments.js' | |
| 2 | +export * from './github-action-env.js' | |
| 3 | +export * from './host-managed-bedrock-env.js' | |
| 4 | +export * from './linux-files.js' | |
| 5 | +export * from './linux-probed.js' | |
| 6 | +export * from './sending-env.js' | |
| 7 | +export * from './windows-env.js' | |
| 8 | + | |
| 9 | +export * as default from '.' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +/** | |
| 2 | + * The files an Ubuntu machine under WSL2 on EC2 holds where the plugin | |
| 3 | + * reads the distribution, the kernel and the hypervisor. | |
| 4 | + */ | |
| 5 | +export const LINUX_FILES: Readonly<Record<string, string>> = { | |
| 6 | + '/etc/os-release': 'NAME="Ubuntu"\nID=ubuntu\nVERSION_ID="24.04"\n', | |
| 7 | + '/proc/version': 'Linux version 6.6.87.2-microsoft-standard-WSL2', | |
| 8 | + '/sys/hypervisor/uuid': 'ec2e1916-9099-7caf-fd21-012345abcdef', | |
| 9 | +} |
| @@ -1,0 +1,6 @@ | ||
| 1 | +/** | |
| 2 | + * What the one machine probe prints on that Linux machine: x86_64, npm and | |
| 3 | + * node on the PATH. | |
| 4 | + */ | |
| 5 | +export const LINUX_PROBED = | |
| 6 | + 'Linux\n6.6.87.2-microsoft-standard-WSL2\nx86_64\nnpm,\nnode,\n' |
| @@ -1,0 +1,11 @@ | ||
| 1 | +/** | |
| 2 | + * The environment of an internal build's session on a Mac, in Ghostty under | |
| 3 | + * zsh, signed in, its config under HOME: every switch clear, so rows go. | |
| 4 | + */ | |
| 5 | +export const SENDING_ENV: Readonly<Record<string, string>> = { | |
| 6 | + USER_TYPE: 'ant', | |
| 7 | + CLAUDE_CODE_ENTRYPOINT: 'cli', | |
| 8 | + TERM_PROGRAM: 'ghostty', | |
| 9 | + SHELL: '/bin/zsh', | |
| 10 | + HOME: '/Users/person', | |
| 11 | +} |
| @@ -1,0 +1,12 @@ | ||
| 1 | +/** | |
| 2 | + * The environment of an external build's session on Windows, in Windows | |
| 3 | + * Terminal under cmd, its config under USERPROFILE. | |
| 4 | + */ | |
| 5 | +export const WINDOWS_ENV: Readonly<Record<string, string>> = { | |
| 6 | + USER_TYPE: 'external', | |
| 7 | + OS: 'Windows_NT', | |
| 8 | + PROCESSOR_ARCHITECTURE: 'AMD64', | |
| 9 | + COMSPEC: 'C:\\Windows\\System32\\cmd.exe', | |
| 10 | + USERPROFILE: 'C:\\Users\\person', | |
| 11 | + WT_SESSION: '1', | |
| 12 | +} |
| @@ -1,0 +1,42 @@ | ||
| 1 | +import { DEVICE_ID } from './device-id.js' | |
| 2 | +import { REMOTE_HASH } from './remote-hash.js' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The survey answer's row as SENDING_ENV's session sends it, complete: the | |
| 6 | + * shape rowOf reads back, every column the plugin can fill filled. | |
| 7 | + */ | |
| 8 | +export const EXPECTED_ROW = { | |
| 9 | + event_type: 'ClaudeCodeInternalEvent', | |
| 10 | + hasEventId: true, | |
| 11 | + hasTimestamp: true, | |
| 12 | + event_name: 'tengu_plugin_survey_answered', | |
| 13 | + device_id: DEVICE_ID, | |
| 14 | + email: 'person@example.invalid', | |
| 15 | + auth: { account_uuid: 'acc-1111-2222', organization_uuid: 'org-3333-4444' }, | |
| 16 | + session_id: 'the-session', | |
| 17 | + model: 'the-model', | |
| 18 | + user_type: 'ant', | |
| 19 | + is_interactive: true, | |
| 20 | + client_type: 'cli', | |
| 21 | + entrypoint: 'cli', | |
| 22 | + env: { | |
| 23 | + platform: 'darwin', | |
| 24 | + platform_raw: 'darwin', | |
| 25 | + arch: 'arm64', | |
| 26 | + terminal: 'ghostty', | |
| 27 | + shell: 'zsh', | |
| 28 | + package_managers: 'npm,pnpm', | |
| 29 | + runtimes: 'bun,node', | |
| 30 | + is_ci: false, | |
| 31 | + is_claubbit: false, | |
| 32 | + is_github_action: false, | |
| 33 | + is_claude_code_action: false, | |
| 34 | + is_claude_code_remote: false, | |
| 35 | + is_local_agent_mode: false, | |
| 36 | + is_conductor: false, | |
| 37 | + deployment_environment: 'unknown-darwin', | |
| 38 | + vcs: 'git', | |
| 39 | + is_claude_ai_auth: true, | |
| 40 | + }, | |
| 41 | + metadata: { rh: REMOTE_HASH, answer: 2, page: 'ready', seen: true }, | |
| 42 | +} |
| @@ -1,33 +1,112 @@ | ||
| 1 | -import type { Args, HttpResponse, On, SessionAuthorization } from 'claude-code' | |
| 1 | +import type { Args, On } from 'claude-code' | |
| 2 | +import { mock } from 'claude-code/testing' | |
| 2 | 3 | |
| 3 | 4 | import { ACCEPTED } from './accepted.js' |
| 4 | 5 | import { BEARER } from './bearer.js' |
| 6 | +import { CONFIG_PATH } from './config-path.js' | |
| 7 | +import { GLOBAL_CONFIG } from './global-config.js' | |
| 8 | +import { LISTING } from './listing.js' | |
| 9 | +import { PROBED } from './probed.js' | |
| 10 | +import { REPO } from './repo.js' | |
| 11 | +import type { SentSession } from './sent-session.js' | |
| 12 | +import type { SessionOptions } from './session-options.js' | |
| 5 | 13 | |
| 6 | 14 | /** |
| 7 | - * Answers what the telemetry plugin reads of a session signed in first | |
| 8 | - * party, and keeps each post the ingest accepts. | |
| 15 | + * Answers, beneath the plugins, everything the telemetry plugin reads of a | |
| 16 | + * session signed in first party on a Mac in a git checkout, on a mock clock. | |
| 9 | 17 | * |
| 18 | + * Keeps what the plugin did: each post, file read, program run and debug | |
| 19 | + * line, as it does it. | |
| 20 | + * | |
| 10 | 21 | * @param on the test's `on` |
| 11 | - * @param authorization the credential the session holds | |
| 12 | - * @param answer what the ingest answers each post; accepted by default | |
| 13 | - * @returns each post, as it was made | |
| 22 | + * @param options how this session differs from the plain one | |
| 23 | + * @returns what the plugin did, and the clock | |
| 14 | 24 | */ |
| 15 | 25 | export function firstPartySession( |
| 16 | 26 | on: On, |
| 17 | - authorization: SessionAuthorization = BEARER, | |
| 18 | - answer: HttpResponse = ACCEPTED, | |
| 19 | -) { | |
| 27 | + options: SessionOptions = {}, | |
| 28 | +): SentSession { | |
| 20 | 29 | const posts: Args<'http.fetch'>[] = [] |
| 30 | + const reads: string[] = [] | |
| 31 | + const runs: Args<'process.run'>[] = [] | |
| 32 | + const lines: string[] = [] | |
| 33 | + const clock = mock.clock(on) | |
| 21 | 34 | |
| 35 | + const files: Readonly<Record<string, string>> = { | |
| 36 | + [CONFIG_PATH]: GLOBAL_CONFIG, | |
| 37 | + ...options.files, | |
| 38 | + } | |
| 39 | + | |
| 40 | + const answers = options.answers ?? [ACCEPTED] | |
| 41 | + const authorizations = options.authorizations ?? [BEARER] | |
| 42 | + | |
| 43 | + let authorizeCalls = 0 | |
| 44 | + | |
| 45 | + const isPolicyUnreadable = options.policy?.kind === 'unreadable' | |
| 46 | + | |
| 47 | + on('session.start', ($, e) => ({ cwd: e.cwd })) | |
| 48 | + on('session.end', ($, e) => ({ sessionId: e.sessionId })) | |
| 22 | 49 | on('session.id', () => ({ value: 'the-session' })) |
| 23 | 50 | on('session.model', () => ({ value: 'the-model' })) |
| 24 | - on('session.authorize', () => ({ value: authorization })) | |
| 51 | + on('session.surfaces', () => ({ value: ['terminal'] })) | |
| 52 | + on('session.cwd', () => ({ value: '/work' })) | |
| 53 | + on('session.repo', () => ({ value: REPO })) | |
| 54 | + on('fs.list', () => ({ value: LISTING })) | |
| 25 | 55 | |
| 56 | + on('session.authorize', () => { | |
| 57 | + authorizeCalls += 1 | |
| 58 | + | |
| 59 | + const answer = | |
| 60 | + authorizations[Math.min(authorizeCalls, authorizations.length) - 1] | |
| 61 | + | |
| 62 | + const isRefused = answer === 'refused' | |
| 63 | + | |
| 64 | + return isRefused | |
| 65 | + ? { deny: 'the credential store is locked' } | |
| 66 | + : { value: answer === undefined ? BEARER : answer } | |
| 67 | + }) | |
| 68 | + | |
| 69 | + on('settings.read', () => | |
| 70 | + isPolicyUnreadable | |
| 71 | + ? { deny: 'the managed settings file is unreadable' } | |
| 72 | + : { value: options.policy?.settings ?? {} }, | |
| 73 | + ) | |
| 74 | + | |
| 75 | + on('fs.read', ($, e) => { | |
| 76 | + reads.push(e.path) | |
| 77 | + | |
| 78 | + const text = files[e.path] | |
| 79 | + | |
| 80 | + return text === undefined ? { deny: `ENOENT: ${e.path}` } : { value: text } | |
| 81 | + }) | |
| 82 | + | |
| 83 | + on('fs.exists', ($, e) => ({ | |
| 84 | + value: (options.existing ?? []).includes(e.path), | |
| 85 | + })) | |
| 86 | + | |
| 87 | + const probed = { ...PROBED, stdout: options.probeOutput ?? PROBED.stdout } | |
| 88 | + | |
| 89 | + on('process.run', ($, e) => { | |
| 90 | + runs.push(e) | |
| 91 | + | |
| 92 | + return { value: probed } | |
| 93 | + }) | |
| 94 | + | |
| 26 | 95 | on('http.fetch', ($, e) => { |
| 27 | 96 | posts.push(e) |
| 28 | 97 | |
| 29 | - return { value: answer } | |
| 98 | + return { | |
| 99 | + value: answers[Math.min(posts.length, answers.length) - 1] ?? ACCEPTED, | |
| 100 | + } | |
| 30 | 101 | }) |
| 31 | 102 | |
| 32 | - return posts | |
| 103 | + on('ui.log', ($, e) => { | |
| 104 | + if (e.to === 'debug') { | |
| 105 | + lines.push(e.text) | |
| 106 | + } | |
| 107 | + | |
| 108 | + return { value: undefined } | |
| 109 | + }) | |
| 110 | + | |
| 111 | + return { posts, reads, runs, lines, clock } | |
| 33 | 112 | } |
| @@ -1,0 +1,14 @@ | ||
| 1 | +/** | |
| 2 | + * A continuation as the gate's catch is handed it after a failure: going | |
| 3 | + * on throws what beneath refused with. | |
| 4 | + * | |
| 5 | + * @param isCalled whether the failed hook had called next before it failed | |
| 6 | + * @returns the continuation | |
| 7 | + */ | |
| 8 | +export const failingNext = (isCalled: boolean) => | |
| 9 | + Object.assign( | |
| 10 | + () => { | |
| 11 | + throw new Error('beneath refused the entry') | |
| 12 | + }, | |
| 13 | + { called: isCalled, error: { kind: 'throw' as const, budget: 0 } }, | |
| 14 | + ) |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './failing-next.js' | |
| 2 | +export * from './next-of.js' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,13 @@ | ||
| 1 | +import type { Tier } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A continuation as the gate is handed it: answers `served` when gone on | |
| 5 | + * to, stamped with a caller in the tier given, or with no origin at all. | |
| 6 | + * | |
| 7 | + * @param tier the caller's tier; undefined for a call that names no origin | |
| 8 | + * @returns the continuation | |
| 9 | + */ | |
| 10 | +export const nextOf = (tier: Tier | undefined) => | |
| 11 | + Object.assign(() => 'served', { | |
| 12 | + origin: tier === undefined ? undefined : { plugin: 'caller', tier }, | |
| 13 | + }) |
| @@ -1,0 +1,16 @@ | ||
| 1 | +import { DEVICE_ID } from './device-id.js' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The CLI's global config as the session's machine holds it: the install's | |
| 5 | + * device id and the signed-in account, among keys the plugin never reads. | |
| 6 | + */ | |
| 7 | +export const GLOBAL_CONFIG = JSON.stringify({ | |
| 8 | + autoUpdates: true, | |
| 9 | + userID: DEVICE_ID, | |
| 10 | + oauthAccount: { | |
| 11 | + accountUuid: 'acc-1111-2222', | |
| 12 | + organizationUuid: 'org-3333-4444', | |
| 13 | + emailAddress: 'person@example.invalid', | |
| 14 | + }, | |
| 15 | + projects: { '/work': {} }, | |
| 16 | +}) |
| @@ -1,0 +1,6 @@ | ||
| 1 | +import type { Telemetry } from '../../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What the holding plugin keeps of the table it was handed: the noun. | |
| 5 | + */ | |
| 6 | +export type Held = { telemetry: Telemetry } |
| @@ -1,20 +1,32 @@ | ||
| 1 | 1 | export * from './accepted.js' |
| 2 | -export * from './analytics-off-environments.js' | |
| 3 | -export * from './base64-alphabet.js' | |
| 4 | -export * from './base64-decoded.js' | |
| 5 | -export * from './base64-digit-bits.js' | |
| 6 | -export * from './batch-of.js' | |
| 7 | 2 | export * from './bearer.js' |
| 3 | +export * from './config-path.js' | |
| 4 | +export * from './deaf-telemetry.js' | |
| 5 | +export * from './device-id.js' | |
| 6 | +export * from './ended.js' | |
| 7 | +export * from './environments' | |
| 8 | +export * from './expected-row.js' | |
| 8 | 9 | export * from './first-party-session.js' |
| 9 | 10 | export * from './fullscreen.js' |
| 10 | -export type * from './ingest-batch.js' | |
| 11 | -export type * from './ingest-event.js' | |
| 11 | +export * from './gate' | |
| 12 | +export * from './global-config.js' | |
| 13 | +export type * from './held.js' | |
| 14 | +export * from './listing.js' | |
| 12 | 15 | export * from './mark.js' |
| 13 | -export * from './marking.js' | |
| 16 | +export * from './plugins' | |
| 17 | +export type * from './policy-option.js' | |
| 18 | +export * from './probed.js' | |
| 19 | +export * from './read-tool.js' | |
| 14 | 20 | export * from './record.js' |
| 15 | -export * from './recording.js' | |
| 16 | 21 | export * from './refused.js' |
| 17 | -export * from './row-of.js' | |
| 22 | +export * from './rejected.js' | |
| 23 | +export * from './remote-hash.js' | |
| 24 | +export * from './repo.js' | |
| 25 | +export type * from './sent-session.js' | |
| 26 | +export type * from './session-options.js' | |
| 27 | +export * from './started.js' | |
| 18 | 28 | export * from './survey-answer.js' |
| 29 | +export * from './typed.js' | |
| 30 | +export * from './wire' | |
| 19 | 31 | |
| 20 | 32 | export * as default from '.' |
| @@ -1,8 +1,0 @@ | ||
| 1 | -import type { IngestEvent } from './ingest-event.js' | |
| 2 | - | |
| 3 | -/** | |
| 4 | - * The body of one post to the ingest, parsed: its events. | |
| 5 | - */ | |
| 6 | -export type IngestBatch = { | |
| 7 | - events: readonly IngestEvent[] | |
| 8 | -} |
| @@ -1,7 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * One event of a batch posted to the ingest: its type and its data fields. | |
| 3 | - */ | |
| 4 | -export type IngestEvent = { | |
| 5 | - event_type: string | |
| 6 | - event_data: Record<string, unknown> | |
| 7 | -} |
| @@ -1,0 +1,8 @@ | ||
| 1 | +import type { FsEntry } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What the session's working directory lists: a git checkout. | |
| 5 | + */ | |
| 6 | +export const LISTING: FsEntry[] = [ | |
| 7 | + { name: '.git', kind: 'dir', size: 0, isLink: false }, | |
| 8 | +] |
| @@ -1,17 +1,0 @@ | ||
| 1 | -import type { Plugin } from 'claude-code/testing' | |
| 2 | - | |
| 3 | -/** | |
| 4 | - * A plugin whose `/mark <entry>` marks the entry through `$.telemetry`, | |
| 5 | - * answering "sent", or why the mark was refused. | |
| 6 | - */ | |
| 7 | -export const marking: Plugin = { | |
| 8 | - name: 'marking', | |
| 9 | - register(on) { | |
| 10 | - on('command.run', { command: 'mark' }, ($, e) => | |
| 11 | - $.telemetry.mark(JSON.parse(e.args)).then( | |
| 12 | - () => ({ text: 'sent' }), | |
| 13 | - (error: unknown) => ({ text: String(error) }), | |
| 14 | - ), | |
| 15 | - ) | |
| 16 | - }, | |
| 17 | -} |
| @@ -1,0 +1,29 @@ | ||
| 1 | +import type { Plugin } from 'claude-code/testing' | |
| 2 | + | |
| 3 | +import type { Held } from '../held.js' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * A plugin a person installed that keeps the table its engine.create step | |
| 7 | + * is handed, to call `telemetry.log` off it later; the scan refuses it. | |
| 8 | + */ | |
| 9 | +export const holding: Plugin = { | |
| 10 | + name: 'holding', | |
| 11 | + tier: 'user', | |
| 12 | + register(on) { | |
| 13 | + let held: Held | null = null | |
| 14 | + | |
| 15 | + on('engine.create', async (_$, e, next) => { | |
| 16 | + const beneath = await next(e) | |
| 17 | + | |
| 18 | + held = beneath | |
| 19 | + | |
| 20 | + return beneath | |
| 21 | + }) | |
| 22 | + | |
| 23 | + on('command.run', { command: 'hold' }, async () => { | |
| 24 | + await held?.telemetry.log({ event: 'held' }) | |
| 25 | + | |
| 26 | + return { text: 'served' } | |
| 27 | + }) | |
| 28 | + }, | |
| 29 | +} |
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.
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.