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
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 |
| @@ -13,12 +13,19 @@ | ||
| 13 | 13 | through Buttons that declare the engine's own actions. The pane refreshes |
| 14 | 14 | as Claude edits and runs shell commands, and while it is open it polls |
| 15 | 15 | the repository's HEAD so a commit or checkout made elsewhere shows too. |
| 16 | -The first successful edit of a session opens the pane by itself where the | |
| 16 | +The main loop's first successful edit of a session opens the pane by | |
| 17 | +itself, as the built-in panel opens on its first checkpoint: where the | |
| 17 | 18 | layout docks it beside the transcript (the fullscreen layout, which each |
| 18 | -drawing's `viewport` says) and the terminal is wide enough (144 columns | |
| 19 | -when the person never chose, 110 when they kept it open before; a person | |
| 20 | -who closed it is left alone); where the surface does not say, nothing | |
| 21 | -opens by itself. | |
| 19 | +drawing's `viewport` says), the terminal is wide enough (144 columns when | |
| 20 | +the person never chose, 110 when they kept it open before; a person who | |
| 21 | +closed it is left alone) and file checkpointing is on; a subagent's edit | |
| 22 | +opens nothing, and where the surface does not say, nothing opens by | |
| 23 | +itself. A docked pane fetches before it opens, as the built-in panel | |
| 24 | +primes its data, so it never lands on `Loading diff…`; an open the engine | |
| 25 | +leaves waiting undrawn is withdrawn, so no later resize seats it, and the | |
| 26 | +next edit asks again. A session resumed or continued whose transcript | |
| 27 | +already holds such an edit opens the pane on the same terms as soon as the | |
| 28 | +width is known, as the built-in opens on the history it restores. | |
| 22 | 29 | |
| 23 | 30 | Under the fullscreen layout a terminal under 110 columns gets the |
| 24 | 31 | built-in's line asking for a wider one and nothing opens. Without that |
| @@ -34,7 +41,9 @@ | ||
| 34 | 41 | file: its hunks ride the next prompt as context, once. |
| 35 | 42 | |
| 36 | 43 | The pane compares the working tree against HEAD, split at the session's |
| 37 | -start (the default), against HEAD plainly, or against the merge-base with | |
| 44 | +start (the default; the start the engine gives in `$.session.usage()`, so a | |
| 45 | +resumed session keeps its first and `/clear` begins anew), against HEAD | |
| 46 | +plainly, or against the merge-base with | |
| 38 | 47 | the default branch; the base line under the header names a base other than |
| 39 | 48 | the session's, and the choice is kept per repository in the plugin's store. |
| 40 | 49 | A picker shows one earlier turn's edits instead of the working tree, read |
| @@ -60,7 +69,7 @@ | ||
| 60 | 69 | |
| 61 | 70 | | event | what the hook does | |
| 62 | 71 | | --- | --- | |
| 63 | -| `session.start` | Binds the engine once and registers `/diff` (a session where another `/diff` is listed leaves the plugin idle); asks nothing of the repository, which `/diff` or the first edit pins when it comes. | | |
| 72 | +| `session.start` | Binds the engine once and registers `/diff` (a session where another `/diff` is listed leaves the plugin idle); asks nothing of the repository, which `/diff` or the first edit pins when it comes; off its dispatch, reads the transcript, and for a resumed session whose turns edited opens the pane as the first edit would. | | |
| 64 | 73 | | `ui.render` of `PromptHint` | Reads the terminal's width and whether its layout docks a pane, which decide whether the first edit opens the pane. | |
| 65 | 74 | | `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. | |
| 66 | 75 | | `command.run` of `diff` | Pins the repository when none is, opens or closes the pane (focused and closing on Escape without the fullscreen layout), says which, and remembers the choice. | |
| @@ -68,16 +77,17 @@ | ||
| 68 | 77 | | `ui.scroll` of the pane | Docked, moves the hunks under the pinned header and list (three rows a wheel tick, a page a page key), or the list when the wheel is over it, and keeps the engine's window still. | |
| 69 | 78 | | `ui.focus` in the pane | In the dialog's list, selects the file the ring lands on, re-centres the five rows on it, and lands the ring where that row now sits. | |
| 70 | 79 | | `command.run` of `clear`, `resume` | Closes the pane and forgets the session's state, the pinned repository with it. | |
| 71 | -| `tool.call` of `Edit`, `Write`, `NotebookEdit` | After an edit that landed (not refused, not failed), refreshes an open pane; the session's first such edit opens it, pinning the repository then if the terminal has the room. | | |
| 80 | +| `tool.call` of `Edit`, `Write`, `NotebookEdit` | After an edit that landed (not refused, not failed), refreshes an open pane; the main loop's first such edit opens it, pinning the repository then if the terminal has the room and checkpointing is on. | | |
| 72 | 81 | | `tool.call` of `Bash`, `PowerShell` | After a command that was not refused, failed and interrupted ones too, refreshes an open pane. | |
| 73 | 82 | | `prompt.submit` | Adds the armed file's hunks to the prompt's context and disarms. | |
| 74 | 83 | |
| 75 | 84 | ## What it calls on `$` |
| 76 | 85 | |
| 77 | -`clock.after`, `clock.every`, `clock.now`, `command.register`, `fs.list`, | |
| 78 | -`fs.read`, `fs.stat`, `process.run` (git, read-only), `session.messages`, | |
| 79 | -`store.get`, `store.set`, `telemetry.log`, `telemetry.mark`, `ui.close`, | |
| 80 | -`ui.invalidate`, `ui.log`, `ui.open`, `ui.resolve`, `ui.status`. | |
| 86 | +`clock.after`, `clock.every`, `clock.now`, `command.register`, `env.get` | |
| 87 | +(`CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING`), `fs.list`, `fs.read`, `fs.stat`, | |
| 88 | +`process.run` (git, read-only), `session.id`, `session.messages`, | |
| 89 | +`settings.read`, `store.get`, `store.set`, `telemetry.log`, `telemetry.mark`, | |
| 90 | +`ui.close`, `ui.invalidate`, `ui.log`, `ui.open`, `ui.resolve`, `ui.status`. | |
| 81 | 91 | |
| 82 | 92 | `$.telemetry` is the telemetry plugin's noun; where it is absent the rows |
| 83 | 93 | are dropped and nothing else changes. |
| @@ -65,6 +65,12 @@ | ||
| 65 | 65 | storeSet: (key: string, value: unknown) => Promise<void> |
| 66 | 66 | |
| 67 | 67 | /** |
| 68 | + * Whether the session checkpoints edits (`$.settings.read`, `$.env.get`): | |
| 69 | + * the built-in panel opens on an edit only while it does. | |
| 70 | + */ | |
| 71 | + isCheckpointing: () => Promise<boolean> | |
| 72 | + | |
| 73 | + /** | |
| 68 | 74 | * `$.session.messages`. |
| 69 | 75 | */ |
| 70 | 76 | messages: () => Promise<SessionMessage[]> |
| @@ -105,6 +111,14 @@ | ||
| 105 | 111 | sessionId: () => Promise<string> |
| 106 | 112 | |
| 107 | 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 | + /** | |
| 108 | 122 | * `$.telemetry.mark`; rejects where the telemetry built-in is absent. |
| 109 | 123 | */ |
| 110 | 124 | mark: EngineInterface['telemetry']['mark'] |
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | export * from './entry-kinds-of' |
| 9 | 9 | export * from './git' |
| 10 | 10 | export * from './host' |
| 11 | +export * from './is-checkpointing' | |
| 11 | 12 | export * from './is-on-pane-surface' |
| 12 | 13 | export * from './is-record' |
| 13 | 14 | export * from './kept-of' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './is-checkpointing.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,20 @@ | ||
| 1 | +import type { Settings } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Whether the session checkpoints Claude's edits, read as the built-in | |
| 5 | + * reads it: the setting on unless set false, the variable unset or falsy. | |
| 6 | + * | |
| 7 | + * The built-in panel opens on an edit only through a checkpoint, so with | |
| 8 | + * checkpointing off its first-edit open never happens; `/diff` still opens. | |
| 9 | + * | |
| 10 | + * @param settings the merged settings (`$.settings.read()`) | |
| 11 | + * @param disabling `CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING` as `$.env.get` | |
| 12 | + * answers it | |
| 13 | + * @returns false when either turns checkpointing off | |
| 14 | + */ | |
| 15 | +export const isCheckpointing = ( | |
| 16 | + settings: Settings, | |
| 17 | + disabling: string | undefined, | |
| 18 | +): boolean => | |
| 19 | + settings.fileCheckpointingEnabled !== false && | |
| 20 | + !['1', 'true', 'yes', 'on'].includes((disabling ?? '').trim().toLowerCase()) |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { |
| 2 | + Args, | |
| 2 | 3 | On, |
| 3 | 4 | PaneOpenArgs, |
| 4 | 5 | ResultOf, |
| @@ -13,7 +14,9 @@ | ||
| 13 | 14 | import { entryKindsOf } from './entry-kinds-of' |
| 14 | 15 | import type Git from './git' |
| 15 | 16 | import type { Host } from './host' |
| 17 | +import { isCheckpointing } from './is-checkpointing' | |
| 16 | 18 | import { isOnPaneSurface } from './is-on-pane-surface' |
| 19 | +import { isRecord } from './is-record' | |
| 17 | 20 | import Limits from './limits' |
| 18 | 21 | import { mapLimited } from './map-limited' |
| 19 | 22 | import { messageOf } from './message-of' |
| @@ -23,6 +26,7 @@ | ||
| 23 | 26 | import PaneToggle from './pane-toggle' |
| 24 | 27 | import Record from './record' |
| 25 | 28 | import Tools from './tools' |
| 29 | +import Turns from './turns' | |
| 26 | 30 | import Views from './views' |
| 27 | 31 | |
| 28 | 32 | /** |
| @@ -30,8 +34,11 @@ | ||
| 30 | 34 | * pane's drawing and refresh, its opening on Claude's first edit, the ask. |
| 31 | 35 | * |
| 32 | 36 | * Git runs when the built-in's would: `session.start` binds the host and |
| 33 | - * registers `/diff`; `/diff` or the first edit with room pins the backend | |
| 34 | - * where the session started, until `/clear`; an open pane alone fetches. | |
| 37 | + * registers `/diff`, and off its dispatch reads the transcript, so a resumed | |
| 38 | + * session whose turns already edited opens as its first edit would; `/diff` | |
| 39 | + * or the main loop's first checkpointed edit with room pins the backend, | |
| 40 | + * until `/clear`, which reads afresh under a pane it leaves open; a docked | |
| 41 | + * pane fetches, then opens. | |
| 35 | 42 | * |
| 36 | 43 | * @param on the engine's registrar |
| 37 | 44 | */ |
| @@ -43,6 +50,7 @@ | ||
| 43 | 50 | let isPaneOpen = false |
| 44 | 51 | let dialogRows: number | null = null |
| 45 | 52 | let hasAutoOpened = false |
| 53 | + let hasRestoredEdits = false | |
| 46 | 54 | let columns: number | null = null |
| 47 | 55 | let shownSessionId: string | null = null |
| 48 | 56 | let armed: Ask.ArmedAsk | null = null |
| @@ -50,6 +58,7 @@ | ||
| 50 | 58 | let isRefreshing = false |
| 51 | 59 | let isRefreshQueued = false |
| 52 | 60 | let generation = 0 |
| 61 | + let landed = 0 | |
| 53 | 62 | let bodyStamp: string | null = null |
| 54 | 63 | let bodyBase: string | null = null |
| 55 | 64 | |
| @@ -394,7 +403,7 @@ | ||
| 394 | 403 | async function openPane( |
| 395 | 404 | engine: Host, |
| 396 | 405 | trigger: (typeof Record.SHOWN_TRIGGERS)[number], |
| 397 | - ): Promise<void> { | |
| 406 | + ): Promise<boolean> { | |
| 398 | 407 | const isDialog = model.isFullscreen === false |
| 399 | 408 | |
| 400 | 409 | model = { |
| @@ -406,12 +415,26 @@ | ||
| 406 | 415 | |
| 407 | 416 | dialogRows = isDialog ? Views.dialogRowsOf(model) : null |
| 408 | 417 | |
| 409 | - await engine.openPane( | |
| 418 | + const landedBefore = landed | |
| 419 | + | |
| 420 | + if (!isDialog) { | |
| 421 | + await refresh(engine).catch(() => undefined) | |
| 422 | + } | |
| 423 | + | |
| 424 | + const opened = await engine.openPane( | |
| 410 | 425 | isDialog |
| 411 | 426 | ? { ...dialogPane(), focus: true } |
| 412 | 427 | : { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true }, |
| 413 | 428 | ) |
| 414 | 429 | |
| 430 | + const isWaiting = isRecord(opened) && opened.isPlaced === false | |
| 431 | + | |
| 432 | + if (isWaiting) { | |
| 433 | + await engine.closePane({ id: Names.PANE_ID }).catch(() => undefined) | |
| 434 | + | |
| 435 | + return false | |
| 436 | + } | |
| 437 | + | |
| 415 | 438 | isPaneOpen = true |
| 416 | 439 | |
| 417 | 440 | const sessionId = await engine.sessionId().catch(() => null) |
| @@ -421,7 +444,13 @@ | ||
| 421 | 444 | Record.recorderOf(engine).shown(trigger, Record.widthBucketOf(columns)) |
| 422 | 445 | } |
| 423 | 446 | |
| 424 | - void refresh(engine) | |
| 447 | + const isStale = isDialog || landed !== landedBefore | |
| 448 | + | |
| 449 | + if (isStale) { | |
| 450 | + void refresh(engine) | |
| 451 | + } | |
| 452 | + | |
| 453 | + return true | |
| 425 | 454 | } |
| 426 | 455 | |
| 427 | 456 | async function closePane(engine: Host): Promise<void> { |
| @@ -460,6 +489,12 @@ | ||
| 460 | 489 | return |
| 461 | 490 | } |
| 462 | 491 | |
| 492 | + const isCheckpointed = await engine.isCheckpointing().catch(() => true) | |
| 493 | + | |
| 494 | + if (!isCheckpointed || isTaken()) { | |
| 495 | + return | |
| 496 | + } | |
| 497 | + | |
| 463 | 498 | await pinBackend(engine) |
| 464 | 499 | |
| 465 | 500 | if (!backend || isTaken()) { |
| @@ -467,9 +502,19 @@ | ||
| 467 | 502 | } |
| 468 | 503 | |
| 469 | 504 | hasAutoOpened = true |
| 470 | - await openPane(engine, 'auto_open') | |
| 505 | + hasAutoOpened = await openPane(engine, 'auto_open') | |
| 471 | 506 | } |
| 472 | 507 | |
| 508 | + async function openOnRestore(engine: Host): Promise<void> { | |
| 509 | + const messages = await engine.messages().catch((): SessionMessage[] => []) | |
| 510 | + | |
| 511 | + hasRestoredEdits = Turns.turnDiffsOf(messages).length > 0 | |
| 512 | + | |
| 513 | + if (hasRestoredEdits) { | |
| 514 | + await openOnFirstEdit(engine) | |
| 515 | + } | |
| 516 | + } | |
| 517 | + | |
| 473 | 518 | function disarm(engine: Host) { |
| 474 | 519 | armed = null |
| 475 | 520 | model = { ...model, armedPath: null } |
| @@ -574,8 +619,14 @@ | ||
| 574 | 619 | redraw(engine) |
| 575 | 620 | } |
| 576 | 621 | |
| 622 | + async function startedAtOf(engine: Host): Promise<number | null> { | |
| 623 | + const startedAt: unknown = await engine.startedAt().catch(() => undefined) | |
| 624 | + | |
| 625 | + return typeof startedAt === 'number' ? startedAt : null | |
| 626 | + } | |
| 627 | + | |
| 577 | 628 | async function bind(engine: Host, cwd: string): Promise<void> { |
| 578 | - sessionStartMs = await engine.now() | |
| 629 | + sessionStartMs = (await startedAtOf(engine)) ?? (await engine.now()) | |
| 579 | 630 | pin.cwd = cwd |
| 580 | 631 | |
| 581 | 632 | try { |
| @@ -602,6 +653,11 @@ | ||
| 602 | 653 | readFile: path => $.fs.read(path), |
| 603 | 654 | storeGet: key => $.store.get(key), |
| 604 | 655 | storeSet: (key, value) => $.store.set(key, value), |
| 656 | + isCheckpointing: async () => | |
| 657 | + isCheckpointing( | |
| 658 | + await $.settings.read(), | |
| 659 | + await $.env.get('CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING'), | |
| 660 | + ), | |
| 605 | 661 | messages: () => $.session.messages(), |
| 606 | 662 | invalidate: () => $.ui.invalidate('ui.render'), |
| 607 | 663 | status: text => $.ui.status(text), |
| @@ -610,12 +666,22 @@ | ||
| 610 | 666 | closePane: pane => $.ui.close(pane), |
| 611 | 667 | registerCommand: spec => $.command.register(spec), |
| 612 | 668 | sessionId: () => $.session.id(), |
| 669 | + startedAt: () => | |
| 670 | + $.session | |
| 671 | + .usage() | |
| 672 | + .then((usage: unknown) => | |
| 673 | + isRecord(usage) ? usage.startedAt : undefined, | |
| 674 | + ), | |
| 613 | 675 | mark: entry => $.telemetry.mark(entry), |
| 614 | 676 | log: entry => $.telemetry.log(entry), |
| 615 | 677 | }, |
| 616 | 678 | e.cwd, |
| 617 | 679 | ) |
| 618 | 680 | |
| 681 | + if (host) { | |
| 682 | + void openOnRestore(host).catch(() => undefined) | |
| 683 | + } | |
| 684 | + | |
| 619 | 685 | return next(e) |
| 620 | 686 | }) |
| 621 | 687 | |
| @@ -624,12 +690,18 @@ | ||
| 624 | 690 | const viewport: { columns?: number; isFullscreen?: boolean } | undefined = |
| 625 | 691 | e.viewport |
| 626 | 692 | |
| 693 | + const isFirstMeasure = columns === null && viewport?.columns !== undefined | |
| 694 | + | |
| 627 | 695 | columns = viewport?.columns ?? columns |
| 628 | 696 | |
| 629 | 697 | model = { |
| 630 | 698 | ...model, |
| 631 | 699 | isFullscreen: viewport?.isFullscreen ?? model.isFullscreen, |
| 632 | 700 | } |
| 701 | + | |
| 702 | + if (isFirstMeasure && hasRestoredEdits && host) { | |
| 703 | + void openOnFirstEdit(host).catch(() => undefined) | |
| 704 | + } | |
| 633 | 705 | } |
| 634 | 706 | |
| 635 | 707 | return next(e) |
| @@ -699,8 +771,15 @@ | ||
| 699 | 771 | } |
| 700 | 772 | |
| 701 | 773 | const isOpening = toggle === 'open' |
| 702 | - await (isOpening ? openPane(host, 'manual') : closePane(host)) | |
| 703 | 774 | |
| 775 | + const isDone = isOpening | |
| 776 | + ? await openPane(host, 'manual') | |
| 777 | + : await closePane(host).then(() => true) | |
| 778 | + | |
| 779 | + if (!isDone) { | |
| 780 | + return { text: Names.RESIZE_TERMINAL_TEXT } | |
| 781 | + } | |
| 782 | + | |
| 704 | 783 | if (!isFullscreen) { |
| 705 | 784 | return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT } |
| 706 | 785 | } |
| @@ -799,29 +878,48 @@ | ||
| 799 | 878 | on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => { |
| 800 | 879 | const result = await next(e) |
| 801 | 880 | |
| 802 | - if (host) { | |
| 803 | - if (isPaneOpen) { | |
| 804 | - await closePane(host).catch(() => undefined) | |
| 805 | - } | |
| 881 | + if (!host) { | |
| 882 | + return result | |
| 883 | + } | |
| 806 | 884 | |
| 807 | - unpin() | |
| 808 | - hasAutoOpened = false | |
| 809 | - bodyStamp = null | |
| 810 | - bodyBase = null | |
| 811 | - bodyLoads.clear() | |
| 812 | - disarm(host) | |
| 813 | - model = PaneState.afterNewSession(model) | |
| 885 | + const isResume = e.command === 'resume' | |
| 886 | + const isKeptOpen = isPaneOpen && !isResume | |
| 887 | + | |
| 888 | + if (isPaneOpen && isResume) { | |
| 889 | + await closePane(host).catch(() => undefined) | |
| 814 | 890 | } |
| 815 | 891 | |
| 892 | + unpin() | |
| 893 | + hasAutoOpened = false | |
| 894 | + hasRestoredEdits = false | |
| 895 | + bodyStamp = null | |
| 896 | + bodyBase = null | |
| 897 | + bodyLoads.clear() | |
| 898 | + disarm(host) | |
| 899 | + model = PaneState.afterNewSession(model) | |
| 900 | + | |
| 901 | + sessionStartMs = | |
| 902 | + (await startedAtOf(host)) ?? | |
| 903 | + (isResume ? sessionStartMs : await host.now()) | |
| 904 | + | |
| 905 | + if (isKeptOpen) { | |
| 906 | + await pinBackend(host) | |
| 907 | + void refresh(host) | |
| 908 | + } | |
| 909 | + | |
| 910 | + if (isResume) { | |
| 911 | + void openOnRestore(host).catch(() => undefined) | |
| 912 | + } | |
| 913 | + | |
| 816 | 914 | return result |
| 817 | 915 | }) |
| 818 | 916 | |
| 819 | 917 | function afterTool( |
| 820 | 918 | engine: Host, |
| 821 | - tool: string, | |
| 919 | + e: Args<'tool.call'>, | |
| 822 | 920 | result: ResultOf['tool.call'] | undefined, |
| 823 | 921 | ) { |
| 824 | - const isEdit = Tools.EDITING_TOOLS.some(name => name === tool) | |
| 922 | + const isEdit = Tools.EDITING_TOOLS.some(name => name === e.tool) | |
| 825 | 923 | |
| 826 | 924 | const hasEdited = |
| 827 | 925 | isEdit && |
| @@ -829,14 +927,19 @@ | ||
| 829 | 927 | result.deny === undefined && |
| 830 | 928 | result.isError !== true |
| 831 | 929 | |
| 832 | - const isStale = | |
| 833 | - isPaneOpen && (isEdit ? hasEdited : Tools.mayHaveWritten(result)) | |
| 930 | + const hasLanded = isEdit ? hasEdited : Tools.mayHaveWritten(result) | |
| 834 | 931 | |
| 835 | - if (isStale) { | |
| 932 | + if (hasLanded) { | |
| 933 | + landed += 1 | |
| 934 | + } | |
| 935 | + | |
| 936 | + if (hasLanded && isPaneOpen) { | |
| 836 | 937 | scheduleRefresh(engine) |
| 837 | 938 | } |
| 838 | 939 | |
| 839 | - if (hasEdited) { | |
| 940 | + const isMainLoopEdit = hasEdited && e.agentId === undefined | |
| 941 | + | |
| 942 | + if (isMainLoopEdit) { | |
| 840 | 943 | void openOnFirstEdit(engine).catch(() => undefined) |
| 841 | 944 | } |
| 842 | 945 | } |
| @@ -853,7 +956,7 @@ | ||
| 853 | 956 | return result |
| 854 | 957 | } finally { |
| 855 | 958 | if (host) { |
| 856 | - afterTool(host, e.tool, result) | |
| 959 | + afterTool(host, e, result) | |
| 857 | 960 | } |
| 858 | 961 | } |
| 859 | 962 | }, |
| @@ -1,0 +1,35 @@ | ||
| 1 | +import type { SessionMessage, Settings } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What the world beneath a repository session holds and answers besides | |
| 5 | + * git: the store, the settings, the environment, the transcript, whether a | |
| 6 | + * pane is seated. | |
| 7 | + */ | |
| 8 | +export type Beneath = { | |
| 9 | + /** | |
| 10 | + * What the plugin's store holds at the start; nothing when not given. | |
| 11 | + */ | |
| 12 | + stored?: Readonly<Record<string, unknown>> | |
| 13 | + | |
| 14 | + /** | |
| 15 | + * What `$.settings.read` answers; empty when not given. | |
| 16 | + */ | |
| 17 | + settings?: Settings | |
| 18 | + | |
| 19 | + /** | |
| 20 | + * The variables `$.env.get` answers from; none set when not given. | |
| 21 | + */ | |
| 22 | + env?: Readonly<Record<string, string>> | |
| 23 | + | |
| 24 | + /** | |
| 25 | + * What `$.session.messages` answers each time it is read, as a resumed | |
| 26 | + * session's transcript already holds turns; empty when not given. | |
| 27 | + */ | |
| 28 | + messages?: () => readonly SessionMessage[] | |
| 29 | + | |
| 30 | + /** | |
| 31 | + * Whether an open made now is left waiting undrawn (LEFT_WAITING), as an | |
| 32 | + * engine leaves an unasked open on a narrow terminal; placed when not given. | |
| 33 | + */ | |
| 34 | + isLeftWaiting?: () => boolean | |
| 35 | +} |
| @@ -1,0 +1,5 @@ | ||
| 1 | +export type * from './beneath.js' | |
| 2 | +export * from './left-waiting.js' | |
| 3 | +export * from './slow-diff-ms.js' | |
| 4 | + | |
| 5 | +export * as default from '.' |
| @@ -1,0 +1,17 @@ | ||
| 1 | +import type { ResultOf } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What an engine answers an open it leaves waiting undrawn: the plugin | |
| 5 | + * opened unasked on a terminal narrower than an unrequested pane is given. | |
| 6 | + * | |
| 7 | + * Typed through `never` so it compiles against declarations that predate the | |
| 8 | + * answer, where `ui.open` resolves nothing. | |
| 9 | + */ | |
| 10 | +export const LEFT_WAITING: ResultOf['ui.open'] = { | |
| 11 | + value: { | |
| 12 | + isPlaced: false, | |
| 13 | + reason: | |
| 14 | + 'unasked below 144 columns (120 now): placed when the person opens ' + | |
| 15 | + 'it, or when the terminal is widened to 144 columns', | |
| 16 | + } as never, | |
| 17 | +} |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * How long a slow repository takes to answer `git diff` in the tests that | |
| 3 | + * time the pane's opening against its first fetch: well inside git's timeout. | |
| 4 | + */ | |
| 5 | +export const SLOW_DIFF_MS = 3000 |
| @@ -1,0 +1,14 @@ | ||
| 1 | +import type { SessionMessage } from 'claude-code' | |
| 2 | + | |
| 3 | +import { editPatchOf } from './edit-patch-of.js' | |
| 4 | +import TurnDiffsOf from './turn-diffs-of' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * A transcript a resumed session brings back whose one turn edited a file: | |
| 8 | + * the prompt, the Edit that landed, its result. | |
| 9 | + */ | |
| 10 | +export const EDITED_TRANSCRIPT: readonly SessionMessage[] = [ | |
| 11 | + TurnDiffsOf.promptOf('change a to b'), | |
| 12 | + TurnDiffsOf.editsOf(editPatchOf(['-a', '+b'])), | |
| 13 | + TurnDiffsOf.toolResults(), | |
| 14 | +] |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { Args, On } from 'claude-code' |
| 2 | 2 | import { mock } from 'claude-code/testing' |
| 3 | 3 | |
| 4 | +import Beneath from './beneath' | |
| 4 | 5 | import { gitIn } from './git-in.js' |
| 5 | 6 | import { HINT_DRAWN } from './hint-drawn.js' |
| 6 | 7 | import { keeping } from './keeping.js' |
| @@ -11,24 +12,26 @@ | ||
| 11 | 12 | * A session in a repository git answers for from a script (REPOSITORY, in |
| 12 | 13 | * /work, when none is given), keeping what the plugin does there. |
| 13 | 14 | * |
| 14 | - * Kept: each git run, ring move, pane opened or closed, status line. The clock | |
| 15 | - * starts at 0 and the engine draws the hint. A test that rewrites the | |
| 16 | - * script between calls changes what git answers next. | |
| 15 | + * Kept: each git run, ring move, pane opened, left waiting or closed, status | |
| 16 | + * line. The clock starts at 0 and the engine draws the hint; the rest of the | |
| 17 | + * world is the test's (Beneath). Rewriting the script changes git's answers. | |
| 17 | 18 | * |
| 18 | 19 | * @param on the test's `on` |
| 19 | 20 | * @param script git's output for each invocation whose line holds the key |
| 20 | - * @param stored what the plugin's store holds at the start | |
| 21 | - * @returns the runs, the ring's moves, the panes opened and closed, the | |
| 22 | - * statuses, the clock | |
| 21 | + * @param beneath the store, settings, environment, transcript, whether an | |
| 22 | + * open is seated | |
| 23 | + * @returns the runs, the ring's moves, the panes opened, left waiting and | |
| 24 | + * closed, the statuses, the clock | |
| 23 | 25 | */ |
| 24 | 26 | export function inRepository( |
| 25 | 27 | on: On, |
| 26 | 28 | script: Readonly<Record<string, string>> = REPOSITORY, |
| 27 | - stored: Readonly<Record<string, unknown>> = {}, | |
| 29 | + beneath: Beneath.Beneath = {}, | |
| 28 | 30 | ) { |
| 29 | 31 | const runs: Args<'process.run'>[] = [] |
| 30 | 32 | const focused: Args<'ui.focus'>[] = [] |
| 31 | 33 | const statuses: (string | undefined)[] = [] |
| 34 | + const waiting: Args<'ui.open'>[] = [] | |
| 32 | 35 | const opened = keeping<Args<'ui.open'>>() |
| 33 | 36 | const closed = keeping<Args<'ui.close'>>() |
| 34 | 37 | const clock = startsSession(on) |
| @@ -51,17 +54,31 @@ | ||
| 51 | 54 | return { value: undefined } |
| 52 | 55 | }) |
| 53 | 56 | |
| 54 | - on('ui.open', opened.hook) | |
| 57 | + on('ui.open', (engine, e) => { | |
| 58 | + const isWaiting = beneath.isLeftWaiting?.() === true | |
| 59 | + | |
| 60 | + if (!isWaiting) { | |
| 61 | + return opened.hook(engine, e) | |
| 62 | + } | |
| 63 | + | |
| 64 | + waiting.push(e) | |
| 65 | + | |
| 66 | + return Beneath.LEFT_WAITING | |
| 67 | + }) | |
| 68 | + | |
| 55 | 69 | on('ui.close', closed.hook) |
| 56 | 70 | on('ui.invalidate', () => ({ value: undefined })) |
| 57 | 71 | on('ui.render', { component: 'PromptHint' }, () => HINT_DRAWN) |
| 58 | - on('session.messages', () => ({ value: [] })) | |
| 59 | - mock.store(on, stored) | |
| 72 | + on('session.messages', () => ({ value: [...(beneath.messages?.() ?? [])] })) | |
| 73 | + on('settings.read', () => ({ value: beneath.settings ?? {} })) | |
| 74 | + mock.store(on, beneath.stored ?? {}) | |
| 75 | + mock.env(on, beneath.env ?? {}) | |
| 60 | 76 | |
| 61 | 77 | return { |
| 62 | 78 | runs, |
| 63 | 79 | focused, |
| 64 | 80 | opened: opened.kept, |
| 81 | + waiting, | |
| 65 | 82 | closed: closed.kept, |
| 66 | 83 | statuses, |
| 67 | 84 | clock, |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | export * from './answers-of.js' |
| 2 | 2 | export * from './ask' |
| 3 | 3 | export * from './backend-of' |
| 4 | +export * from './beneath' | |
| 4 | 5 | export * from './big-lines.js' |
| 5 | 6 | export * from './branch-base-of' |
| 6 | 7 | export * from './builtin-holds.js' |
| @@ -12,6 +13,7 @@ | ||
| 12 | 13 | export * from './diff.js' |
| 13 | 14 | export * from './diff-at.js' |
| 14 | 15 | export * from './edit-patch-of.js' |
| 16 | +export * from './edited-transcript.js' | |
| 15 | 17 | export * from './fetch-diff' |
| 16 | 18 | export * from './git-hung.js' |
| 17 | 19 | export * from './git-in.js' |
| @@ -42,6 +44,7 @@ | ||
| 42 | 44 | export * from './renamed.js' |
| 43 | 45 | export * from './repository.js' |
| 44 | 46 | export * from './repository-of' |
| 47 | +export * from './resume.js' | |
| 45 | 48 | export * from './ring-onto.js' |
| 46 | 49 | export * from './scripted' |
| 47 | 50 | export * from './session.js' |
| @@ -54,6 +57,7 @@ | ||
| 54 | 57 | export * from './two-files.js' |
| 55 | 58 | export * from './typed-prompt-of.js' |
| 56 | 59 | export * from './unsaid-hint.js' |
| 60 | +export * from './usage-at.js' | |
| 57 | 61 | export * from './vs-main.js' |
| 58 | 62 | export * from './wheel-over-list.js' |
| 59 | 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' |
| @@ -483,18 +483,372 @@ | ||
| 483 | 483 | ).toEqual(['diff']) |
| 484 | 484 | }) |
| 485 | 485 | |
| 486 | - test('/clear closes the pane it finds open', async ($, on) => { | |
| 486 | + test("only the main loop's edit opens the pane", async ($, on) => { | |
| 487 | 487 | const world = Fixtures.inRepository(on) |
| 488 | 488 | |
| 489 | + const edit = (agentId?: string) => | |
| 490 | + $.tool.call({ | |
| 491 | + tool: 'Write', | |
| 492 | + file_path: '/work/app.ts', | |
| 493 | + content: '2', | |
| 494 | + ...(agentId !== undefined && { agentId }), | |
| 495 | + }) | |
| 496 | + | |
| 497 | + on('tool.call', () => ({ result: 'written' })) | |
| 498 | + | |
| 499 | + await $.session.start(Fixtures.SESSION) | |
| 500 | + await $.ui.render(Fixtures.HINT) | |
| 501 | + await edit('a-explorer') | |
| 502 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 503 | + | |
| 504 | + expect(world.opened, 'the built-in checkpoints no subagent edit').toEqual( | |
| 505 | + [], | |
| 506 | + ) | |
| 507 | + | |
| 508 | + expect(world.runs, 'so nothing was asked of the repository').toEqual([]) | |
| 509 | + | |
| 510 | + await edit() | |
| 511 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 512 | + | |
| 513 | + expect( | |
| 514 | + world.opened.map(pane => pane.id), | |
| 515 | + "the main loop's", | |
| 516 | + ).toEqual(['diff']) | |
| 517 | + }) | |
| 518 | + | |
| 519 | + test('checkpointing off in settings: edits open nothing', async ($, on) => { | |
| 520 | + const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { | |
| 521 | + settings: { fileCheckpointingEnabled: false }, | |
| 522 | + }) | |
| 523 | + | |
| 524 | + on('tool.call', () => ({ result: 'edited' })) | |
| 525 | + | |
| 526 | + await $.session.start(Fixtures.SESSION) | |
| 527 | + await $.ui.render(Fixtures.HINT) | |
| 528 | + | |
| 529 | + await $.tool.call({ | |
| 530 | + tool: 'Edit', | |
| 531 | + file_path: '/work/app.ts', | |
| 532 | + old_string: '1', | |
| 533 | + new_string: '2', | |
| 534 | + }) | |
| 535 | + | |
| 536 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 537 | + | |
| 538 | + expect(world.opened, 'the built-in opens on a checkpoint alone').toEqual([]) | |
| 539 | + | |
| 540 | + expect(world.runs, 'so nothing was asked of the repository').toEqual([]) | |
| 541 | + | |
| 542 | + expect(await $.command.run(Fixtures.DIFF)).toEqual({ | |
| 543 | + text: 'Diff panel shown', | |
| 544 | + }) | |
| 545 | + }) | |
| 546 | + | |
| 547 | + test('checkpointing off by variable: edits open nothing', async ($, on) => { | |
| 548 | + const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { | |
| 549 | + env: { CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING: 'true' }, | |
| 550 | + }) | |
| 551 | + | |
| 552 | + on('tool.call', () => ({ result: 'edited' })) | |
| 553 | + | |
| 554 | + await $.session.start(Fixtures.SESSION) | |
| 555 | + await $.ui.render(Fixtures.HINT) | |
| 556 | + | |
| 557 | + await $.tool.call({ | |
| 558 | + tool: 'Edit', | |
| 559 | + file_path: '/work/app.ts', | |
| 560 | + old_string: '1', | |
| 561 | + new_string: '2', | |
| 562 | + }) | |
| 563 | + | |
| 564 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 565 | + | |
| 566 | + expect(world.opened).toEqual([]) | |
| 567 | + }) | |
| 568 | + | |
| 569 | + test('an open left waiting is withdrawn; /diff opens it', async ($, on) => { | |
| 570 | + let isNarrow = true | |
| 571 | + | |
| 572 | + const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { | |
| 573 | + stored: { [Names.STORE_OPEN_KEY]: true }, | |
| 574 | + isLeftWaiting: () => isNarrow, | |
| 575 | + }) | |
| 576 | + | |
| 577 | + const edit = () => | |
| 578 | + $.tool.call({ | |
| 579 | + tool: 'Edit', | |
| 580 | + file_path: '/work/app.ts', | |
| 581 | + old_string: '1', | |
| 582 | + new_string: '2', | |
| 583 | + }) | |
| 584 | + | |
| 585 | + on('tool.call', () => ({ result: 'edited' })) | |
| 586 | + | |
| 587 | + await $.session.start(Fixtures.SESSION) | |
| 588 | + await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)) | |
| 589 | + await edit() | |
| 590 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 591 | + | |
| 592 | + expect( | |
| 593 | + world.waiting.map(pane => pane.id), | |
| 594 | + 'the edit opened', | |
| 595 | + ).toEqual(['diff']) | |
| 596 | + | |
| 597 | + expect( | |
| 598 | + world.closed.map(pane => pane.id), | |
| 599 | + 'and withdrew the pane the engine left waiting, so no resize seats it', | |
| 600 | + ).toEqual(['diff']) | |
| 601 | + | |
| 602 | + expect( | |
| 603 | + world.runs.map(run => Fixtures.gitWordOf(run.argv)).slice(0, 2), | |
| 604 | + 'the repository was found and read once before the open, as the ' + | |
| 605 | + 'built-in primes; nothing polls for a pane no one sees', | |
| 606 | + ).toEqual(['rev-parse --show-toplevel', 'status']) | |
| 607 | + | |
| 608 | + expect( | |
| 609 | + world.runs.map(run => Fixtures.gitWordOf(run.argv)), | |
| 610 | + 'no HEAD poll for a withdrawn pane', | |
| 611 | + ).not.toContain(Fixtures.POLL_WORD) | |
| 612 | + | |
| 613 | + isNarrow = false | |
| 614 | + | |
| 615 | + expect( | |
| 616 | + await $.command.run(Fixtures.diffAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)), | |
| 617 | + '/diff opens it, where a pane believed open would have been hidden', | |
| 618 | + ).toEqual({ text: 'Diff panel shown' }) | |
| 619 | + | |
| 620 | + await $.command.run(Fixtures.diffAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)) | |
| 621 | + await edit() | |
| 622 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 623 | + | |
| 624 | + expect( | |
| 625 | + world.opened.map(pane => pane.id), | |
| 626 | + 'closed by hand, no edit opens it again', | |
| 627 | + ).toEqual(['diff']) | |
| 628 | + }) | |
| 629 | + | |
| 630 | + test('an unplaced open is tried again at the next edit', async ($, on) => { | |
| 631 | + let isNarrow = true | |
| 632 | + | |
| 633 | + const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { | |
| 634 | + stored: { [Names.STORE_OPEN_KEY]: true }, | |
| 635 | + isLeftWaiting: () => isNarrow, | |
| 636 | + }) | |
| 637 | + | |
| 638 | + const edit = () => | |
| 639 | + $.tool.call({ | |
| 640 | + tool: 'Edit', | |
| 641 | + file_path: '/work/app.ts', | |
| 642 | + old_string: '1', | |
| 643 | + new_string: '2', | |
| 644 | + }) | |
| 645 | + | |
| 646 | + on('tool.call', () => ({ result: 'edited' })) | |
| 647 | + | |
| 648 | + await $.session.start(Fixtures.SESSION) | |
| 649 | + await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)) | |
| 650 | + await edit() | |
| 651 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 652 | + | |
| 653 | + expect(world.opened, 'the first try waited and was withdrawn').toEqual([]) | |
| 654 | + | |
| 655 | + isNarrow = false | |
| 656 | + | |
| 657 | + await edit() | |
| 658 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 659 | + | |
| 660 | + expect( | |
| 661 | + world.opened.map(pane => pane.id), | |
| 662 | + 'the next edit opened it, as the built-in reads the width again then', | |
| 663 | + ).toEqual(['diff']) | |
| 664 | + }) | |
| 665 | + | |
| 666 | + test('a docked pane opens once its first fetch settled', async ($, on) => { | |
| 667 | + const opened: string[] = [] | |
| 668 | + const clock = Fixtures.startsSession(on) | |
| 669 | + | |
| 670 | + on('process.run', async ($, e) => { | |
| 671 | + if (e.argv.includes('--shortstat')) { | |
| 672 | + await clock.sleep(Fixtures.SLOW_DIFF_MS) | |
| 673 | + } | |
| 674 | + | |
| 675 | + return { value: Fixtures.gitIn(e.argv) } | |
| 676 | + }) | |
| 677 | + | |
| 678 | + on('ui.open', ($, e) => { | |
| 679 | + opened.push(e.id) | |
| 680 | + | |
| 681 | + return { value: undefined } | |
| 682 | + }) | |
| 683 | + | |
| 684 | + on('ui.close', () => ({ value: undefined })) | |
| 685 | + on('ui.invalidate', () => ({ value: undefined })) | |
| 686 | + on('ui.render', { component: 'PromptHint' }, () => Fixtures.HINT_DRAWN) | |
| 687 | + on('session.messages', () => ({ value: [] })) | |
| 688 | + on('settings.read', () => ({ value: {} })) | |
| 689 | + on('tool.call', () => ({ result: 'edited' })) | |
| 690 | + mock.store(on) | |
| 691 | + mock.env(on, {}) | |
| 692 | + | |
| 693 | + await $.session.start(Fixtures.SESSION) | |
| 694 | + await $.ui.render(Fixtures.HINT) | |
| 695 | + | |
| 696 | + await $.tool.call({ | |
| 697 | + tool: 'Edit', | |
| 698 | + file_path: '/work/app.ts', | |
| 699 | + old_string: '1', | |
| 700 | + new_string: '2', | |
| 701 | + }) | |
| 702 | + | |
| 703 | + await clock.advance(Fixtures.SLOW_DIFF_MS - 1) | |
| 704 | + | |
| 705 | + expect(opened, 'git has not answered: no pane, no Loading frame').toEqual( | |
| 706 | + [], | |
| 707 | + ) | |
| 708 | + | |
| 709 | + await clock.advance(Fixtures.SETTLE_MS) | |
| 710 | + | |
| 711 | + expect(opened, 'the fetch settled: the pane opens filled').toEqual(['diff']) | |
| 712 | + | |
| 713 | + const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE)) | |
| 714 | + | |
| 715 | + expect(drawn).toContain('1 file changed') | |
| 716 | + expect(drawn).not.toContain('Loading diff') | |
| 717 | + }) | |
| 718 | + | |
| 719 | + test('/clear leaves the pane it finds open up and reads the repository afresh', async ($, on) => { | |
| 720 | + const world = Fixtures.inRepository(on) | |
| 721 | + | |
| 489 | 722 | on('command.run', { command: 'clear' }, () => ({})) |
| 490 | 723 | |
| 491 | 724 | await $.session.start(Fixtures.SESSION) |
| 492 | 725 | await $.command.run(Fixtures.DIFF) |
| 726 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 727 | + | |
| 728 | + const read = world.runs.length | |
| 729 | + | |
| 493 | 730 | await $.command.run(Fixtures.CLEAR) |
| 731 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 494 | 732 | |
| 495 | - expect(world.closed.map(pane => pane.id)).toEqual(['diff']) | |
| 733 | + expect(world.closed, 'as the built-in panel stays across /clear').toEqual( | |
| 734 | + [], | |
| 735 | + ) | |
| 736 | + | |
| 737 | + expect( | |
| 738 | + world.runs.slice(read).map(run => Fixtures.gitWordOf(run.argv))[0], | |
| 739 | + 'the repository found again for the conversation that starts over', | |
| 740 | + ).toBe('rev-parse --show-toplevel') | |
| 741 | + | |
| 742 | + expect( | |
| 743 | + Fixtures.textOf(await $.ui.render(Fixtures.PANE)), | |
| 744 | + 'drawn from the fresh read', | |
| 745 | + ).toContain('1 file changed') | |
| 496 | 746 | }) |
| 497 | 747 | |
| 748 | + test('a resumed session whose turns edited opens the pane before any new edit', async ($, on) => { | |
| 749 | + const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { | |
| 750 | + messages: () => Fixtures.EDITED_TRANSCRIPT, | |
| 751 | + }) | |
| 752 | + | |
| 753 | + await $.session.start(Fixtures.SESSION) | |
| 754 | + | |
| 755 | + expect(world.runs, 'the start itself still runs no git').toEqual([]) | |
| 756 | + | |
| 757 | + await $.ui.render(Fixtures.HINT) | |
| 758 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 759 | + | |
| 760 | + expect( | |
| 761 | + world.opened.map(pane => pane.id), | |
| 762 | + 'open once the width is known, as the built-in opens on the restore', | |
| 763 | + ).toEqual(['diff']) | |
| 764 | + }) | |
| 765 | + | |
| 766 | + test('a resumed session opens nothing where its first edit would not', async ($, on) => { | |
| 767 | + const narrow = Fixtures.inRepository(on, Fixtures.REPOSITORY, { | |
| 768 | + messages: () => Fixtures.EDITED_TRANSCRIPT, | |
| 769 | + }) | |
| 770 | + | |
| 771 | + await $.session.start(Fixtures.SESSION) | |
| 772 | + await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)) | |
| 773 | + await narrow.clock.advance(Fixtures.SETTLE_MS) | |
| 774 | + | |
| 775 | + expect(narrow.opened, 'under the unasked floor, never kept open').toEqual( | |
| 776 | + [], | |
| 777 | + ) | |
| 778 | + | |
| 779 | + expect(narrow.runs, 'and no git for it').toEqual([]) | |
| 780 | + }) | |
| 781 | + | |
| 782 | + test('a resumed session the person kept the pane open in opens it from the lower floor', async ($, on) => { | |
| 783 | + const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { | |
| 784 | + messages: () => Fixtures.EDITED_TRANSCRIPT, | |
| 785 | + stored: { [Names.STORE_OPEN_KEY]: true }, | |
| 786 | + }) | |
| 787 | + | |
| 788 | + await $.session.start(Fixtures.SESSION) | |
| 789 | + await $.ui.render(Fixtures.hintAt(Limits.OPEN_MIN_COLUMNS)) | |
| 790 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 791 | + | |
| 792 | + expect(world.opened.map(pane => pane.id)).toEqual(['diff']) | |
| 793 | + }) | |
| 794 | + | |
| 795 | + test('a resumed session with no edits opens nothing', async ($, on) => { | |
| 796 | + const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { | |
| 797 | + messages: () => [Fixtures.promptOf('just talk')], | |
| 798 | + }) | |
| 799 | + | |
| 800 | + await $.session.start(Fixtures.SESSION) | |
| 801 | + await $.ui.render(Fixtures.HINT) | |
| 802 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 803 | + | |
| 804 | + expect(world.opened).toEqual([]) | |
| 805 | + expect(world.runs).toEqual([]) | |
| 806 | + }) | |
| 807 | + | |
| 808 | + test('a resumed session whose pane the person closed opens nothing', async ($, on) => { | |
| 809 | + const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { | |
| 810 | + messages: () => Fixtures.EDITED_TRANSCRIPT, | |
| 811 | + stored: { [Names.STORE_OPEN_KEY]: false }, | |
| 812 | + }) | |
| 813 | + | |
| 814 | + await $.session.start(Fixtures.SESSION) | |
| 815 | + await $.ui.render(Fixtures.HINT) | |
| 816 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 817 | + | |
| 818 | + expect(world.opened).toEqual([]) | |
| 819 | + expect(world.runs).toEqual([]) | |
| 820 | + }) | |
| 821 | + | |
| 822 | + test('/resume closes the pane, then opens it for the turns it brought back', async ($, on) => { | |
| 823 | + let transcript: readonly SessionMessage[] = [] | |
| 824 | + | |
| 825 | + const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { | |
| 826 | + messages: () => transcript, | |
| 827 | + }) | |
| 828 | + | |
| 829 | + on('command.run', { command: 'resume' }, () => { | |
| 830 | + transcript = Fixtures.EDITED_TRANSCRIPT | |
| 831 | + | |
| 832 | + return {} | |
| 833 | + }) | |
| 834 | + | |
| 835 | + await $.session.start(Fixtures.SESSION) | |
| 836 | + await $.ui.render(Fixtures.HINT) | |
| 837 | + await $.command.run(Fixtures.DIFF) | |
| 838 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 839 | + await $.command.run(Fixtures.RESUME) | |
| 840 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 841 | + | |
| 842 | + expect(world.closed.map(pane => pane.id), 'closed at /resume').toEqual([ | |
| 843 | + 'diff', | |
| 844 | + ]) | |
| 845 | + | |
| 846 | + expect( | |
| 847 | + world.opened.map(pane => pane.id), | |
| 848 | + '/diff opened it, /resume opened it again for the restored turns', | |
| 849 | + ).toEqual(['diff', 'diff']) | |
| 850 | + }) | |
| 851 | + | |
| 498 | 852 | test('ask attaches the hunks on screen and calls no tool', async ($, on) => { |
| 499 | 853 | const world = Fixtures.inRepository(on, Fixtures.oneSecret()) |
| 500 | 854 | const called: Args<'tool.call'>[] = [] |
| @@ -563,7 +917,7 @@ | ||
| 563 | 917 | |
| 564 | 918 | test('a worktree opens on the base it kept', async ($, on) => { |
| 565 | 919 | const world = Fixtures.inRepository(on, Fixtures.oneSecret(), { |
| 566 | - 'base:/main/wt': 'uncommitted', | |
| 920 | + stored: { 'base:/main/wt': 'uncommitted' }, | |
| 567 | 921 | }) |
| 568 | 922 | |
| 569 | 923 | await $.session.start(Fixtures.WORKTREE_SESSION) |
| @@ -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 | } |
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.
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.